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,25 @@
|
||||
class FactorSettingsDTO {
|
||||
const FactorSettingsDTO(this.enabled, this.target, this.method);
|
||||
final bool enabled;
|
||||
final String method;
|
||||
final String target;
|
||||
}
|
||||
|
||||
class FactorChallengeDTO {
|
||||
const FactorChallengeDTO(
|
||||
this.id,
|
||||
this.purpose,
|
||||
this.operation,
|
||||
this.target,
|
||||
this.expiresAt,
|
||||
this.resendAt,
|
||||
this.method,
|
||||
);
|
||||
final String id, purpose, operation, target, method;
|
||||
final DateTime expiresAt, resendAt;
|
||||
}
|
||||
|
||||
class FactorGrantDTO {
|
||||
const FactorGrantDTO(this.token);
|
||||
final String token;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import '../dto/two_factor.dart';
|
||||
import '../models/two_factor.dart';
|
||||
|
||||
abstract final class FactorMapper {
|
||||
static FactorSettings settings(FactorSettingsDTO dto) => FactorSettings(
|
||||
enabled: dto.enabled,
|
||||
target: dto.target,
|
||||
method: FactorMethod.values.byName(dto.method),
|
||||
);
|
||||
static FactorChallenge challenge(FactorChallengeDTO dto) => FactorChallenge(
|
||||
id: dto.id,
|
||||
purpose: FactorPurpose.values.byName(dto.purpose),
|
||||
operation: dto.operation,
|
||||
target: dto.target,
|
||||
expiresAt: dto.expiresAt,
|
||||
resendAt: dto.resendAt,
|
||||
method: FactorMethod.values.byName(dto.method),
|
||||
);
|
||||
static FactorGrant grant(FactorGrantDTO dto) => FactorGrant(dto.token);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'two_factor.dart';
|
||||
|
||||
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||
class LoginRequest {
|
||||
const LoginRequest({
|
||||
@@ -25,12 +27,25 @@ class SignupRequest {
|
||||
|
||||
/// An operation result, not proof of a session or an authorization decision.
|
||||
class AuthResult {
|
||||
const AuthResult({required this.userId, required this.isSimulated});
|
||||
const AuthResult({
|
||||
required this.userId,
|
||||
required this.isSimulated,
|
||||
this.challenge,
|
||||
});
|
||||
final String userId;
|
||||
final FactorChallenge? challenge;
|
||||
final bool isSimulated;
|
||||
}
|
||||
|
||||
enum AuthError { invalidCredentials, emailInUse, unavailable, unexpected }
|
||||
enum AuthError {
|
||||
invalidCredentials,
|
||||
emailInUse,
|
||||
unavailable,
|
||||
unexpected,
|
||||
factorLocked,
|
||||
factorCooldown,
|
||||
factorRequired,
|
||||
}
|
||||
|
||||
class AuthException implements Exception {
|
||||
const AuthException(this.code);
|
||||
@@ -39,7 +54,7 @@ class AuthException implements Exception {
|
||||
String toString() => 'AuthException(${code.name})';
|
||||
}
|
||||
|
||||
enum AuthStatus { idle, loading, success, error }
|
||||
enum AuthStatus { idle, loading, challenge, success, error }
|
||||
|
||||
class AuthState {
|
||||
const AuthState._(this.status, {this.result, this.error});
|
||||
@@ -47,6 +62,8 @@ class AuthState {
|
||||
const AuthState.loading() : this._(AuthStatus.loading);
|
||||
const AuthState.success(AuthResult result)
|
||||
: this._(AuthStatus.success, result: result);
|
||||
const AuthState.challenge(AuthResult result)
|
||||
: this._(AuthStatus.challenge, result: result);
|
||||
const AuthState.failure(AuthError error)
|
||||
: this._(AuthStatus.error, error: error);
|
||||
final AuthStatus status;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
enum FactorMethod { email, authenticator }
|
||||
|
||||
enum FactorPurpose { login, topUp, exchange, payout, enable, disable }
|
||||
|
||||
enum FactorError { invalid, expired, locked, cooldown, unavailable, required }
|
||||
|
||||
class FactorException implements Exception {
|
||||
const FactorException(this.code);
|
||||
final FactorError code;
|
||||
}
|
||||
|
||||
class FactorSettings {
|
||||
const FactorSettings({
|
||||
required this.enabled,
|
||||
required this.target,
|
||||
this.method = FactorMethod.email,
|
||||
});
|
||||
final bool enabled;
|
||||
final FactorMethod method;
|
||||
final String target;
|
||||
}
|
||||
|
||||
class FactorChallenge {
|
||||
const FactorChallenge({
|
||||
required this.id,
|
||||
required this.purpose,
|
||||
required this.operation,
|
||||
required this.target,
|
||||
required this.expiresAt,
|
||||
required this.resendAt,
|
||||
this.method = FactorMethod.email,
|
||||
});
|
||||
final String id, operation, target;
|
||||
final FactorPurpose purpose;
|
||||
final FactorMethod method;
|
||||
final DateTime expiresAt, resendAt;
|
||||
}
|
||||
|
||||
class FactorGrant {
|
||||
const FactorGrant(this.token);
|
||||
final String token;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/auth.dart';
|
||||
import '../models/two_factor.dart';
|
||||
|
||||
/// Shared operation lifecycle adapted from Sendico's ChangeNotifier providers.
|
||||
/// Reset invalidates pending results; it does not cancel service-side work.
|
||||
@@ -19,13 +20,21 @@ abstract class AuthProvider extends ChangeNotifier {
|
||||
try {
|
||||
final result = await operation();
|
||||
if (_disposed || generation != _generation) return;
|
||||
_state = AuthState.success(result);
|
||||
_state = result.challenge == null
|
||||
? AuthState.success(result)
|
||||
: AuthState.challenge(result);
|
||||
} catch (error) {
|
||||
if (_disposed || generation != _generation) return;
|
||||
// Never retain or expose arbitrary exceptions that may contain credentials.
|
||||
_state = AuthState.failure(
|
||||
error is AuthException ? error.code : AuthError.unexpected,
|
||||
);
|
||||
_state = AuthState.failure(switch (error) {
|
||||
AuthException(code: final code) => code,
|
||||
FactorException(code: FactorError.locked) => AuthError.factorLocked,
|
||||
FactorException(code: FactorError.cooldown) => AuthError.factorCooldown,
|
||||
FactorException(code: FactorError.required || FactorError.expired) =>
|
||||
AuthError.factorRequired,
|
||||
FactorException() => AuthError.unavailable,
|
||||
_ => AuthError.unexpected,
|
||||
});
|
||||
}
|
||||
if (!_disposed && generation == _generation) notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import '../models/auth.dart';
|
||||
import '../models/two_factor.dart';
|
||||
import '../services/contracts.dart';
|
||||
import '../services/guarded_login.dart';
|
||||
import 'auth.dart';
|
||||
|
||||
class LoginProvider extends AuthProvider {
|
||||
LoginProvider(this.service);
|
||||
final LoginService service;
|
||||
|
||||
Future<void> complete(FactorChallenge challenge, FactorGrant grant) =>
|
||||
run(() {
|
||||
final adapter = service;
|
||||
if (adapter is! PendingLoginService)
|
||||
throw const AuthException(AuthError.unavailable);
|
||||
return adapter.completeLogin(challenge, grant);
|
||||
});
|
||||
|
||||
Future<void> login({
|
||||
required String email,
|
||||
required String password,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/two_factor.dart';
|
||||
import '../services/two_factor.dart';
|
||||
|
||||
class SecurityProvider extends ChangeNotifier {
|
||||
SecurityProvider(this.service, AnalyticsService analytics)
|
||||
: recorder = JourneyRecorder(analytics);
|
||||
final TwoFactorService service;
|
||||
final JourneyRecorder recorder;
|
||||
FactorSettings? data;
|
||||
FactorError? error;
|
||||
bool busy = false, _disposed = false;
|
||||
Future<void> refresh() async {
|
||||
if (busy || _disposed) return;
|
||||
await _run(() async {
|
||||
final value = await service.fetch();
|
||||
if (!_disposed) data = value;
|
||||
});
|
||||
}
|
||||
|
||||
Future<FactorChallenge?> prepare(bool enabled) async {
|
||||
if (busy || _disposed || data == null) return null;
|
||||
FactorChallenge? challenge;
|
||||
final op = recorder.begin(
|
||||
JourneyKind.security,
|
||||
JourneyStep.factorChallenge,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
await _run(() async {
|
||||
final purpose = enabled ? FactorPurpose.enable : FactorPurpose.disable;
|
||||
challenge = await service.begin(purpose, purpose.name);
|
||||
});
|
||||
if (_disposed) return null;
|
||||
recorder.finish(
|
||||
op,
|
||||
simulated: service.isSimulated,
|
||||
failure: error == null ? null : JourneyFailure.unavailable,
|
||||
);
|
||||
return challenge;
|
||||
}
|
||||
|
||||
Future<void> apply(bool enabled, FactorGrant grant) async {
|
||||
if (busy || _disposed) return;
|
||||
final op = recorder.begin(
|
||||
JourneyKind.security,
|
||||
JourneyStep.factorSettings,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
await _run(() async {
|
||||
final result = await service.setEnabled(enabled, grant.token);
|
||||
if (!_disposed) data = result;
|
||||
});
|
||||
if (_disposed) return;
|
||||
recorder.finish(
|
||||
op,
|
||||
simulated: service.isSimulated,
|
||||
terminal: error == null,
|
||||
failure: error == null ? null : JourneyFailure.unavailable,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _run(Future<void> Function() action) async {
|
||||
busy = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (!_disposed)
|
||||
error = e is FactorException ? e.code : FactorError.unavailable;
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
recorder.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/two_factor.dart';
|
||||
import '../services/two_factor.dart';
|
||||
|
||||
class TwoFactorProvider extends ChangeNotifier {
|
||||
TwoFactorProvider(
|
||||
this.service,
|
||||
this.challenge, {
|
||||
required this.recorder,
|
||||
required this.journey,
|
||||
DateTime Function()? now,
|
||||
}) : now = now ?? DateTime.now {
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => notifyListeners(),
|
||||
);
|
||||
}
|
||||
final TwoFactorService service;
|
||||
final JourneyRecorder recorder;
|
||||
final JourneyKind journey;
|
||||
final DateTime Function() now;
|
||||
FactorChallenge challenge;
|
||||
FactorGrant? result;
|
||||
FactorError? error;
|
||||
bool busy = false, _disposed = false;
|
||||
Timer? _timer;
|
||||
bool get expired => !now().isBefore(challenge.expiresAt);
|
||||
int get cooldown {
|
||||
final remaining =
|
||||
(challenge.resendAt.difference(now()).inMilliseconds / 1000).ceil();
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
bool get locked => error == FactorError.locked;
|
||||
Future<void> submit(String code) async {
|
||||
if (_disposed || busy || result != null || locked) return;
|
||||
if (expired) {
|
||||
error = FactorError.expired;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
if (!RegExp(r'^\d{6}$').hasMatch(code)) {
|
||||
error = FactorError.invalid;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
await _run(JourneyStep.factorVerification, () async {
|
||||
final grant = await service.verify(challenge.id, code);
|
||||
if (!_disposed) result = grant;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> resend() async {
|
||||
if (_disposed || busy || cooldown > 0 || locked || result != null) return;
|
||||
await _run(JourneyStep.factorDelivery, () async {
|
||||
final next = await service.resend(challenge.id);
|
||||
if (!_disposed) challenge = next;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _run(JourneyStep step, Future<void> Function() action) async {
|
||||
busy = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
final op = recorder.begin(journey, step, simulated: service.isSimulated);
|
||||
try {
|
||||
await action();
|
||||
if (_disposed) return;
|
||||
recorder.finish(op, simulated: service.isSimulated);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = e is FactorException ? e.code : FactorError.unavailable;
|
||||
recorder.finish(
|
||||
op,
|
||||
simulated: service.isSimulated,
|
||||
failure: switch (error!) {
|
||||
FactorError.invalid => JourneyFailure.invalid,
|
||||
FactorError.expired => JourneyFailure.expired,
|
||||
_ => JourneyFailure.unavailable,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/two_factor.dart';
|
||||
import '../providers/two_factor.dart';
|
||||
|
||||
/// Labels are supplied by each client's localization layer.
|
||||
class TwoFactorLabels {
|
||||
const TwoFactorLabels({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.code,
|
||||
required this.confirm,
|
||||
required this.cancel,
|
||||
required this.resend,
|
||||
required this.expired,
|
||||
required this.help,
|
||||
required this.errorText,
|
||||
required this.cooldownText,
|
||||
});
|
||||
final String title, description, code, confirm, cancel, resend, expired, help;
|
||||
final String Function(FactorError) errorText;
|
||||
final String Function(int) cooldownText;
|
||||
}
|
||||
|
||||
/// Reusable full-page code entry, owned by the caller's route/provider lifecycle.
|
||||
class TwoFactorPage extends StatefulWidget {
|
||||
const TwoFactorPage({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.labels,
|
||||
required this.onVerified,
|
||||
required this.onCancel,
|
||||
this.summary,
|
||||
});
|
||||
final TwoFactorProvider provider;
|
||||
final TwoFactorLabels labels;
|
||||
final ValueChanged<FactorGrant> onVerified;
|
||||
final VoidCallback onCancel;
|
||||
final Widget? summary;
|
||||
@override
|
||||
State<TwoFactorPage> createState() => _TwoFactorPageState();
|
||||
}
|
||||
|
||||
class _TwoFactorPageState extends State<TwoFactorPage> {
|
||||
final _code = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
bool _delivered = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.provider.addListener(_changed);
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
final p = widget.provider;
|
||||
if (p.result != null && !_delivered) {
|
||||
_delivered = true;
|
||||
_code.clear();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onVerified(p.result!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
widget.provider.submit(_code.text.trim());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.provider.removeListener(_changed);
|
||||
_code.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: widget.provider,
|
||||
builder: (context, _) {
|
||||
final p = widget.provider, l = widget.labels;
|
||||
final t = Theme.of(context), c = Theme.of(context).colorScheme;
|
||||
return PopScope(
|
||||
canPop: !p.busy,
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundColor: c.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.verified_user_outlined,
|
||||
size: 30,
|
||||
color: c.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Semantics(
|
||||
header: true,
|
||||
child: Text(l.title, style: t.textTheme.displaySmall),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l.description, style: t.textTheme.bodyLarge),
|
||||
if (p.challenge.method == FactorMethod.email) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(p.challenge.target),
|
||||
],
|
||||
if (widget.summary != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
widget.summary!,
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
if (p.busy) const LinearProgressIndicator(),
|
||||
TextField(
|
||||
key: const ValueKey('two-factor-code'),
|
||||
controller: _code,
|
||||
focusNode: _focus,
|
||||
autofocus: true,
|
||||
enabled: !p.busy && !p.locked && !p.expired,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
style: t.textTheme.headlineMedium?.copyWith(
|
||||
letterSpacing: 8,
|
||||
),
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
decoration: InputDecoration(labelText: l.code),
|
||||
),
|
||||
if (p.error != null || p.expired) ...[
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
p.error == null ? l.expired : l.errorText(p.error!),
|
||||
key: const ValueKey('two-factor-error'),
|
||||
style: TextStyle(color: c.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
key: const ValueKey('two-factor-confirm'),
|
||||
onPressed: p.busy || p.expired || p.locked
|
||||
? null
|
||||
: _submit,
|
||||
child: Text(l.confirm),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (p.challenge.method == FactorMethod.email)
|
||||
TextButton(
|
||||
key: const ValueKey('two-factor-resend'),
|
||||
onPressed: p.busy || p.cooldown > 0 || p.locked
|
||||
? null
|
||||
: () async {
|
||||
await p.resend();
|
||||
if (mounted && p.error == null) {
|
||||
_code.clear();
|
||||
_focus.requestFocus();
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
p.cooldown > 0
|
||||
? l.cooldownText(p.cooldown)
|
||||
: l.resend,
|
||||
),
|
||||
),
|
||||
Text(l.help, style: t.textTheme.bodySmall),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
key: const ValueKey('two-factor-cancel'),
|
||||
onPressed: p.busy ? null : widget.onCancel,
|
||||
child: Text(l.cancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user