replaced account data singleton with a session model

This commit is contained in:
2026-09-27 14:49:38 +02:00
parent 79b33ab248
commit 5e65c4671a
60 changed files with 1019 additions and 679 deletions
@@ -1,12 +1,13 @@
import 'package:dio/dio.dart';
import '../../../model/account_data.dart';
import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../queries/auth_login/auth_login.dart';
import 'device_token_name.dart';
import 'token_storage.dart';
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
/// re-logs in once with the credentials in [AccountData] before retrying.
/// renews the token once before retrying.
class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried';
@@ -87,25 +88,26 @@ class MarianumConnectAuthInterceptor extends Interceptor {
}
Future<bool> _performReLogin() async {
if (!AccountData().isPopulated()) return false;
final username = AccountData().getUsername();
final session = SessionManager().current;
if (session is! CredentialSession) return false;
final username = session.username;
// A background engine (widget task) keeps the account it loaded. When
// the app signed that account out, its revoked token answers 401 — a
// re-login would mint a fresh token for it into the shared keystore.
if (await AccountData().readStoredUsername() != username) return false;
if (await SessionManager().readStoredUsername() != username) return false;
try {
await _loginClient.run(
username: username,
password: AccountData().getPassword(),
password: session.password,
tokenName: await DeviceTokenName.resolve(),
);
} catch (_) {
if (await AccountData().readStoredUsername() == username) {
if (await SessionManager().readStoredUsername() == username) {
await _tokenStorage.clear();
}
return false;
}
final stored = await AccountData().readStoredUsername();
final stored = await SessionManager().readStoredUsername();
if (stored == username) return true;
// Signed out during the login: drop the orphaned token, unless another
// account already stored its own.
@@ -1,39 +1,43 @@
import 'dart:developer';
import '../../../model/account_data.dart';
import '../../../session/session.dart';
import '../../../session/session_lifecycle.dart';
import '../../../session/session_manager.dart';
import '../../errors/auth_exception.dart';
import '../queries/auth_logout/auth_logout.dart';
import '../queries/auth_verify/auth_verify.dart';
import 'token_storage.dart';
/// Background credential probe — a server-side password rotation forces a
/// re-login on the next cold start even when the bearer token would still
/// be accepted.
/// Credential probe. A server-side password rotation forces a re-login on the
/// next cold start even when the bearer token would still be accepted.
class SessionValidator {
static Future<void> probeStored({
required Future<void> Function() onInvalidated,
}) async {
if (!AccountData().isPopulated()) return;
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo
// session must be skipped here or its missing token would 401 into a logout.
if (AccountData().isDemo) return;
final username = AccountData().getUsername();
final password = AccountData().getPassword();
final epoch = AccountData().sessionEpoch;
final session = SessionManager().current;
// The probes use their own dio (bypassing the demo interceptor), so a demo
// session must be skipped or its missing token would 401 into a logout.
if (session == null || session.isDemo) return;
final epoch = SessionManager().sessionEpoch;
try {
await AuthVerify().run(username: username, password: password);
switch (session) {
case CredentialSession(:final username, :final password):
await AuthVerify().run(username: username, password: password);
}
} on AuthException catch (e) {
if (e.statusCode != 401) return;
// The probed account already signed out; the 401 must not sign out
// whoever logged in meanwhile.
if (!AccountData().isCurrentSession(epoch)) return;
log('MC: stored credentials rejected — forcing re-login');
await AuthLogout().run();
await const MarianumConnectTokenStorage().clear();
await AccountData().removeData();
if (!SessionManager().isCurrentSession(epoch)) return;
log('MC: stored session rejected — forcing re-login');
await SessionLifecycle.signOut(
notice: switch (session) {
CredentialSession() =>
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
},
);
await onInvalidated();
} catch (e) {
log('MC: background credential check failed (transient): $e');
log('MC: background session check failed (transient): $e');
}
}
}
@@ -1,5 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../errors/auth_exception.dart';
/// `first_unlock` accessibility so the token can be read during background
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
@@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
);
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
/// separate from `AccountData` because the username/password live on (Nextcloud
/// separate from `SessionManager` because the username/password live on (Nextcloud
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
class MarianumConnectTokenStorage {
static const _tokenKey = 'mc_bearer_token';
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
Future<String?> readToken() => _storage.read(key: _tokenKey);
/// Request options carrying the stored token, for probes that bypass the
/// auth interceptor. Throws [AuthException] when no token is stored.
Future<Options> requireBearerOptions(String caller) async {
final token = await readToken();
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: '$caller: no bearer token in storage',
);
}
return Options(headers: {'Authorization': 'Bearer $token'});
}
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
Future<DateTime?> readExpiresAt() async {
@@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../errors/app_exception.dart';
import 'errors/marianumconnect_error.dart';
import 'marianumconnect_api.dart';
import 'marianumconnect_endpoint.dart';
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
try {
return await body();
} on DioException catch (e) {
throw mapMarianumConnectError(e);
throw mapError(e);
}
}
/// The AppException a failed call surfaces as. Query families with domain
/// error codes override this instead of re-implementing [guard].
AppException mapError(DioException error) => mapMarianumConnectError(error);
/// GETs [path] and parses the JSON object body with [fromJson].
Future<T> getObject<T>(
String path,
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
.toList();
});
/// GETs the raw bytes of [path] (files that need the bearer token).
Future<Uint8List> getBytes(String path) => guard(() async {
final response = await dio.get<List<int>>(
endpoint(path),
options: Options(responseType: ResponseType.bytes),
);
final bytes = response.data!;
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
});
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
String isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
required String username,
required String password,
}) async {
final token = await _tokenStorage.readToken();
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
final options = await _tokenStorage.requireBearerOptions('AuthVerify');
return guard(() async {
await dio.post<void>(
endpoint('auth/verify'),
data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}),
options: options,
);
});
}
@@ -1,7 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw PDF bytes of a Marianum Message from
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
GetNewsletterFile(this.id, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
Future<Uint8List> run() =>
getBytes('newsletter/${Uri.encodeComponent(id)}/file');
}
@@ -1,7 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
GetTickerPageFile(this.slug, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
Future<Uint8List> run() =>
getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
}
@@ -1,7 +1,7 @@
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../utils/random_id.dart';
/// A stable, anonymous per-install identifier for telemetry. Generated once on
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
/// keystore, so a device stays a single row across password rotations and FCM
@@ -21,15 +21,9 @@ class TelemetryDeviceId {
_cached = existing;
return existing;
}
final generated = _generate();
final generated = randomHexId();
await _storage.write(key: _key, value: generated);
_cached = generated;
return generated;
}
static String _generate() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
}
@@ -2,8 +2,8 @@ import 'dart:developer';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../model/account_data.dart';
import '../../../demo/demo_mode.dart';
import '../../../../session/session.dart';
import '../../../../session/session_manager.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
@@ -26,28 +26,32 @@ class CustomEventsMigration {
const CustomEventsMigration._();
static Future<void> runOnce() async {
if (DemoMode.active) return;
// Only password accounts can derive the legacy identity.
final session = SessionManager().current;
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return;
final epoch = AccountData().sessionEpoch;
final epoch = SessionManager().sessionEpoch;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()),
GetCustomTimetableEventParams(session.legacyUserSecret),
).run();
for (final event in response.events) {
// The POST authenticates with whoever is signed in now; after a
// sign-out the previous account's events would land in the next one.
if (!AccountData().isCurrentSession(epoch)) return;
if (!SessionManager().isCurrentSession(epoch)) return;
await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
if (!AccountData().isCurrentSession(epoch)) return;
if (!SessionManager().isCurrentSession(epoch)) return;
await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
);
} catch (e) {
// Leave the flag unset so the next launch retries; the delete-after-post
// above keeps a partial run duplicate-free.