110 lines
3.4 KiB
Dart
110 lines
3.4 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer';
|
|
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
|
|
import '../../model/account_data.dart';
|
|
import 'auth/login/login.dart';
|
|
import 'auth/login/login_request.dart';
|
|
import 'auth/login/login_response.dart';
|
|
import 'errors/connect_exception.dart';
|
|
|
|
/// Holds the Bearer token issued by `POST /auth/login` so that subsequent
|
|
/// RMV calls can attach it without prompting the user. Uses the LDAP
|
|
/// credentials already kept in [AccountData], so this is transparent to the
|
|
/// user — no extra login UI.
|
|
class ConnectAuthStore {
|
|
static const _tokenKey = 'connect_bearer_token';
|
|
static const _expiresAtKey = 'connect_token_expires_at';
|
|
static const _tokenName = 'MarianumMobile App';
|
|
static const _expiryGuard = Duration(minutes: 1);
|
|
|
|
static final ConnectAuthStore _instance = ConnectAuthStore._();
|
|
factory ConnectAuthStore() => _instance;
|
|
static ConnectAuthStore get instance => _instance;
|
|
ConnectAuthStore._();
|
|
|
|
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
|
|
|
String? _token;
|
|
DateTime? _expiresAt;
|
|
bool _hydrated = false;
|
|
Future<String>? _inflightLogin;
|
|
|
|
Future<void> _hydrate() async {
|
|
if (_hydrated) return;
|
|
_token = await _storage.read(key: _tokenKey);
|
|
final rawExp = await _storage.read(key: _expiresAtKey);
|
|
_expiresAt = rawExp == null ? null : DateTime.tryParse(rawExp);
|
|
_hydrated = true;
|
|
}
|
|
|
|
bool _isUsable() {
|
|
if (_token == null || _token!.isEmpty) return false;
|
|
final exp = _expiresAt;
|
|
if (exp == null) return true;
|
|
return DateTime.now().add(_expiryGuard).isBefore(exp);
|
|
}
|
|
|
|
/// Returns a usable bearer token, logging in if necessary. Concurrent
|
|
/// callers share the same in-flight login future so a single 401 doesn't
|
|
/// trigger N parallel logins.
|
|
Future<String> getToken({bool forceRefresh = false}) async {
|
|
await _hydrate();
|
|
if (!forceRefresh && _isUsable()) return _token!;
|
|
return _inflightLogin ??= _login().whenComplete(() {
|
|
_inflightLogin = null;
|
|
});
|
|
}
|
|
|
|
Future<String> _login() async {
|
|
if (!AccountData().isPopulated()) {
|
|
throw ConnectException.notAuthenticated();
|
|
}
|
|
final username = AccountData().getUsername();
|
|
final password = AccountData().getPassword();
|
|
final LoginResponse response;
|
|
try {
|
|
response = await Login(
|
|
LoginRequest(
|
|
username: username,
|
|
password: password,
|
|
tokenName: _tokenName,
|
|
),
|
|
).run();
|
|
} on ConnectException {
|
|
rethrow;
|
|
} catch (e, st) {
|
|
log('connect login threw: $e', stackTrace: st);
|
|
throw ConnectException(
|
|
userMessage:
|
|
'Anmeldung am Connect-Server fehlgeschlagen. Bitte später erneut versuchen.',
|
|
technicalDetails: 'connect login failed: $e',
|
|
);
|
|
}
|
|
_token = response.token;
|
|
_expiresAt = response.expiresAt;
|
|
await _storage.write(key: _tokenKey, value: response.token);
|
|
if (response.expiresAt != null) {
|
|
await _storage.write(
|
|
key: _expiresAtKey,
|
|
value: response.expiresAt!.toIso8601String(),
|
|
);
|
|
} else {
|
|
await _storage.delete(key: _expiresAtKey);
|
|
}
|
|
return response.token;
|
|
}
|
|
|
|
Future<void> invalidate() async {
|
|
_token = null;
|
|
_expiresAt = null;
|
|
await _storage.delete(key: _tokenKey);
|
|
await _storage.delete(key: _expiresAtKey);
|
|
}
|
|
|
|
/// Same as [invalidate] — separate method to make logout call-sites read
|
|
/// clearly.
|
|
Future<void> clear() => invalidate();
|
|
}
|