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

@@ -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();
}
}