168 lines
5.7 KiB
Dart
168 lines
5.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../push/push_secure_storage.dart';
|
|
|
|
class AccountData {
|
|
static const _usernameField = 'username';
|
|
static const _passwordField = 'password';
|
|
// App password lives in the push-shared (group-scoped) keystore so the iOS
|
|
// Notification Service Extension can authenticate Nextcloud calls too.
|
|
static const _appPasswordField = 'nextcloud_app_password';
|
|
|
|
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
|
|
|
static final AccountData _instance = AccountData._construct();
|
|
Completer<void> _populated = Completer();
|
|
|
|
factory AccountData() => _instance;
|
|
|
|
AccountData._construct() {
|
|
_migrateAndLoad();
|
|
}
|
|
|
|
String? _username;
|
|
String? _password;
|
|
String? _appPassword;
|
|
|
|
String getUsername() {
|
|
if (_username == null) throw Exception('Username not initialized');
|
|
return _username!;
|
|
}
|
|
|
|
String getPassword() {
|
|
if (_password == null) throw Exception('Password not initialized');
|
|
return _password!;
|
|
}
|
|
|
|
String getUserSecret() => sha512
|
|
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
|
|
.toString();
|
|
|
|
Future<String> getDeviceId() async => sha512
|
|
.convert(
|
|
utf8.encode(
|
|
'${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}',
|
|
),
|
|
)
|
|
.toString();
|
|
|
|
Future<void> setData(String username, String password) async {
|
|
await _secureStorage.write(key: _usernameField, value: username);
|
|
await _secureStorage.write(key: _passwordField, value: password);
|
|
_username = username;
|
|
_password = password;
|
|
if (!_populated.isCompleted) _populated.complete();
|
|
}
|
|
|
|
Future<void> removeData() async {
|
|
_populated = Completer();
|
|
_username = null;
|
|
_password = null;
|
|
_appPassword = null;
|
|
await _secureStorage.delete(key: _usernameField);
|
|
await _secureStorage.delete(key: _passwordField);
|
|
await _clearAppPasswordStorage();
|
|
}
|
|
|
|
/// Persists a freshly minted Nextcloud app password. After this every
|
|
/// [getBasicAuthHeader] call authenticates with the app password instead of
|
|
/// the real password.
|
|
Future<void> setAppPassword(String appPassword) async {
|
|
_appPassword = appPassword;
|
|
try {
|
|
await pushSecureStorage.write(key: _appPasswordField, value: appPassword);
|
|
} on Object {
|
|
// Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet
|
|
// provisioned). Keeping it in memory still lets this session use it.
|
|
}
|
|
}
|
|
|
|
Future<void> clearAppPassword() async {
|
|
_appPassword = null;
|
|
await _clearAppPasswordStorage();
|
|
}
|
|
|
|
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
|
|
|
|
Future<void> _clearAppPasswordStorage() async {
|
|
try {
|
|
await pushSecureStorage.delete(key: _appPasswordField);
|
|
} on Object {
|
|
// ignore — nothing stored or keystore unavailable
|
|
}
|
|
}
|
|
|
|
Future<void> _migrateAndLoad() async {
|
|
await _migrateFromLegacyStorage();
|
|
_username = await _secureStorage.read(key: _usernameField);
|
|
_password = await _secureStorage.read(key: _passwordField);
|
|
try {
|
|
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
|
} on Object {
|
|
_appPassword = null;
|
|
}
|
|
if (!_populated.isCompleted) _populated.complete();
|
|
}
|
|
|
|
// Move credentials from the old SharedPreferences plain-text storage into the
|
|
// platform's secure keystore. Run once per install and clear the legacy keys.
|
|
Future<void> _migrateFromLegacyStorage() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final legacyUsername = prefs.getString(_usernameField);
|
|
final legacyPassword = prefs.getString(_passwordField);
|
|
if (legacyUsername == null || legacyPassword == null) return;
|
|
|
|
final hasSecure = (await _secureStorage.read(key: _usernameField)) != null;
|
|
if (!hasSecure) {
|
|
await _secureStorage.write(key: _usernameField, value: legacyUsername);
|
|
await _secureStorage.write(key: _passwordField, value: legacyPassword);
|
|
}
|
|
await prefs.remove(_usernameField);
|
|
await prefs.remove(_passwordField);
|
|
}
|
|
|
|
Future<bool> waitForPopulation() async {
|
|
await _populated.future;
|
|
return isPopulated();
|
|
}
|
|
|
|
bool isPopulated() => _username != null && _password != null;
|
|
|
|
/// Returns the value for an HTTP `Authorization` header using HTTP Basic.
|
|
/// Prefer this over embedding credentials in URLs — error logs and crash
|
|
/// reports often capture the URL but not headers.
|
|
String getBasicAuthHeader() {
|
|
if (!isPopulated()) {
|
|
throw Exception(
|
|
'AccountData (e.g. username or password) is not initialized!',
|
|
);
|
|
}
|
|
// Prefer the scoped app password once available; it survives real-password
|
|
// rotation and is what the push-v2 registration is bound to.
|
|
final secret = _appPassword ?? _password;
|
|
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
|
}
|
|
|
|
/// Basic-auth header that always uses the real password. Needed exactly once,
|
|
/// to mint the app password via `core/getapppassword` (an app password cannot
|
|
/// mint another).
|
|
String getRealPasswordBasicAuthHeader() {
|
|
if (!isPopulated()) {
|
|
throw Exception(
|
|
'AccountData (e.g. username or password) is not initialized!',
|
|
);
|
|
}
|
|
return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}';
|
|
}
|
|
|
|
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
|
|
/// header map ready to merge into HTTP request headers.
|
|
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
|
|
}
|