232 lines
7.8 KiB
Dart
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');
|
|
}
|
|
}
|
|
}
|