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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user