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
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import 'wallet.dart';
|
||||
|
||||
@@ -33,6 +35,7 @@ class TransferProvider extends ChangeNotifier {
|
||||
Quote? quote;
|
||||
WalletOperation? result;
|
||||
WalletError? error;
|
||||
FactorError? factorError;
|
||||
bool noSubmission = false;
|
||||
bool busy = false, uncertain = false, _disposed = false, _handedOff = false;
|
||||
int _revision = 0;
|
||||
@@ -59,6 +62,7 @@ class TransferProvider extends ChangeNotifier {
|
||||
quote = null;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
factorError = null;
|
||||
_key = null;
|
||||
_revision++;
|
||||
notifyListeners();
|
||||
@@ -68,6 +72,7 @@ class TransferProvider extends ChangeNotifier {
|
||||
if (!busy && !uncertain) {
|
||||
quote = null;
|
||||
error = null;
|
||||
factorError = null;
|
||||
_key = null;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -84,6 +89,7 @@ class TransferProvider extends ChangeNotifier {
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
factorError = null;
|
||||
notifyListeners();
|
||||
final revision = ++_revision;
|
||||
final op = recorder.begin(
|
||||
@@ -115,7 +121,45 @@ class TransferProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> submit() async {
|
||||
Future<FactorChallenge?> challenge() async {
|
||||
if (busy || uncertain || quote == null) return null;
|
||||
if (expired) {
|
||||
error = WalletError.expiredQuote;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
busy = true;
|
||||
error = null;
|
||||
factorError = null;
|
||||
notifyListeners();
|
||||
FactorChallenge? result;
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.factorChallenge,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
try {
|
||||
result = await wallet.factors?.begin(
|
||||
FactorPurpose.values.byName(kind.name),
|
||||
quote!.id,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!_disposed) {
|
||||
factorError = e is FactorException ? e.code : FactorError.unavailable;
|
||||
}
|
||||
}
|
||||
if (_disposed) return null;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
recorder.finish(
|
||||
op,
|
||||
simulated: wallet.service.isSimulated,
|
||||
failure: factorError == null ? null : JourneyFailure.unavailable,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> submit({FactorGrant? approval}) async {
|
||||
if (busy || quote == null || uncertain) return;
|
||||
if (expired) {
|
||||
error = WalletError.expiredQuote;
|
||||
@@ -125,6 +169,7 @@ class TransferProvider extends ChangeNotifier {
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
factorError = null;
|
||||
notifyListeners();
|
||||
_key ??= List.generate(
|
||||
16,
|
||||
@@ -136,18 +181,31 @@ class TransferProvider extends ChangeNotifier {
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
try {
|
||||
final r = await wallet.service.submit(quote!.id, _key!);
|
||||
final r = await wallet.service.submit(
|
||||
quote!.id,
|
||||
_key!,
|
||||
approval: approval?.token,
|
||||
);
|
||||
if (_disposed) return;
|
||||
_accepted(r, op);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
if (e is FactorException) {
|
||||
error = null;
|
||||
factorError = e.code;
|
||||
uncertain = false;
|
||||
} else {
|
||||
error = _code(e);
|
||||
}
|
||||
// All transport/unexpected failures are ambiguous after execution started.
|
||||
uncertain = [
|
||||
WalletError.unavailable,
|
||||
WalletError.uncertain,
|
||||
].contains(error);
|
||||
recorder.finish(op, failure: _failure(error!));
|
||||
recorder.finish(
|
||||
op,
|
||||
failure: error == null ? JourneyFailure.invalid : _failure(error!),
|
||||
);
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
@@ -158,6 +216,7 @@ class TransferProvider extends ChangeNotifier {
|
||||
result = value;
|
||||
uncertain = false;
|
||||
error = null;
|
||||
factorError = null;
|
||||
recipient = '';
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
wallet.track(value, recorder, journey);
|
||||
@@ -169,6 +228,7 @@ class TransferProvider extends ChangeNotifier {
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
factorError = null;
|
||||
notifyListeners();
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
|
||||
@@ -4,12 +4,15 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../services/contracts.dart';
|
||||
|
||||
class WalletProvider extends ChangeNotifier {
|
||||
WalletProvider(this.service, this.analytics);
|
||||
WalletProvider(this.service, this.analytics, {this.factors});
|
||||
final WalletService service;
|
||||
final TwoFactorService? factors;
|
||||
final AnalyticsService analytics;
|
||||
WalletSnapshot? data;
|
||||
bool loading = false,
|
||||
|
||||
@@ -6,7 +6,11 @@ abstract interface class WalletService {
|
||||
Future<Quote> quote(TransferRequest request);
|
||||
|
||||
/// Same key must reconcile uncertain submissions without duplicate execution.
|
||||
Future<WalletOperation> submit(String quoteId, String idempotencyKey);
|
||||
Future<WalletOperation> submit(
|
||||
String quoteId,
|
||||
String idempotencyKey, {
|
||||
String? approval,
|
||||
});
|
||||
Future<WalletOperation?> findSubmission(String idempotencyKey);
|
||||
Future<AccountProfile> profile();
|
||||
Future<AccountProfile> saveLocale(String locale);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/limits_transport.dart';
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
@@ -11,6 +13,7 @@ import 'contracts.dart';
|
||||
class DummyWalletService implements WalletService {
|
||||
DummyWalletService(
|
||||
this.identity, {
|
||||
this.factors,
|
||||
this.delay = const Duration(milliseconds: 350),
|
||||
this.settlement = const Duration(seconds: 2),
|
||||
DateTime Function()? now,
|
||||
@@ -18,6 +21,7 @@ class DummyWalletService implements WalletService {
|
||||
}) : now = now ?? DateTime.now,
|
||||
_locale = initialLocale;
|
||||
final IdentityService identity;
|
||||
final TwoFactorService? factors;
|
||||
final Duration delay, settlement;
|
||||
final DateTime Function() now;
|
||||
final _accounts = [
|
||||
@@ -159,13 +163,28 @@ class DummyWalletService implements WalletService {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletOperation> submit(String quoteId, String idempotencyKey) async {
|
||||
Future<WalletOperation> submit(
|
||||
String quoteId,
|
||||
String idempotencyKey, {
|
||||
String? approval,
|
||||
}) async {
|
||||
await _wait();
|
||||
if (_submissions.containsKey(idempotencyKey))
|
||||
return WalletMapper.operation(_submissions[idempotencyKey]!);
|
||||
final q = _quotes[quoteId];
|
||||
if (q == null) throw const WalletException(WalletError.expiredQuote);
|
||||
final verified = await _verified();
|
||||
try {
|
||||
await factors?.authorize(
|
||||
FactorPurpose.values.byName(q.request.kind),
|
||||
q.id,
|
||||
approval,
|
||||
);
|
||||
} catch (_) {
|
||||
final accepted = _submissions[idempotencyKey];
|
||||
if (accepted != null) return WalletMapper.operation(accepted);
|
||||
rethrow;
|
||||
}
|
||||
_settle();
|
||||
if (_submissions.containsKey(idempotencyKey))
|
||||
return WalletMapper.operation(_submissions[idempotencyKey]!);
|
||||
|
||||
@@ -8,6 +8,8 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
sxchnge_auth:
|
||||
path: ../sxchnge_auth
|
||||
sxchnge_analytics:
|
||||
path: ../sxchnge_analytics
|
||||
sxchnge_kyc:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
void main() {
|
||||
for (final kind in TransferKind.values) {
|
||||
test(
|
||||
'${kind.name} requires matching approval before funds change',
|
||||
() async {
|
||||
final factor = DummyTwoFactorService(
|
||||
enabled: true,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
final wallet = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
factors: factor,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
final q = await wallet.quote(
|
||||
TransferRequest(
|
||||
kind: kind,
|
||||
source: 'eur',
|
||||
amount: 1000,
|
||||
destination: kind == TransferKind.exchange ? 'usd' : null,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
wallet.submit(q.id, 'request'),
|
||||
throwsA(isA<FactorException>()),
|
||||
);
|
||||
expect((await wallet.fetch()).accounts.first.available, 7500);
|
||||
expect((await wallet.fetch()).operations, isEmpty);
|
||||
final c = (await factor.begin(
|
||||
FactorPurpose.values.byName(kind.name),
|
||||
q.id,
|
||||
))!;
|
||||
final g = await factor.verify(c.id, '123456');
|
||||
final result = await wallet.submit(q.id, 'request', approval: g.token);
|
||||
expect((await wallet.submit(q.id, 'request')).id, result.id);
|
||||
expect((await wallet.fetch()).operations.length, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
test('concurrent retries consume one approval and debit once', () async {
|
||||
final factor = DummyTwoFactorService(enabled: true, delay: Duration.zero);
|
||||
final wallet = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
factors: factor,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
final q = await wallet.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 1000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
final c = (await factor.begin(FactorPurpose.payout, q.id))!;
|
||||
final grant = await factor.verify(c.id, '123456');
|
||||
final results = await Future.wait([
|
||||
wallet.submit(q.id, 'same-request', approval: grant.token),
|
||||
wallet.submit(q.id, 'same-request', approval: grant.token),
|
||||
]);
|
||||
expect(results[0].id, results[1].id);
|
||||
final snapshot = await wallet.fetch();
|
||||
expect(snapshot.operations.length, 1);
|
||||
expect(snapshot.accounts.first.available, 6400);
|
||||
});
|
||||
test('proof for another quote cannot authorize payout', () async {
|
||||
final factor = DummyTwoFactorService(enabled: true, delay: Duration.zero);
|
||||
final wallet = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
factors: factor,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
final q = await wallet.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 1000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
final c = (await factor.begin(FactorPurpose.payout, 'different-quote'))!;
|
||||
final g = await factor.verify(c.id, '123456');
|
||||
await expectLater(
|
||||
wallet.submit(q.id, 'request', approval: g.token),
|
||||
throwsA(isA<FactorException>()),
|
||||
);
|
||||
expect((await wallet.fetch()).accounts.first.available, 7500);
|
||||
});
|
||||
test(
|
||||
'quote expired during factor verification does not move money',
|
||||
() async {
|
||||
var now = DateTime(2026, 9, 12);
|
||||
final factor = DummyTwoFactorService(
|
||||
enabled: true,
|
||||
delay: Duration.zero,
|
||||
now: () => now,
|
||||
);
|
||||
final wallet = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
factors: factor,
|
||||
delay: Duration.zero,
|
||||
now: () => now,
|
||||
);
|
||||
final q = await wallet.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.exchange,
|
||||
source: 'eur',
|
||||
destination: 'usd',
|
||||
amount: 1000,
|
||||
),
|
||||
);
|
||||
final c = (await factor.begin(FactorPurpose.exchange, q.id))!;
|
||||
final g = await factor.verify(c.id, '123456');
|
||||
now = now.add(const Duration(seconds: 61));
|
||||
await expectLater(
|
||||
wallet.submit(q.id, 'request', approval: g.token),
|
||||
throwsA(
|
||||
isA<WalletException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
WalletError.expiredQuote,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect((await wallet.fetch()).accounts.first.available, 7500);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user