Add reusable two-factor verification for login and money operations (#32)

This commit is contained in:
Stephan D committed 2026-09-12 12:44:34 +02:00
1 parent b7a58b1a87
commit fb40f860d2
41 files changed
+2439 -43

No files matched your search

@@ -0,0 +1,186 @@
import 'dart:math';
import '../dto/two_factor.dart';
import '../mappers/two_factor.dart';
import '../models/two_factor.dart';
import 'two_factor.dart';
/// Single-account backend stand-in. Codes and approvals never leave memory.
class DummyTwoFactorService implements TwoFactorService {
DummyTwoFactorService({
bool enabled = false,
this.delay = const Duration(milliseconds: 250),
DateTime Function()? now,
this.method = FactorMethod.email,
this.code = '123456',
this.target = 'a•••@example.com',
}) : _enabled = enabled,
now = now ?? DateTime.now;
bool _enabled;
final Duration delay;
final DateTime Function() now;
final String code, target;
final FactorMethod method;
FactorError? nextError;
final _challenges = <String, _Attempt>{};
final _grants = <String, FactorChallengeDTO>{};
final _failures = <FactorPurpose, int>{};
final _deliveryAfter = <FactorPurpose, DateTime>{};
final _blockedUntil = <FactorPurpose, DateTime>{};
@override
bool get isSimulated => true;
String _id() => List.generate(
24,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
Future<void> _wait() async {
await Future<void>.delayed(delay);
final failure = nextError;
nextError = null;
if (failure != null) throw FactorException(failure);
}
FactorSettings _settings() =>
FactorMapper.settings(FactorSettingsDTO(_enabled, target, method.name));
@override
Future<FactorSettings> fetch() async {
await _wait();
return _settings();
}
void _checkLock(FactorPurpose purpose) {
if (now().isBefore(_blockedUntil[purpose] ?? DateTime(2000))) {
throw const FactorException(FactorError.locked);
}
if (_blockedUntil.remove(purpose) != null) _failures.remove(purpose);
}
FactorChallenge _issue(FactorPurpose purpose, String operation) {
_checkLock(purpose);
if (method == FactorMethod.email &&
now().isBefore(_deliveryAfter[purpose] ?? DateTime(2000))) {
throw const FactorException(FactorError.cooldown);
}
_deliveryAfter[purpose] = now().add(const Duration(seconds: 30));
final dto = FactorChallengeDTO(
_id(),
purpose.name,
operation,
target,
now().add(const Duration(minutes: 5)),
now().add(const Duration(seconds: 30)),
method.name,
);
_challenges[dto.id] = _Attempt(dto);
return FactorMapper.challenge(dto);
}
@override
Future<FactorChallenge?> begin(
FactorPurpose purpose,
String operation,
) async {
if (!_enabled &&
nextError == null &&
purpose != FactorPurpose.enable &&
purpose != FactorPurpose.disable)
return null;
await _wait();
return _issue(purpose, operation);
}
@override
Future<FactorChallenge> resend(String challengeId) async {
await _wait();
final attempt = _challenges[challengeId];
if (method != FactorMethod.email)
throw const FactorException(FactorError.required);
if (attempt == null || attempt.verified)
throw const FactorException(FactorError.expired);
_checkLock(FactorPurpose.values.byName(attempt.dto.purpose));
if (now().isBefore(attempt.dto.resendAt))
throw const FactorException(FactorError.cooldown);
_challenges.remove(challengeId);
return _issue(
FactorPurpose.values.byName(attempt.dto.purpose),
attempt.dto.operation,
);
}
@override
Future<FactorGrant> verify(String challengeId, String value) async {
await _wait();
final attempt = _challenges[challengeId];
if (attempt == null || !now().isBefore(attempt.dto.expiresAt)) {
throw const FactorException(FactorError.expired);
}
final purpose = FactorPurpose.values.byName(attempt.dto.purpose);
_checkLock(purpose);
if (attempt.verified) {
if (value == code && _grants.containsKey(attempt.grant)) {
return FactorMapper.grant(FactorGrantDTO(attempt.grant!));
}
throw const FactorException(FactorError.expired);
}
if (value != code) {
_failures[purpose] = (_failures[purpose] ?? 0) + 1;
if (_failures[purpose]! >= 5) {
_blockedUntil[purpose] = now().add(const Duration(minutes: 5));
throw const FactorException(FactorError.locked);
}
throw const FactorException(FactorError.invalid);
}
_failures.remove(purpose);
attempt.verified = true;
final token = _id();
_grants[token] = attempt.dto;
attempt.grant = token;
return FactorMapper.grant(FactorGrantDTO(token));
}
void _consume(FactorPurpose purpose, String operation, String? grant) {
final dto = _grants[grant];
if (dto == null ||
dto.purpose != purpose.name ||
dto.operation != operation) {
throw const FactorException(FactorError.required);
}
if (!now().isBefore(dto.expiresAt))
throw const FactorException(FactorError.expired);
_grants.remove(grant);
}
@override
Future<void> authorize(
FactorPurpose purpose,
String operation,
String? grant,
) async {
await _wait();
if (grant != null ||
_enabled ||
purpose == FactorPurpose.enable ||
purpose == FactorPurpose.disable) {
_consume(purpose, operation, grant);
}
}
@override
Future<FactorSettings> setEnabled(bool enabled, String grant) async {
await _wait();
final purpose = enabled ? FactorPurpose.enable : FactorPurpose.disable;
_consume(purpose, purpose.name, grant);
_enabled = enabled;
// Changing protection invalidates all prior challenges and approvals.
_challenges.clear();
_grants.clear();
return _settings();
}
}
class _Attempt {
_Attempt(this.dto);
final FactorChallengeDTO dto;
String? grant;
bool verified = false;
}
@@ -0,0 +1,71 @@
import '../models/auth.dart';
import '../models/two_factor.dart';
import 'contracts.dart';
import 'two_factor.dart';
import 'verification.dart';
abstract interface class PendingLoginService implements LoginService {
Future<AuthResult> completeLogin(
FactorChallenge challenge,
FactorGrant grant,
);
}
/// Holds a successful credential result until the second factor is authorized.
/// A production adapter should receive the pending result from the backend.
class GuardedLoginService implements PendingLoginService, SimulationInfo {
GuardedLoginService(this.credentials, this.factors);
final LoginService credentials;
final TwoFactorService factors;
final _pending = <String, AuthResult>{};
int _serial = 0;
@override
bool get isSimulated =>
factors.isSimulated ||
(credentials is SimulationInfo &&
(credentials as SimulationInfo).isSimulated);
@override
Future<AuthResult> login(LoginRequest request) async {
final accepted = await credentials.login(request);
if (accepted.challenge != null) return accepted;
final result = AuthResult(
userId: accepted.userId,
isSimulated: accepted.isSimulated || factors.isSimulated,
);
final challenge = await factors.begin(
FactorPurpose.login,
'login-${++_serial}',
);
if (challenge == null) return result;
_pending[challenge.operation] = result;
return AuthResult(
userId: '',
isSimulated: result.isSimulated,
challenge: challenge,
);
}
@override
Future<AuthResult> completeLogin(
FactorChallenge challenge,
FactorGrant grant,
) async {
final pending = _pending[challenge.operation];
if (pending == null) {
if (credentials is PendingLoginService) {
return (credentials as PendingLoginService).completeLogin(
challenge,
grant,
);
}
throw const AuthException(AuthError.unavailable);
}
await factors.authorize(
FactorPurpose.login,
challenge.operation,
grant.token,
);
return _pending.remove(challenge.operation) ??
(throw const AuthException(AuthError.unavailable));
}
}
@@ -1,14 +1,16 @@
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/auth.dart';
import '../models/two_factor.dart';
import '../models/verification.dart';
import 'contracts.dart';
import 'guarded_login.dart';
import 'verification.dart';
/// Decorates domain services, so widget rebuilds never produce analytics events.
class InstrumentedAuthServices
implements
LoginService,
PendingLoginService,
SignupService,
VerificationService,
SimulationInfo {
@@ -33,6 +35,7 @@ class InstrumentedAuthServices
Future<T> Function() action,
bool Function(T) simulated, {
bool terminal = false,
bool Function(T)? terminalWhen,
bool onboarding = false,
bool simulation = false,
}) async {
@@ -47,7 +50,7 @@ class InstrumentedAuthServices
recorder.finish(
operation,
simulated: simulated(result),
terminal: terminal,
terminal: terminalWhen?.call(result) ?? terminal,
);
return result;
} catch (error) {
@@ -57,12 +60,17 @@ class InstrumentedAuthServices
AuthError.emailInUse => JourneyFailure.emailInUse,
AuthError.unavailable => JourneyFailure.unavailable,
AuthError.unexpected => JourneyFailure.unexpected,
AuthError.factorLocked ||
AuthError.factorCooldown => JourneyFailure.unavailable,
AuthError.factorRequired => JourneyFailure.invalid,
},
VerificationException(code: final code) => switch (code) {
VerificationError.expired => JourneyFailure.expired,
VerificationError.invalid => JourneyFailure.invalid,
VerificationError.internal => JourneyFailure.internal,
},
FactorException(code: FactorError.expired) => JourneyFailure.expired,
FactorException() => JourneyFailure.unavailable,
_ => JourneyFailure.unexpected,
};
recorder.finish(operation, failure: failure);
@@ -79,6 +87,23 @@ class InstrumentedAuthServices
JourneyStep.login,
() => loginService.login(request),
(r) => r.isSimulated,
terminalWhen: (r) => r.challenge == null,
simulation: _simulation(loginService),
);
@override
Future<AuthResult> completeLogin(
FactorChallenge challenge,
FactorGrant grant,
) => _run(
JourneyKind.login,
JourneyStep.login,
() {
final service = loginService;
if (service is! PendingLoginService)
throw const AuthException(AuthError.unavailable);
return service.completeLogin(challenge, grant);
},
(r) => r.isSimulated,
terminal: true,
simulation: _simulation(loginService),
);
@@ -0,0 +1,19 @@
import '../models/two_factor.dart';
abstract interface class TwoFactorService {
bool get isSimulated;
Future<FactorSettings> fetch();
/// The service decides whether this purpose requires an additional factor.
Future<FactorChallenge?> begin(FactorPurpose purpose, String operation);
Future<FactorChallenge> resend(String challengeId);
Future<FactorGrant> verify(String challengeId, String code);
/// Operation adapters consume a one-use, scope-bound grant at execution.
Future<void> authorize(
FactorPurpose purpose,
String operation,
String? grant,
);
Future<FactorSettings> setEnabled(bool enabled, String grant);
}