368 lines
14 KiB
Dart
368 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../api/marianumconnect/auth/token_storage.dart';
|
|
import '../push/push_secure_storage.dart';
|
|
import '../utils/exponential_backoff.dart';
|
|
import '../utils/random_id.dart';
|
|
import 'account_codec.dart';
|
|
import 'nextcloud_credentials.dart';
|
|
import 'session.dart';
|
|
import 'session_codec.dart';
|
|
|
|
/// Owns the active [Session] and its persistence. One instance per isolate;
|
|
/// the widget background isolate reads the same keychain.
|
|
///
|
|
/// Several accounts can be signed in; the frozen [SessionKeys] slots, the MC
|
|
/// token and the group-keychain app passwords always hold the *active* one
|
|
/// (native code and the background isolate read only those). Inactive
|
|
/// accounts are parked in a per-account vault entry.
|
|
class SessionManager {
|
|
// `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 = [
|
|
SessionKeys.kind,
|
|
SessionKeys.username,
|
|
SessionKeys.password,
|
|
SessionKeys.guardianEmail,
|
|
SessionKeys.demo,
|
|
SessionKeys.loginFlow,
|
|
];
|
|
|
|
static const _indexKey = 'accounts_index';
|
|
static String _vaultKey(String id) => 'account_vault_$id';
|
|
static const _tokenStorage = MarianumConnectTokenStorage();
|
|
|
|
static final SessionManager _instance = SessionManager._();
|
|
factory SessionManager() => _instance;
|
|
|
|
SessionManager._() {
|
|
unawaited(_loadWithRetry());
|
|
}
|
|
|
|
Completer<void> _loaded = Completer();
|
|
Session? _current;
|
|
|
|
Session? get current => _current;
|
|
|
|
/// Every signed-in account and which one is active.
|
|
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
|
|
AccountIndex.empty,
|
|
);
|
|
|
|
AccountEntry? get activeAccount => accounts.value.active;
|
|
|
|
bool get isSignedIn => _current != null;
|
|
|
|
bool get isDemo => _current?.isDemo ?? false;
|
|
|
|
/// Whether the session has a Nextcloud identity (Talk, Files, NC push).
|
|
bool get hasNextcloud => _current?.nextcloud != null;
|
|
|
|
/// True once the stored session has been read (or given up on).
|
|
bool get isLoaded => _loaded.isCompleted;
|
|
|
|
/// Resolves once the stored session is known. After [signOut] it stays
|
|
/// pending until the next sign-in.
|
|
Future<Session?> waitForLoad() async {
|
|
await _loaded.future;
|
|
return _current;
|
|
}
|
|
|
|
/// Stops waiting for the stored session; the app then behaves as signed
|
|
/// out. The keychain entries stay untouched so a later start can still
|
|
/// restore the session.
|
|
void abandonLoad() {
|
|
if (!_loaded.isCompleted) _loaded.complete();
|
|
}
|
|
|
|
/// Bumped when the server rejects a token that cannot be renewed silently
|
|
/// (passwordless accounts). The app confirms via [SessionValidator] before
|
|
/// signing out, so a transient 401 does not cost the session.
|
|
final ValueNotifier<int> unauthorizedSignal = ValueNotifier(0);
|
|
|
|
void reportUnauthorized() => unauthorizedSignal.value++;
|
|
|
|
NextcloudCredentials requireNextcloud() =>
|
|
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
|
|
|
|
/// Makes [session] the active account, replacing the active slots
|
|
/// completely. An account signed in before keeps its entry; call
|
|
/// [stashActive] first so the previously active one stays switchable.
|
|
Future<void> signIn(Session session) async {
|
|
await _writeActive(session);
|
|
await _saveIndex(
|
|
accounts.value.activate(
|
|
session,
|
|
newId: randomHexId(bytes: 8),
|
|
now: _now(),
|
|
),
|
|
);
|
|
if (!_loaded.isCompleted) _loaded.complete();
|
|
}
|
|
|
|
Future<void> _writeActive(Session session) async {
|
|
await Future.wait([
|
|
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
|
_writeSecret(key, value),
|
|
_writeGroupSecret(
|
|
SessionKeys.appPassword,
|
|
session.nextcloud?.appPassword,
|
|
),
|
|
_writeGroupSecret(
|
|
SessionKeys.appPasswordTalk,
|
|
session.nextcloud?.appPasswordTalk,
|
|
),
|
|
]);
|
|
_current = session;
|
|
}
|
|
|
|
/// Wipes the active slots and forgets the active account. Other accounts
|
|
/// stay in their vaults; see [activate].
|
|
Future<AccountEntry?> signOut() async {
|
|
final removed = activeAccount;
|
|
_loaded = Completer();
|
|
_current = null;
|
|
await Future.wait([
|
|
for (final field in _sessionFields) _secureStorage.delete(key: field),
|
|
_writeGroupSecret(SessionKeys.appPassword, null),
|
|
_writeGroupSecret(SessionKeys.appPasswordTalk, null),
|
|
if (removed != null) _secureStorage.delete(key: _vaultKey(removed.id)),
|
|
]);
|
|
if (removed != null) await _saveIndex(accounts.value.remove(removed.id));
|
|
return removed;
|
|
}
|
|
|
|
/// Parks the active account (session, app passwords, MC token) in its vault
|
|
/// so the slots can be taken over by another account.
|
|
Future<void> stashActive() async {
|
|
final session = _current;
|
|
final entry = activeAccount;
|
|
if (session == null || entry == null) return;
|
|
final fields = {
|
|
...sessionVaultFields(session),
|
|
...await _tokenStorage.readAll(),
|
|
};
|
|
await _secureStorage.write(
|
|
key: _vaultKey(entry.id),
|
|
value: encodeVault(fields),
|
|
);
|
|
}
|
|
|
|
/// Loads the vault of [id] into the active slots. The active account must
|
|
/// have been stashed before, or its session is lost.
|
|
Future<void> activate(String id) async {
|
|
final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id)));
|
|
final session = decodeSession(fields);
|
|
if (session == null) throw StateError('No stored session for account $id');
|
|
await _writeActive(session);
|
|
await _tokenStorage.writeAll(fields);
|
|
await _saveIndex(accounts.value.select(id, now: _now()));
|
|
if (!_loaded.isCompleted) _loaded.complete();
|
|
}
|
|
|
|
/// Remembers the real name of the active account for the account list.
|
|
Future<void> setDisplayName(String displayName) async {
|
|
final entry = activeAccount;
|
|
if (entry == null || entry.displayName == displayName) return;
|
|
await _saveIndex(accounts.value.rename(entry.id, displayName));
|
|
}
|
|
|
|
/// Session and MC token of an inactive account, e.g. to revoke them.
|
|
Future<(Session?, Map<String, String>)> readVault(String id) async {
|
|
final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id)));
|
|
return (decodeSession(fields), fields);
|
|
}
|
|
|
|
/// Drops an inactive account without touching the active slots.
|
|
Future<void> forget(String id) async {
|
|
await _secureStorage.delete(key: _vaultKey(id));
|
|
await _saveIndex(accounts.value.remove(id));
|
|
}
|
|
|
|
Future<void> _saveIndex(AccountIndex index) async {
|
|
accounts.value = index;
|
|
await _secureStorage.write(key: _indexKey, value: index.encode());
|
|
}
|
|
|
|
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
|
|
|
/// Persists a freshly minted Nextcloud app password; from then on every
|
|
/// Nextcloud call authenticates with it instead of the real password.
|
|
Future<void> setAppPassword(String appPassword) async {
|
|
_updateNextcloud((nc) => nc.copyWith(appPassword: () => appPassword));
|
|
await _writeGroupSecret(SessionKeys.appPassword, appPassword);
|
|
}
|
|
|
|
Future<void> clearAppPassword() async {
|
|
_updateNextcloud((nc) => nc.copyWith(appPassword: () => null));
|
|
await _writeGroupSecret(SessionKeys.appPassword, null);
|
|
}
|
|
|
|
/// Adopts an app password obtained via Login Flow v2 and switches the
|
|
/// account into flow mode. A 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();
|
|
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
|
|
await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true');
|
|
}
|
|
|
|
Future<void> setAppPasswordTalk(String appPassword) async {
|
|
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => appPassword));
|
|
await _writeGroupSecret(SessionKeys.appPasswordTalk, appPassword);
|
|
}
|
|
|
|
Future<void> clearAppPasswordTalk() async {
|
|
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => null));
|
|
await _writeGroupSecret(SessionKeys.appPasswordTalk, null);
|
|
}
|
|
|
|
void _updateNextcloud(
|
|
NextcloudCredentials Function(NextcloudCredentials) update,
|
|
) {
|
|
final session = _current;
|
|
if (session is CredentialSession) {
|
|
_current = session.withNextcloud(update(session.nextcloud));
|
|
}
|
|
}
|
|
|
|
Future<void> _writeSecret(String key, String? value) => value == null
|
|
? _secureStorage.delete(key: key)
|
|
: _secureStorage.write(key: key, value: value);
|
|
|
|
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
|
// Notification Service Extension can authenticate Nextcloud calls too. That
|
|
// keystore may be unavailable (entitlement not provisioned); the in-memory
|
|
// copy still serves this session.
|
|
Future<void> _writeGroupSecret(String key, String? value) async {
|
|
try {
|
|
if (value == null) {
|
|
await pushSecureStorage.delete(key: key);
|
|
} else {
|
|
await pushSecureStorage.write(key: key, value: value);
|
|
}
|
|
} on Object {
|
|
// ignore — see above
|
|
}
|
|
}
|
|
|
|
/// 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; !_loaded.isCompleted; attempt++) {
|
|
try {
|
|
await _migrateAndLoad();
|
|
return;
|
|
} catch (e, s) {
|
|
log('Session load failed (attempt $attempt): $e', stackTrace: s);
|
|
await Future<void>.delayed(exponentialBackoff(attempt));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _migrateAndLoad() async {
|
|
await _migrateFromLegacyStorage();
|
|
await _migrateKeychainAccessibility();
|
|
// On the startup critical path (and every background wake): read in
|
|
// parallel instead of one keychain round-trip after the other.
|
|
final values = await Future.wait(
|
|
_sessionFields.map((field) => _secureStorage.read(key: field)),
|
|
);
|
|
final raw = Map<String, String?>.fromIterables(_sessionFields, values);
|
|
try {
|
|
final (appPassword, appPasswordTalk) = await (
|
|
pushSecureStorage.read(key: SessionKeys.appPassword),
|
|
pushSecureStorage.read(key: SessionKeys.appPasswordTalk),
|
|
).wait;
|
|
raw[SessionKeys.appPassword] = appPassword;
|
|
raw[SessionKeys.appPasswordTalk] = appPasswordTalk;
|
|
} on Object {
|
|
// Group keystore unavailable: fall back to the real password.
|
|
}
|
|
_current = decodeSession(raw);
|
|
await _loadIndex();
|
|
if (!_loaded.isCompleted) _loaded.complete();
|
|
}
|
|
|
|
Future<void> _loadIndex() async {
|
|
var index = AccountIndex.decode(await _secureStorage.read(key: _indexKey));
|
|
final session = _current;
|
|
if (session != null) {
|
|
if (index.active == null ||
|
|
index.matching(session)?.id != index.activeId) {
|
|
// First start after the update: the existing account keeps its data
|
|
// in the un-namespaced storage.
|
|
index = index.activate(
|
|
session,
|
|
newId: randomHexId(bytes: 8),
|
|
namespace: index.accounts.isEmpty ? '' : null,
|
|
now: _now(),
|
|
);
|
|
await _secureStorage.write(key: _indexKey, value: index.encode());
|
|
}
|
|
accounts.value = index;
|
|
return;
|
|
}
|
|
accounts.value = index.remove(index.activeId ?? '');
|
|
// Interrupted switch or sign-out: fall back to another stored account
|
|
// instead of showing the login screen.
|
|
final fallback = accounts.value.mostRecentExcept(null);
|
|
if (fallback != null) await activate(fallback.id);
|
|
}
|
|
|
|
// 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(SessionKeys.username);
|
|
final legacyPassword = prefs.getString(SessionKeys.password);
|
|
if (legacyUsername == null || legacyPassword == null) return;
|
|
|
|
final hasSecure =
|
|
(await _secureStorage.read(key: SessionKeys.username)) != null;
|
|
if (!hasSecure) {
|
|
await _secureStorage.write(
|
|
key: SessionKeys.username,
|
|
value: legacyUsername,
|
|
);
|
|
await _secureStorage.write(
|
|
key: SessionKeys.password,
|
|
value: legacyPassword,
|
|
);
|
|
}
|
|
await prefs.remove(SessionKeys.username);
|
|
await prefs.remove(SessionKeys.password);
|
|
}
|
|
|
|
Future<void> _migrateKeychainAccessibility() async {
|
|
if (!Platform.isIOS) return;
|
|
final legacyValues = await Future.wait(
|
|
_sessionFields.map((field) => _legacySecureStorage.read(key: field)),
|
|
);
|
|
for (final (i, field) in _sessionFields.indexed) {
|
|
final value = legacyValues[i];
|
|
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);
|
|
}
|
|
}
|
|
}
|