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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user