197 lines
6.5 KiB
Dart
197 lines
6.5 KiB
Dart
import 'dart:developer';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../api/demo/demo_mode.dart';
|
|
import '../../api/errors/error_mapper.dart';
|
|
import '../../api/marianumconnect/auth/device_token_name.dart';
|
|
import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart';
|
|
import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart';
|
|
import '../../api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart';
|
|
import '../../auth_link/device_binding.dart';
|
|
import '../../auth_link/guardian_login_link.dart';
|
|
import '../../auth_link/pending_guardian_request.dart';
|
|
import '../../session/session.dart';
|
|
import '../../session/session_manager.dart';
|
|
import '../../widget_data/widget_sync.dart';
|
|
|
|
enum GuardianLoginStep { enterEmail, enterCode }
|
|
|
|
/// Drives the passwordless guardian login: request a mail, then finish with
|
|
/// the mailed code or link. The running request is persisted so the flow
|
|
/// survives the app being killed while the user reads the mail.
|
|
class GuardianLoginController extends ChangeNotifier {
|
|
final AuthGuardianRequest _request;
|
|
final AuthGuardianVerify _verify;
|
|
final PendingGuardianRequestStore _store;
|
|
final Future<void> Function(Session session) _signIn;
|
|
final Future<String> Function() _tokenName;
|
|
final DateTime Function() _now;
|
|
|
|
GuardianLoginController({
|
|
AuthGuardianRequest? request,
|
|
AuthGuardianVerify? verify,
|
|
PendingGuardianRequestStore store = const PendingGuardianRequestStore(),
|
|
Future<void> Function(Session session)? signIn,
|
|
Future<String> Function()? tokenName,
|
|
DateTime Function()? now,
|
|
}) : _request = request ?? AuthGuardianRequest(),
|
|
_verify = verify ?? AuthGuardianVerify(),
|
|
_store = store,
|
|
_signIn = signIn ?? _defaultSignIn,
|
|
_tokenName = tokenName ?? DeviceTokenName.resolve,
|
|
_now = now ?? DateTime.now;
|
|
|
|
GuardianLoginStep _step = GuardianLoginStep.enterEmail;
|
|
PendingGuardianRequest? _pending;
|
|
bool _loading = false;
|
|
String? _errorMessage;
|
|
String? _errorDetails;
|
|
|
|
GuardianLoginStep get step => _step;
|
|
PendingGuardianRequest? get pending => _pending;
|
|
bool get loading => _loading;
|
|
String? get errorMessage => _errorMessage;
|
|
String? get errorDetails => _errorDetails;
|
|
|
|
bool canResend() {
|
|
final pending = _pending;
|
|
return pending != null && !_now().isBefore(pending.resendAvailableAt);
|
|
}
|
|
|
|
/// Picks up a request started before the app was closed.
|
|
Future<void> restore() async {
|
|
final stored = await _store.read();
|
|
if (stored == null) return;
|
|
if (stored.isExpired(_now())) {
|
|
await _store.clear();
|
|
return;
|
|
}
|
|
_pending = stored;
|
|
_step = GuardianLoginStep.enterCode;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Sends the login mail. Returns true when the user is already signed in
|
|
/// (demo address), false when the code step follows or the request failed.
|
|
Future<bool> requestCode(String email) async {
|
|
final normalized = email.trim().toLowerCase();
|
|
if (DemoMode.matchesGuardian(normalized)) {
|
|
await _signIn(GuardianSession(email: normalized, isDemo: true));
|
|
return true;
|
|
}
|
|
await _run(() async {
|
|
final secret = DeviceBinding.generateSecret();
|
|
final response = await _request.run(
|
|
email: normalized,
|
|
deviceChallenge: DeviceBinding.challengeFor(secret),
|
|
tokenName: await _tokenName(),
|
|
);
|
|
final pending = PendingGuardianRequest(
|
|
requestId: response.requestId,
|
|
email: normalized,
|
|
deviceSecret: secret,
|
|
expiresAt: response.expiresAt,
|
|
resendAvailableAt: response.resendAvailableAt,
|
|
codeLength: response.codeLength,
|
|
);
|
|
await _store.write(pending);
|
|
_pending = pending;
|
|
_step = GuardianLoginStep.enterCode;
|
|
});
|
|
return false;
|
|
}
|
|
|
|
Future<void> resend() async {
|
|
final pending = _pending;
|
|
if (pending == null || !canResend()) return;
|
|
await requestCode(pending.email);
|
|
}
|
|
|
|
/// Mail clients and autofill may insert spaces into the code.
|
|
static String normalizeCode(String code) =>
|
|
code.replaceAll(RegExp(r'\s'), '');
|
|
|
|
Future<bool> submitCode(String code) => _complete(code: normalizeCode(code));
|
|
|
|
/// Completes the login from a mail link. A link belonging to another
|
|
/// request (other device, or an older mail) cannot be verified here.
|
|
Future<bool> submitLink(GuardianLoginLink link) {
|
|
if (_pending?.requestId != link.requestId) {
|
|
_errorMessage = GuardianLoginException.messageFor(
|
|
GuardianLoginError.deviceMismatch,
|
|
);
|
|
_errorDetails = null;
|
|
notifyListeners();
|
|
return Future.value(false);
|
|
}
|
|
return _complete(linkToken: link.linkToken);
|
|
}
|
|
|
|
/// Abandons the running request, e.g. to correct a mistyped address.
|
|
Future<void> changeEmail() async {
|
|
await _store.clear();
|
|
_pending = null;
|
|
_step = GuardianLoginStep.enterEmail;
|
|
_errorMessage = null;
|
|
_errorDetails = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<bool> _complete({String? code, String? linkToken}) async {
|
|
final pending = _pending;
|
|
if (pending == null) return false;
|
|
var signedIn = false;
|
|
await _run(() async {
|
|
await _verify.run(
|
|
requestId: pending.requestId,
|
|
deviceVerifier: pending.deviceSecret,
|
|
tokenName: await _tokenName(),
|
|
code: code,
|
|
linkToken: linkToken,
|
|
);
|
|
await _store.clear();
|
|
await _signIn(GuardianSession(email: pending.email));
|
|
signedIn = true;
|
|
});
|
|
return signedIn;
|
|
}
|
|
|
|
Future<void> _run(Future<void> Function() body) async {
|
|
if (_loading) return;
|
|
_loading = true;
|
|
_errorMessage = null;
|
|
_errorDetails = null;
|
|
notifyListeners();
|
|
try {
|
|
await body();
|
|
} on GuardianLoginException catch (e) {
|
|
_errorMessage = e.userMessage;
|
|
_errorDetails = e.technicalDetails;
|
|
// These end the request for good; only a new mail helps.
|
|
if (e.error
|
|
case GuardianLoginError.requestExpired ||
|
|
GuardianLoginError.requestConsumed ||
|
|
GuardianLoginError.tooManyAttempts) {
|
|
await _store.clear();
|
|
_pending = null;
|
|
_step = GuardianLoginStep.enterEmail;
|
|
}
|
|
} catch (e) {
|
|
log('Guardian login failed: $e');
|
|
_errorMessage = errorToUserMessage(e);
|
|
_errorDetails = errorToTechnicalDetails(e);
|
|
} finally {
|
|
_loading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
static Future<void> _defaultSignIn(Session session) async {
|
|
// Drop any widget snapshot of a previous account before the new one loads.
|
|
await WidgetSync.clear();
|
|
await WidgetSync.triggerUpdate();
|
|
await SessionManager().signIn(session);
|
|
}
|
|
}
|