Files
Client/lib/view/login/guardian_login_controller.dart
T

232 lines
7.8 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() => resendCooldown() == Duration.zero;
/// Restzeit, bis [resend] wieder erlaubt ist.
Duration resendCooldown() {
final pending = _pending;
if (pending == null) return Duration.zero;
final remaining = pending.resendAvailableAt.difference(_now());
return remaining.isNegative ? Duration.zero : remaining;
}
/// 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)) {
var signedIn = false;
await _run(() async {
await _signIn(GuardianSession(email: normalized, isDemo: true));
signedIn = true;
});
return signedIn;
}
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);
}
/// A mail link that does not belong to the server the app talks to (a live
/// link while the app points at beta). Without this the tap would do nothing
/// at all.
void rejectForeignLink() {
_errorMessage =
'Dieser Anmeldelink gehört zu einem anderen Server als dem, mit dem '
'die App gerade verbunden ist. Bitte gib den Code aus der E-Mail ein.';
_errorDetails = null;
notifyListeners();
}
/// 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) {
_errorMessage = GuardianLoginException.messageFor(
GuardianLoginError.requestExpired,
);
_errorDetails = null;
_step = GuardianLoginStep.enterEmail;
notifyListeners();
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 {
// Sign in first: the one-time code is already spent at this point, so a
// failing widget reset must not cost the session (the user would have to
// request a fresh mail for a login that actually succeeded).
await SessionManager().signIn(session);
// Drop any widget snapshot of a previous account.
try {
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
} on Object catch (e) {
log('Guardian login: widget reset failed: $e');
}
}
}