added guardian login with views for their assigned childs
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Nextcloud identity of a session. Immutable; the session manager swaps in a
|
||||
/// new instance whenever an app password is minted or revoked.
|
||||
class NextcloudCredentials {
|
||||
final String username;
|
||||
|
||||
/// The real account password. Invalid against Nextcloud when
|
||||
/// [usesLoginFlow] is set (2FA accounts), where only [appPassword] works.
|
||||
final String password;
|
||||
final String? appPassword;
|
||||
|
||||
/// Backs the second (apptype=talk) push registration — Nextcloud binds each
|
||||
/// push subscription to its session token, so two registrations need two
|
||||
/// app passwords.
|
||||
final String? appPasswordTalk;
|
||||
|
||||
/// True when the credentials came from Login Flow v2 (browser login, e.g.
|
||||
/// because the account has two-factor authentication).
|
||||
final bool usesLoginFlow;
|
||||
|
||||
const NextcloudCredentials({
|
||||
required this.username,
|
||||
required this.password,
|
||||
this.appPassword,
|
||||
this.appPasswordTalk,
|
||||
this.usesLoginFlow = false,
|
||||
});
|
||||
|
||||
bool get hasAppPassword => appPassword != null && appPassword!.isNotEmpty;
|
||||
|
||||
bool get hasAppPasswordTalk =>
|
||||
appPasswordTalk != null && appPasswordTalk!.isNotEmpty;
|
||||
|
||||
/// The app password once available (minted or flow-issued), otherwise the
|
||||
/// real password. It survives real-password rotation and is what the push
|
||||
/// registration is bound to.
|
||||
String get secret => hasAppPassword ? appPassword! : password;
|
||||
|
||||
/// HTTP Basic header value. Prefer headers over credentials in URLs — error
|
||||
/// logs and crash reports often capture the URL but not headers.
|
||||
String get basicAuthHeader => _basicAuth(secret);
|
||||
|
||||
Map<String, String> get authHeaders => {'Authorization': basicAuthHeader};
|
||||
|
||||
/// 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 get talkBasicAuthHeader {
|
||||
if (hasAppPasswordTalk) return _basicAuth(appPasswordTalk!);
|
||||
// Login-flow account whose second (talk) flow pass was skipped: no silent
|
||||
// minting possible, the talk registration shares the flow credential.
|
||||
if (usesLoginFlow && hasAppPassword) return _basicAuth(appPassword!);
|
||||
throw StateError('Talk app password not available yet');
|
||||
}
|
||||
|
||||
/// Always the real password. Needed to mint the app password via
|
||||
/// `core/getapppassword` — an app password cannot mint another.
|
||||
String get realPasswordBasicAuthHeader => _basicAuth(password);
|
||||
|
||||
NextcloudCredentials copyWith({
|
||||
String? Function()? appPassword,
|
||||
String? Function()? appPasswordTalk,
|
||||
bool? usesLoginFlow,
|
||||
}) => NextcloudCredentials(
|
||||
username: username,
|
||||
password: password,
|
||||
appPassword: appPassword != null ? appPassword() : this.appPassword,
|
||||
appPasswordTalk: appPasswordTalk != null
|
||||
? appPasswordTalk()
|
||||
: this.appPasswordTalk,
|
||||
usesLoginFlow: usesLoginFlow ?? this.usesLoginFlow,
|
||||
);
|
||||
|
||||
String _basicAuth(String secret) =>
|
||||
'Basic ${base64Encode(utf8.encode('$username:$secret'))}';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'nextcloud_credentials.dart';
|
||||
|
||||
/// The signed-in account. Exactly one session is active at a time; features
|
||||
/// check for the backend identities they need ([nextcloud]) instead of
|
||||
/// assuming every account has all of them.
|
||||
sealed class Session {
|
||||
/// Local demo session: every backend is served from fixtures (see DemoMode).
|
||||
final bool isDemo;
|
||||
|
||||
const Session({this.isDemo = false});
|
||||
|
||||
/// Nextcloud identity, or null for accounts without one (guardians).
|
||||
NextcloudCredentials? get nextcloud;
|
||||
}
|
||||
|
||||
/// Student, teacher or staff account: username + password, backed by
|
||||
/// MarianumConnect and Nextcloud (with the same username and password).
|
||||
final class CredentialSession extends Session {
|
||||
@override
|
||||
final NextcloudCredentials nextcloud;
|
||||
|
||||
CredentialSession({
|
||||
required String username,
|
||||
required String password,
|
||||
String? appPassword,
|
||||
String? appPasswordTalk,
|
||||
bool usesLoginFlow = false,
|
||||
super.isDemo,
|
||||
}) : nextcloud = NextcloudCredentials(
|
||||
username: username,
|
||||
password: password,
|
||||
appPassword: appPassword,
|
||||
appPasswordTalk: appPasswordTalk,
|
||||
usesLoginFlow: usesLoginFlow,
|
||||
);
|
||||
|
||||
const CredentialSession._(this.nextcloud, {super.isDemo});
|
||||
|
||||
String get username => nextcloud.username;
|
||||
|
||||
String get password => nextcloud.password;
|
||||
|
||||
CredentialSession withNextcloud(NextcloudCredentials nextcloud) =>
|
||||
CredentialSession._(nextcloud, isDemo: isDemo);
|
||||
|
||||
/// Legacy MHSL identity (`sha512(user:pass)`), only for the one-off custom
|
||||
/// events migration.
|
||||
String get legacyUserSecret =>
|
||||
sha512.convert(utf8.encode('$username:$password')).toString();
|
||||
}
|
||||
|
||||
/// Parent/guardian account: passwordless e-mail login, MarianumConnect only.
|
||||
final class GuardianSession extends Session {
|
||||
final String email;
|
||||
|
||||
const GuardianSession({required this.email, super.isDemo});
|
||||
|
||||
@override
|
||||
NextcloudCredentials? get nextcloud => null;
|
||||
}
|
||||
|
||||
/// Thrown when a Nextcloud-only feature is reached with a session that has no
|
||||
/// Nextcloud identity. Indicates a missing gate, not a user error.
|
||||
class NextcloudUnavailableException implements Exception {
|
||||
const NextcloudUnavailableException();
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'NextcloudUnavailableException: session has no Nextcloud account';
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'session.dart';
|
||||
|
||||
/// Keychain keys of the session. Names are frozen: installed versions and the
|
||||
/// iOS AppDelegate/NSE read them, so renaming would log every user out.
|
||||
abstract final class SessionKeys {
|
||||
static const username = 'username';
|
||||
static const password = 'password';
|
||||
static const appPassword = 'nextcloud_app_password';
|
||||
static const appPasswordTalk = 'nextcloud_app_password_talk';
|
||||
static const loginFlow = 'nextcloud_login_flow';
|
||||
static const demo = 'is_demo';
|
||||
|
||||
// Added with guardian accounts. Absent on installs from before — see
|
||||
// [decodeSession].
|
||||
static const kind = 'session_kind';
|
||||
static const guardianEmail = 'guardian_email';
|
||||
|
||||
static const kindCredential = 'credential';
|
||||
static const kindGuardian = 'guardian';
|
||||
}
|
||||
|
||||
/// Rebuilds the session from raw keychain values. Installs from before
|
||||
/// guardian accounts carry no [SessionKeys.kind]; a stored username and
|
||||
/// password then mean a credential session, so existing users stay signed in.
|
||||
Session? decodeSession(Map<String, String?> raw) {
|
||||
final isDemo = raw[SessionKeys.demo] == 'true';
|
||||
switch (raw[SessionKeys.kind]) {
|
||||
case SessionKeys.kindGuardian:
|
||||
final email = raw[SessionKeys.guardianEmail];
|
||||
if (email == null || email.isEmpty) return null;
|
||||
return GuardianSession(email: email, isDemo: isDemo);
|
||||
case null:
|
||||
case SessionKeys.kindCredential:
|
||||
final username = raw[SessionKeys.username];
|
||||
final password = raw[SessionKeys.password];
|
||||
if (username == null || password == null) return null;
|
||||
return CredentialSession(
|
||||
username: username,
|
||||
password: password,
|
||||
appPassword: raw[SessionKeys.appPassword],
|
||||
appPasswordTalk: raw[SessionKeys.appPasswordTalk],
|
||||
usesLoginFlow: raw[SessionKeys.loginFlow] == 'true',
|
||||
isDemo: isDemo,
|
||||
);
|
||||
default:
|
||||
// Written by a newer app version; unknown here, treat as signed out.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keychain values for [session], excluding the group-scoped app passwords
|
||||
/// (written separately so the iOS NSE can read them). `null` = delete.
|
||||
Map<String, String?> encodeSessionFields(Session session) => switch (session) {
|
||||
CredentialSession() => {
|
||||
SessionKeys.kind: SessionKeys.kindCredential,
|
||||
SessionKeys.username: session.username,
|
||||
SessionKeys.password: session.password,
|
||||
SessionKeys.demo: session.isDemo ? 'true' : null,
|
||||
SessionKeys.loginFlow: session.nextcloud.usesLoginFlow ? 'true' : null,
|
||||
SessionKeys.guardianEmail: null,
|
||||
},
|
||||
GuardianSession() => {
|
||||
SessionKeys.kind: SessionKeys.kindGuardian,
|
||||
SessionKeys.guardianEmail: session.email,
|
||||
SessionKeys.demo: session.isDemo ? 'true' : null,
|
||||
SessionKeys.username: null,
|
||||
SessionKeys.password: null,
|
||||
SessionKeys.loginFlow: null,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../auth_link/guardian_link_listener.dart';
|
||||
import '../push/push_registration.dart';
|
||||
import 'session_manager.dart';
|
||||
|
||||
abstract final class SessionLifecycle {
|
||||
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
|
||||
/// (while those credentials still exist), then revoke the MC bearer token,
|
||||
/// finally wipe the local session. Each step is best-effort so an offline
|
||||
/// sign-out still reaches a clean local state.
|
||||
static Future<void> signOut() async {
|
||||
try {
|
||||
await PushRegistration().logoutCleanup();
|
||||
} on Object catch (e) {
|
||||
log('Sign-out: push cleanup failed: $e');
|
||||
}
|
||||
await AuthLogout().run();
|
||||
await SessionManager().signOut();
|
||||
// A login link that arrived while signed in must not be replayed on the
|
||||
// login screen that follows.
|
||||
GuardianLinkListener.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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 '../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.guardianEmail,
|
||||
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;
|
||||
|
||||
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());
|
||||
|
||||
/// Replaces any stored session completely; no prior [signOut] needed.
|
||||
Future<void> signIn(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;
|
||||
if (!_loaded.isCompleted) _loaded.complete();
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
_loaded = Completer();
|
||||
_current = null;
|
||||
await Future.wait([
|
||||
for (final field in _sessionFields) _secureStorage.delete(key: field),
|
||||
_writeGroupSecret(SessionKeys.appPassword, null),
|
||||
_writeGroupSecret(SessionKeys.appPasswordTalk, null),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
if (!_loaded.isCompleted) _loaded.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(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user