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

+36 -3
View File
@@ -27,11 +27,11 @@ await login.login(
login.dispose();
```
`SignupProvider` accepts a `SignupService` and exposes `signup(name:, email:, password:, locale:)`. Both providers extend ChangeNotifier and can be installed with `ChangeNotifierProvider`, ListenableBuilder, or another Flutter state binding. The package itself depends only on Flutter foundation; it contains no widgets, routes, localization, web-only code, network client, or storage.
`SignupProvider` accepts a `SignupService` and exposes `signup(name:, email:, password:, locale:)`. Both providers extend ChangeNotifier and can be installed with `ChangeNotifierProvider`, ListenableBuilder, or another Flutter state binding. The package includes providers and a reusable Material two-factor page. Clients own routes and localization; the package contains no web-only code, network client, or storage.
## Lifecycle and services
Providers expose immutable `AuthState`: idle, loading, success, or error. They trim email/name, preserve the password verbatim, pass the locale to services, suppress duplicate submissions while loading, and clear old errors before retrying. Reset invalidates older responses and allows a new request; disposal ignores all later completions. Reset does not cancel remote work. Do not treat a provider reset as undoing a production signup.
Providers expose immutable `AuthState`: idle, loading, challenge, success, or error. They trim email/name, preserve the password verbatim, pass the locale to services, suppress duplicate submissions while loading, and clear old errors before retrying. Reset invalidates older responses and allows a new request; disposal ignores all later completions. Reset does not cancel remote work. Do not treat a provider reset as undoing a production signup.
Services return `AuthResult` or throw `AuthException` with a typed `AuthError`. Unexpected exceptions are reduced to `unexpected`; raw errors and credentials are not retained in provider state. Credentials exist only as ephemeral request input. Localize error codes in each consuming application.
@@ -50,7 +50,7 @@ Supported failure codes: `invalidCredentials`, `emailInUse`, `unavailable`, and
Adapted the ChangeNotifier lifecycle, service separation, locale-carrying request, and typed-outcome patterns from Sendico's `pshared/lib/provider/account.dart`, `models/auth/login_outcome.dart`, and `service/authorization/client.dart`. These were patterns rather than directly portable implementations: the originals depend on Sendico account/organization models, device IDs, static API services, token storage, and verification flows. Those dependencies are deliberately absent here. The B2B organization signup contract is not copied into the consumer product.
When a supported sxchnge BFF contract exists, implement transport DTOs and mappers behind the service interfaces, returning domain results. No dummy DTO/mapping layer is needed without serialized transport. Keep web cookie and mobile credential handling in production platform adapters, not the providers.
When a supported sxchnge BFF contract exists, implement transport DTOs and mappers behind the service interfaces, returning domain results. Dummy responses also pass through explicit DTOs and mappers, following the repository architecture. Keep web cookie and mobile credential handling in production platform adapters, not the providers.
## Validation
@@ -66,3 +66,36 @@ When a supported sxchnge BFF contract exists, implement transport DTOs and mappe
shared `JourneyRecorder` from `sxchnge_analytics`. It preserves results/exceptions
for providers while emitting only typed semantic outcomes. `SimulationInfo`
allows decorators and UI to identify preview services before the first result.
## Two-factor verification
`TwoFactorService` owns settings, challenge creation/resend, verification and
one-use approval consumption. DTOs map to `FactorSettings`, `FactorChallenge`
and `FactorGrant`; `SecurityProvider` and `TwoFactorProvider` coordinate state.
`TwoFactorPage` accepts the provider, client-localized `TwoFactorLabels`, callbacks
and an optional transaction summary. Its route owner must dispose the provider;
the page disposes its own code/focus controllers. No code or grant is persisted.
`FactorMethod.email` shows masked delivery and resend controls;
`FactorMethod.authenticator` uses app-code instructions without delivery UI.
The contract is ready for a future authenticator/TOTP adapter. Enrollment, secret
provisioning, method selection and recovery require a backend contract first.
`GuardedLoginService` wraps a credentials service and exposes a pending challenge
instead of successful login. `LoginProvider.complete` completes through
`PendingLoginService` only after scope-bound authorization. Production adapters
must return backend-owned pending login results and enforce session issuance
there; this local wrapper is not a production authorization boundary.
The initial `DummyTwoFactorService` is disabled by default. Enable it in account
settings using fixture code **123456**. It resets on reload, sends no email and
does not validate real TOTP. Challenges expire after five minutes; email delivery
has a 30-second cooldown; five wrong attempts lock that purpose for five minutes.
Resending cannot reset attempts. Tests can inject `now`, `delay`, `method`,
`enabled` and `nextError` to exercise states.
Use the same service instance for login, settings and the dummy wallet. A custom
`WalletService` must validate the passed approval at execution and preserve
idempotency; attaching the page alone does not protect funds. Production checks
must bind approvals to the authenticated account and immutable operation details,
validate expiry/policy, and atomically consume approval with execution.
@@ -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),
),
],
),
),
),
),
),
),
);
},
);
}
@@ -12,3 +12,11 @@ export 'src/providers/verification.dart';
export 'src/services/verification.dart';
export 'src/services/instrumented.dart';
export 'src/models/two_factor.dart';
export 'src/providers/two_factor.dart';
export 'src/providers/security.dart';
export 'src/services/two_factor.dart';
export 'src/services/dummy_factor.dart';
export 'src/widgets/two_factor.dart';
export 'src/services/guarded_login.dart';
@@ -0,0 +1,212 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
void main() {
late DateTime now;
late DummyTwoFactorService service;
setUp(() {
now = DateTime(2026, 9, 12);
service = DummyTwoFactorService(delay: Duration.zero, now: () => now);
});
Matcher error(FactorError code) =>
throwsA(isA<FactorException>().having((e) => e.code, 'code', code));
Future<FactorGrant> grant(FactorPurpose purpose, String op) async {
final c = (await service.begin(purpose, op))!;
return service.verify(c.id, '123456');
}
test(
'protection is off until enrollment code and setting update complete',
() async {
final c = (await service.begin(FactorPurpose.enable, 'enable'))!;
expect((await service.fetch()).enabled, false);
await expectLater(
service.verify(c.id, '000000'),
error(FactorError.invalid),
);
expect((await service.fetch()).enabled, false);
final g = await service.verify(c.id, '123456');
expect((await service.fetch()).enabled, false);
expect((await service.setEnabled(true, g.token)).enabled, true);
await expectLater(
service.setEnabled(false, g.token),
error(FactorError.required),
);
final off = await grant(FactorPurpose.disable, 'disable');
expect((await service.setEnabled(false, off.token)).enabled, false);
},
);
test('grant is scope bound, one-use and expires', () async {
final g = await grant(FactorPurpose.enable, 'enable');
await expectLater(
service.authorize(FactorPurpose.disable, 'disable', g.token),
error(FactorError.required),
);
await expectLater(
service.authorize(FactorPurpose.enable, 'other', g.token),
error(FactorError.required),
);
now = now.add(const Duration(minutes: 6));
await expectLater(
service.authorize(FactorPurpose.enable, 'enable', g.token),
error(FactorError.expired),
);
});
test('resend has cooldown and invalidates old challenge', () async {
final a = (await service.begin(FactorPurpose.enable, 'enable'))!;
await expectLater(service.resend(a.id), error(FactorError.cooldown));
await expectLater(
service.begin(FactorPurpose.enable, 'enable'),
error(FactorError.cooldown),
);
now = now.add(const Duration(seconds: 31));
final b = await service.resend(a.id);
await expectLater(
service.verify(a.id, '123456'),
error(FactorError.expired),
);
expect((await service.verify(b.id, '123456')).token, isNotEmpty);
});
test(
'attempt limit survives resend and resets only after lock expires',
() async {
var c = (await service.begin(FactorPurpose.enable, 'enable'))!;
for (var i = 0; i < 4; i++) {
await expectLater(
service.verify(c.id, '000000'),
error(FactorError.invalid),
);
}
now = now.add(const Duration(seconds: 31));
c = await service.resend(c.id);
await expectLater(
service.verify(c.id, '000000'),
error(FactorError.locked),
);
await expectLater(
service.verify(c.id, '123456'),
error(FactorError.locked),
);
await expectLater(
service.begin(FactorPurpose.enable, 'enable'),
error(FactorError.locked),
);
now = now.add(const Duration(minutes: 6));
c = (await service.begin(FactorPurpose.enable, 'enable'))!;
expect((await service.verify(c.id, '123456')).token, isNotEmpty);
},
);
test('verification retry recovers the same unconsumed grant', () async {
final c = (await service.begin(FactorPurpose.enable, 'enable'))!;
final a = await service.verify(c.id, '123456');
final b = await service.verify(c.id, '123456');
expect(a.token, b.token);
await service.setEnabled(true, a.token);
await expectLater(
service.verify(c.id, '123456'),
error(FactorError.expired),
);
});
test(
'credential acceptance is pending until factor completes login',
() async {
service = DummyTwoFactorService(enabled: true, delay: Duration.zero);
final login = LoginProvider(
GuardedLoginService(
const DummyLoginService(delay: Duration.zero),
service,
),
);
addTearDown(login.dispose);
await login.login(
email: 'person@example.com',
password: 'secret',
locale: 'en',
);
expect(login.state.status, AuthStatus.challenge);
expect(login.state.result!.userId, isEmpty);
final c = login.state.result!.challenge!;
final g = await service.verify(c.id, '123456');
await login.complete(c, g);
expect(login.state.status, AuthStatus.success);
expect(login.state.result!.challenge, null);
},
);
test(
'login without enabled factor retains original success contract',
() async {
final login = LoginProvider(
GuardedLoginService(
const DummyLoginService(delay: Duration.zero),
service,
),
);
addTearDown(login.dispose);
await login.login(
email: 'person@example.com',
password: 'secret',
locale: 'en',
);
expect(login.state.status, AuthStatus.success);
},
);
test('stub factor policy cannot report a production login outcome', () async {
final guarded = GuardedLoginService(_AcceptedCredentials(), service);
final result = await guarded.login(
const LoginRequest(
email: 'person@example.com',
password: 'secret',
locale: 'en',
),
);
expect(result.isSimulated, true);
expect(guarded.isSimulated, true);
});
test('provider ignores verification result after disposal', () async {
final c = (await service.begin(FactorPurpose.enable, 'enable'))!;
final recorder = JourneyRecorder(DummyAnalyticsService());
addTearDown(recorder.dispose);
final provider = TwoFactorProvider(
service,
c,
recorder: recorder,
journey: JourneyKind.security,
now: () => now,
);
final pending = provider.submit('123456');
provider.dispose();
await pending;
expect(provider.result, null);
});
test(
'authenticator challenge is method aware and cannot resend email',
() async {
service = DummyTwoFactorService(
enabled: true,
delay: Duration.zero,
method: FactorMethod.authenticator,
);
final c = (await service.begin(FactorPurpose.login, 'login'))!;
expect(c.method, FactorMethod.authenticator);
await expectLater(service.resend(c.id), error(FactorError.required));
},
);
test('service errors never toggle protection', () async {
final g = await grant(FactorPurpose.enable, 'enable');
service.nextError = FactorError.unavailable;
await expectLater(
service.setEnabled(true, g.token),
error(FactorError.unavailable),
);
expect((await service.fetch()).enabled, false);
});
}
class _AcceptedCredentials implements LoginService {
@override
Future<AuthResult> login(LoginRequest request) async =>
const AuthResult(userId: 'account', isSimulated: false);
}