added guardian login with views for their assigned childs
This commit is contained in:
@@ -1,333 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../push/push_secure_storage.dart';
|
||||
import '../utils/exponential_backoff.dart';
|
||||
|
||||
class AccountData {
|
||||
static const _usernameField = 'username';
|
||||
static const _passwordField = 'password';
|
||||
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
||||
// Notification Service Extension can authenticate Nextcloud calls too.
|
||||
// The talk password authenticates the second (apptype=talk) push
|
||||
// registration — Nextcloud binds each push subscription to its session
|
||||
// token, so two registrations need two app passwords.
|
||||
static const _appPasswordField = 'nextcloud_app_password';
|
||||
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
|
||||
// Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA):
|
||||
// the real password is not valid against Nextcloud, only the flow-issued
|
||||
// app password is — and no further app passwords can be minted silently.
|
||||
static const _loginFlowField = 'nextcloud_login_flow';
|
||||
// Persists the demo session across cold starts (see DemoMode).
|
||||
static const _demoField = 'is_demo';
|
||||
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
||||
static const _demoPasswordPlaceholder = 'demo';
|
||||
|
||||
// `first_unlock` so a background launch on a locked device (silent push,
|
||||
// BGAppRefresh) can still read the session. Items written by older versions
|
||||
// carry the plugin default `unlocked` and are invisible to this instance
|
||||
// until _migrateKeychainAccessibility moved them over.
|
||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
|
||||
);
|
||||
static const List<String> _sessionFields = [
|
||||
_usernameField,
|
||||
_passwordField,
|
||||
_demoField,
|
||||
_loginFlowField,
|
||||
];
|
||||
|
||||
static final AccountData _instance = AccountData._construct();
|
||||
Completer<void> _populated = Completer();
|
||||
|
||||
factory AccountData() => _instance;
|
||||
|
||||
AccountData._construct() {
|
||||
unawaited(_loadWithRetry());
|
||||
}
|
||||
|
||||
String? _username;
|
||||
String? _password;
|
||||
String? _appPassword;
|
||||
String? _appPasswordTalk;
|
||||
bool _isDemo = false;
|
||||
bool _usesLoginFlow = false;
|
||||
|
||||
/// True while the active session is a local demo session (see DemoMode).
|
||||
bool get isDemo => _isDemo;
|
||||
|
||||
/// True when the Nextcloud credentials were obtained via Login Flow v2
|
||||
/// (browser login, e.g. because the account has two-factor authentication).
|
||||
/// In that mode the stored real password only authenticates MarianumConnect;
|
||||
/// every Nextcloud call must use the flow-issued app password.
|
||||
bool get usesLoginFlow => _usesLoginFlow;
|
||||
|
||||
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<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();
|
||||
}
|
||||
|
||||
/// Enters a local demo session for [username] — no real credentials or token;
|
||||
/// every backend is served from fixtures while [isDemo] is true (see DemoMode).
|
||||
Future<void> setDemo(String username) async {
|
||||
await _secureStorage.write(key: _usernameField, value: username);
|
||||
await _secureStorage.write(
|
||||
key: _passwordField,
|
||||
value: _demoPasswordPlaceholder,
|
||||
);
|
||||
await _secureStorage.write(key: _demoField, value: 'true');
|
||||
_username = username;
|
||||
_password = _demoPasswordPlaceholder;
|
||||
_isDemo = true;
|
||||
if (!_populated.isCompleted) _populated.complete();
|
||||
}
|
||||
|
||||
Future<void> removeData() async {
|
||||
_populated = Completer();
|
||||
_username = null;
|
||||
_password = null;
|
||||
_appPassword = null;
|
||||
_appPasswordTalk = null;
|
||||
_isDemo = false;
|
||||
_usesLoginFlow = false;
|
||||
await _secureStorage.delete(key: _usernameField);
|
||||
await _secureStorage.delete(key: _passwordField);
|
||||
await _secureStorage.delete(key: _demoField);
|
||||
await _secureStorage.delete(key: _loginFlowField);
|
||||
await _clearAppPasswordStorage();
|
||||
await _clearAppPasswordTalkStorage();
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// Adopts an app password obtained via Login Flow v2 and switches the
|
||||
/// account into flow mode (see [usesLoginFlow]). Any previously stored Talk
|
||||
/// app password belonged to the old session era and is dropped — the second
|
||||
/// (optional) flow pass stores a fresh one via [setAppPasswordTalk].
|
||||
Future<void> setLoginFlow(String appPassword) async {
|
||||
await setAppPassword(appPassword);
|
||||
await clearAppPasswordTalk();
|
||||
_usesLoginFlow = true;
|
||||
await _secureStorage.write(key: _loginFlowField, value: 'true');
|
||||
}
|
||||
|
||||
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
|
||||
|
||||
/// Persists the app password backing the Talk push registration.
|
||||
Future<void> setAppPasswordTalk(String appPassword) async {
|
||||
_appPasswordTalk = appPassword;
|
||||
try {
|
||||
await pushSecureStorage.write(
|
||||
key: _appPasswordTalkField,
|
||||
value: appPassword,
|
||||
);
|
||||
} on Object {
|
||||
// Group-scoped keystore may be unavailable — in-memory still works for
|
||||
// this session, matching setAppPassword.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAppPasswordTalk() async {
|
||||
_appPasswordTalk = null;
|
||||
await _clearAppPasswordTalkStorage();
|
||||
}
|
||||
|
||||
bool hasAppPasswordTalk() =>
|
||||
_appPasswordTalk != null && _appPasswordTalk!.isNotEmpty;
|
||||
|
||||
Future<void> _clearAppPasswordStorage() async {
|
||||
try {
|
||||
await pushSecureStorage.delete(key: _appPasswordField);
|
||||
} on Object {
|
||||
// ignore — nothing stored or keystore unavailable
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAppPasswordTalkStorage() async {
|
||||
try {
|
||||
await pushSecureStorage.delete(key: _appPasswordTalkField);
|
||||
} on Object {
|
||||
// ignore — nothing stored or keystore unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS keychain reads fail while protected data is unavailable (app launch
|
||||
/// racing the unlock, background wake on a locked device). Without a retry
|
||||
/// the completer never resolved and the app stayed on the launch screen.
|
||||
Future<void> _loadWithRetry() async {
|
||||
for (var attempt = 1; !_populated.isCompleted; attempt++) {
|
||||
try {
|
||||
await _migrateAndLoad();
|
||||
return;
|
||||
} catch (e, s) {
|
||||
log('AccountData load failed (attempt $attempt): $e', stackTrace: s);
|
||||
await Future<void>.delayed(exponentialBackoff(attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops waiting for the stored session; the app then behaves as logged
|
||||
/// out. The keychain entries stay untouched so a later start can still
|
||||
/// restore the session.
|
||||
void abandonLoad() {
|
||||
if (!_populated.isCompleted) _populated.complete();
|
||||
}
|
||||
|
||||
Future<void> _migrateAndLoad() async {
|
||||
await _migrateFromLegacyStorage();
|
||||
await _migrateKeychainAccessibility();
|
||||
_username = await _secureStorage.read(key: _usernameField);
|
||||
_password = await _secureStorage.read(key: _passwordField);
|
||||
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
||||
_usesLoginFlow =
|
||||
(await _secureStorage.read(key: _loginFlowField)) == 'true';
|
||||
try {
|
||||
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
||||
_appPasswordTalk = await pushSecureStorage.read(
|
||||
key: _appPasswordTalkField,
|
||||
);
|
||||
} on Object {
|
||||
_appPassword = null;
|
||||
_appPasswordTalk = 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<void> _migrateKeychainAccessibility() async {
|
||||
if (!Platform.isIOS) return;
|
||||
for (final field in _sessionFields) {
|
||||
final value = await _legacySecureStorage.read(key: field);
|
||||
if (value == null) continue;
|
||||
// Same account+service: the legacy item has to go before the re-add.
|
||||
await _legacySecureStorage.delete(key: field);
|
||||
await _secureStorage.write(key: field, value: value);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> waitForPopulation() async {
|
||||
await _populated.future;
|
||||
return isPopulated();
|
||||
}
|
||||
|
||||
/// True once the stored session has been read (or given up on).
|
||||
bool get isLoaded => _populated.isCompleted;
|
||||
|
||||
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() {
|
||||
_requirePopulated();
|
||||
// Prefer the scoped app password once available; it survives real-password
|
||||
// rotation and is what the push-v2 registration is bound to.
|
||||
return _basicAuth(_appPassword ?? _password!);
|
||||
}
|
||||
|
||||
/// Basic-auth header using the Talk app password — authenticates the
|
||||
/// apptype=talk push registration (and its unregister). Throws when the
|
||||
/// talk password has not been minted yet; callers treat that as a failed
|
||||
/// talk registration and retry on the next start.
|
||||
String getTalkBasicAuthHeader() {
|
||||
_requirePopulated();
|
||||
if (!hasAppPasswordTalk()) {
|
||||
// Login-flow account whose second (talk) flow pass was skipped: no
|
||||
// silent minting possible, the talk registration shares the single
|
||||
// flow-issued credential.
|
||||
if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!);
|
||||
throw StateError('Talk app password not available yet');
|
||||
}
|
||||
return _basicAuth(_appPasswordTalk!);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
_requirePopulated();
|
||||
return _basicAuth(_password!);
|
||||
}
|
||||
|
||||
/// Secret authenticating against Nextcloud: the app password once available
|
||||
/// (minted or flow-issued), otherwise the real password. Mirrors the
|
||||
/// preference of [getBasicAuthHeader] for clients that need the raw secret
|
||||
/// (WebDAV client construction).
|
||||
String getNextcloudSecret() {
|
||||
_requirePopulated();
|
||||
return _appPassword ?? _password!;
|
||||
}
|
||||
|
||||
void _requirePopulated() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
'AccountData (e.g. username or password) is not initialized!',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _basicAuth(String secret) =>
|
||||
'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
||||
|
||||
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
|
||||
/// header map ready to merge into HTTP request headers.
|
||||
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
|
||||
}
|
||||
Reference in New Issue
Block a user