Add reusable two-factor verification for login and money operations (#32)
This commit is contained in:
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);
|
||||
}
|
||||
Reference in new issue
Block a user