Files
Client/lib/session/session_manager.dart
T

289 lines
10 KiB
Dart

import 'dart:async';
import 'dart:developer';
import 'dart:io';
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';
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.
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.demo,
SessionKeys.loginFlow,
];
static final SessionManager _instance = SessionManager._();
factory SessionManager() => _instance;
SessionManager._() {
unawaited(_loadWithRetry());
}
Completer<void> _loaded = Completer();
Session? _current;
Session? get current => _current;
int _sessionEpoch = 0;
/// Bumped whenever the signed-in account changes. Async work captures it when it starts and drops its
/// result when it changed meanwhile, so a request of the previous account
/// cannot land in the next account's state or cache.
int get sessionEpoch => _sessionEpoch;
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
bool _isUiEngine = false;
/// Called from `main()`; background entry points never run it.
void markUiEngine() => _isUiEngine = true;
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();
}
NextcloudCredentials requireNextcloud() =>
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
/// Replaces any stored session completely; no prior [signOut] needed.
Future<void> signIn(Session session) async {
if (!_isSameAccount(_current, session)) _sessionEpoch++;
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;
if (!_loaded.isCompleted) _loaded.complete();
}
Future<void> signOut() async {
_sessionEpoch++;
_loaded = Completer();
_current = null;
await Future.wait([
for (final field in _sessionFields) _secureStorage.delete(key: field),
_writeGroupSecret(SessionKeys.appPassword, null),
_writeGroupSecret(SessionKeys.appPasswordTalk, null),
]);
}
static bool _isSameAccount(Session? a, Session? b) => switch ((a, b)) {
(final CredentialSession a, final CredentialSession b) =>
a.username == b.username && a.isDemo == b.isDemo,
_ => a == b,
};
/// 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();
_current = await _readActive();
if (!_loaded.isCompleted) _loaded.complete();
}
/// The session in the active slots. On the startup critical path (and every
/// background wake): read in parallel instead of one keychain round-trip
/// after the other.
Future<Session?> _readActive() async {
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.
}
return decodeSession(raw);
}
/// Username currently in the keystore. Other engines (widget task, push
/// isolates) sign out or in without this instance noticing.
Future<String?> readStoredUsername() =>
_secureStorage.read(key: SessionKeys.username);
/// Re-reads the session for long-lived background engines: they load once
/// and would otherwise keep acting with an account that signed out in the
/// app meanwhile. Keeps the known state when the keystore is unreadable.
Future<void> reloadFromStorage() async {
// The UI engine performs sign-in and sign-out itself, so its state is
// current; re-reading mid sign-out could resurrect the removed account.
if (_isUiEngine) return;
try {
final session = await _readActive();
if (!_isSameAccount(_current, session)) _sessionEpoch++;
_current = session;
if (!_loaded.isCompleted) _loaded.complete();
} on Object catch (e) {
log('Session reload failed, keeping loaded state: $e');
}
}
// 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);
}
}
}