migrated timetable integration from WebUntis to the MarianumConnect API, implementing a Dio-based client with bearer token authentication, background session validation, and auto-refresh logic.

This commit is contained in:
2026-05-23 17:32:42 +02:00
parent 2858f910c9
commit 93b9929f8f
106 changed files with 2739 additions and 2624 deletions
@@ -0,0 +1,110 @@
import 'package:dio/dio.dart';
import '../../../model/account_data.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.
class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried';
final MarianumConnectTokenStorage _tokenStorage;
final Dio _retryDio;
final AuthLogin _loginClient;
// Single-flight lock: parallel 401s share the same login Future instead of
// each spawning a fresh row in api_tokens.
Future<bool>? _pendingReLogin;
MarianumConnectAuthInterceptor({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? retryDio,
AuthLogin? loginClient,
}) : _tokenStorage = tokenStorage,
_retryDio = retryDio ?? Dio(),
_loginClient = loginClient ?? AuthLogin();
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// Wait for an in-flight re-login so nachrückende Requests den frischen
// Token mitschicken statt ein eigenes 401 einzufangen.
final pending = _pendingReLogin;
if (pending != null) await pending;
final token = await _tokenStorage.readToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final response = err.response;
if (response?.statusCode != 401 ||
err.requestOptions.extra[_retriedKey] == true) {
handler.next(err);
return;
}
final refreshed = await _attemptReLogin();
if (!refreshed) {
handler.next(err);
return;
}
try {
final retried = await _retryWithFreshToken(err.requestOptions);
handler.resolve(retried);
} on DioException catch (retryError) {
handler.next(retryError);
}
}
Future<bool> _attemptReLogin() {
final inFlight = _pendingReLogin;
if (inFlight != null) return inFlight;
final fresh = _performReLogin();
_pendingReLogin = fresh;
fresh.whenComplete(() {
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
});
return fresh;
}
Future<bool> _performReLogin() async {
if (!AccountData().isPopulated()) return false;
try {
await _loginClient.run(
username: AccountData().getUsername(),
password: AccountData().getPassword(),
tokenName: await DeviceTokenName.resolve(),
);
return true;
} catch (_) {
await _tokenStorage.clear();
return false;
}
}
Future<Response<dynamic>> _retryWithFreshToken(
RequestOptions originalOptions,
) async {
final freshToken = await _tokenStorage.readToken();
final headers = Map<String, dynamic>.of(originalOptions.headers);
if (freshToken != null && freshToken.isNotEmpty) {
headers['Authorization'] = 'Bearer $freshToken';
}
final clone = originalOptions.copyWith(
headers: headers,
extra: {...originalOptions.extra, _retriedKey: true},
);
return _retryDio.fetch<dynamic>(clone);
}
}
@@ -0,0 +1,41 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
/// Bearer-token display name shown in the dashboard token list, in the form
/// `"Marianum Fulda App (Pixel 10)"`. Cached because device-info never
/// changes at runtime.
class DeviceTokenName {
static const String _appName = 'Marianum Fulda App';
static String? _cached;
static Future<String> resolve() async {
if (_cached != null) return _cached!;
final device = await _deviceLabel();
_cached = device.isEmpty ? _appName : '$_appName ($device)';
return _cached!;
}
static Future<String> _deviceLabel() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
final model = android.model.trim();
return model.isNotEmpty ? model : android.device.trim();
}
if (Platform.isIOS) {
final ios = await info.iosInfo;
// utsname.machine bleibt auch ohne user-zugewiesenen Gerätenamen
// verfügbar; ios.name liefert auf iOS 16+ nur noch Generika.
final machine = ios.utsname.machine.trim();
if (machine.isNotEmpty) return machine;
return ios.name.trim();
}
} catch (_) {
// Device-Plugin nicht verfügbar (z.B. Tests).
}
return '';
}
}
@@ -0,0 +1,32 @@
import 'dart:developer';
import '../../../model/account_data.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.
class SessionValidator {
static Future<void> probeStored({
required Future<void> Function() onInvalidated,
}) async {
if (!AccountData().isPopulated()) return;
final username = AccountData().getUsername();
final password = AccountData().getPassword();
try {
await AuthVerify().run(username: username, password: password);
} on AuthException catch (e) {
if (e.statusCode != 401) return;
log('MC: stored credentials rejected — forcing re-login');
await AuthLogout().run();
await const MarianumConnectTokenStorage().clear();
await AccountData().removeData();
await onInvalidated();
} catch (e) {
log('MC: background credential check failed (transient): $e');
}
}
}
@@ -0,0 +1,45 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
/// separate from `AccountData` 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';
static const _tokenIdKey = 'mc_token_id';
static const _expiresAtKey = 'mc_token_expires_at';
final FlutterSecureStorage _storage;
const MarianumConnectTokenStorage([
this._storage = const FlutterSecureStorage(),
]);
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
Future<DateTime?> readExpiresAt() async {
final raw = await _storage.read(key: _expiresAtKey);
if (raw == null || raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
Future<void> write({
required String token,
required String tokenId,
required DateTime? expiresAt,
}) async {
await _storage.write(key: _tokenKey, value: token);
await _storage.write(key: _tokenIdKey, value: tokenId);
await _storage.write(
key: _expiresAtKey,
value: expiresAt?.toIso8601String() ?? '',
);
}
Future<void> clear() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _tokenIdKey);
await _storage.delete(key: _expiresAtKey);
}
}