Add shared two-factor verification to login and money operations #33

Merged
tech merged 2 commits from two-factor-32 into main 2026-09-12 10:45:51 +00:00
41 changed files with 2443 additions and 43 deletions

No files matched your search

+15 -1
View File
@@ -21,7 +21,7 @@ Sendico integration are not implemented.
| [`website/`](website/README.md) | Astro public website, assets, brand references and hosting configuration | | [`website/`](website/README.md) | Astro public website, assets, brand references and hosting configuration |
| [`frontend/app/`](frontend/app/README.md) | Flutter consumer app with a web runner | | [`frontend/app/`](frontend/app/README.md) | Flutter consumer app with a web runner |
| [`sxchnge_theme`](frontend/packages/sxchnge_theme/README.md) | Reusable light/dark themes and bundled Onest font | | [`sxchnge_theme`](frontend/packages/sxchnge_theme/README.md) | Reusable light/dark themes and bundled Onest font |
| [`sxchnge_auth`](frontend/packages/sxchnge_auth/README.md) | Shared login/signup/verification contracts, providers and dummy services | | [`sxchnge_auth`](frontend/packages/sxchnge_auth/README.md) | Shared auth contracts, providers, two-factor page and dummy services |
| [`sxchnge_analytics`](frontend/packages/sxchnge_analytics/README.md) | Typed journey events, recorder and bounded in-memory dummy analytics | | [`sxchnge_analytics`](frontend/packages/sxchnge_analytics/README.md) | Typed journey events, recorder and bounded in-memory dummy analytics |
| [`backend/`](backend/README.md) | Planned BFF and Sendico integration boundaries | | [`backend/`](backend/README.md) | Planned BFF and Sendico integration boundaries |
| [`.agents/skills/`](.agents/skills) | Repository agent skills | | [`.agents/skills/`](.agents/skills) | Repository agent skills |
@@ -210,3 +210,17 @@ by mobile clients. Transactions, rates, fees, recipients and profile storage are
explicitly simulated and reset on reload. No real money moves. See the explicitly simulated and reset on reload. No real money moves. See the
[workspace UX plan](docs/ux/exchange-workspace.md) and [workspace UX plan](docs/ux/exchange-workspace.md) and
[implementation record](docs/implementation.md) for scope and validation. [implementation record](docs/implementation.md) for scope and validation.
## Two-factor verification
Account **Settings → Two-factor verification** enables or disables protection
with a confirmed code. When enabled, login, top-ups, exchanges and payouts use a
shared SXCHNGE-themed challenge page. Money challenges retain the reviewed fees
and funds movement; cancelling preserves the transaction draft.
The initial service uses email-code fixtures: **123456** is the local test code.
No email is sent and settings reset on reload. Method-aware contracts and page
instructions also support authenticator challenges; actual TOTP enrollment,
validation and recovery remain backend integration work. See the
[two-factor UX contract](docs/ux/two-factor.md) and
[shared auth package](frontend/packages/sxchnge_auth/README.md).
+41
View File
@@ -321,3 +321,44 @@ above describe delivery-time state and are superseded by this integration record
Validation of the combined tree: all 115 frontend tests and app analysis passed; Validation of the combined tree: all 115 frontend tests and app analysis passed;
diff checks passed. Branches were retained. No checks were bypassed. diff checks passed. Branches were retained. No checks were bypassed.
## Reusable two-factor verification — task #32
Branch `two-factor-32` adds a SXCHNGE-designed, theme-based code page reused by
login, top-up, exchange, payout and verified profile enable/disable. EN/RU copy,
one accessible input, keyboard/paste support, resend cooldown, invalid/expired/
locked/unavailable recovery and cancellation preserve context. Transaction
verification retains its reviewed funds movement and fees. No Sendico UI or code
was copied for this feature.
Shared auth owns explicit DTOs, mappers, domain models, service contracts and
providers. Method-aware contracts distinguish email and authenticator challenges;
the initial email fixture uses code `123456`, sends no email, defaults off and
resets on reload. Real TOTP enrollment, method selection, delivery, recovery and
backend enforcement remain integration work. Mobile can reuse the page and
providers with its own router/localization and service adapters.
Login stays pending until approval is consumed. The dummy wallet consumes a
purpose/quote-bound, expiring, one-use approval before execution. Concurrent
idempotent retries debit once; expired quotes require fresh review; uncertain
outcomes retain reconciliation. Timers stop on route/app disposal and late
responses are ignored. Settings update only after service confirmation. Analytics
retains parent journey correlation and distinguishes verification from actual
login/settings/settlement outcomes; codes, grants and targets never enter events.
Frontend and UX review covered the complete task diff. Validation passed:
`dart format --output=none --set-exit-if-changed` for all 34 changed Dart files;
`flutter analyze` in app, sxchnge_auth, sxchnge_wallet and sxchnge_analytics;
`make test-frontend` (147 tests); `flutter build web` (including Wasm dry run);
`git diff --check`. Browser checks at 1280×720 and 390×844 verified themed layout,
invalid-code recovery, keyboard entry and confirmed settings. Widget tests cover
Russian at 320px/1.6× text scale and all money entry points. No security scan was
requested or run. Earlier validation failures were corrected, including ARB
metadata handling, disabled-policy latency, route timer disposal and lint findings.
See [the UX and measurement contract](ux/two-factor.md) and
[shared auth integration guide](../frontend/packages/sxchnge_auth/README.md).
Delivery: [PR #33](https://git.sendico.io/tech/sxchnge/pulls/33) targets `main`;
committed and pushed, unmerged. Issue #32 and PR #33 metadata verified: frontend
label, tech owner and SXCHNGE project. No stacked dependencies.
+68
View File
@@ -0,0 +1,68 @@
# Two-factor verification — task #32
Use a SXCHNGE-designed challenge page: themed security icon, clear purpose,
operation summary when moving money, one labeled six-digit input, Verify and
continue, contextual delivery recovery and Cancel. Avoid six separate focus
boxes, automatic submission, repeated banners and raw service error text.
Email is the initial service fixture. Method is carried in settings and challenge
DTO/model contracts. Authenticator/TOTP challenges use their own instruction and
have no email target or resend controls. The reusable shared page accepts labels
and an optional summary, so clients supply their own localization and context.
Authenticator enrollment/secret provisioning is a future backend capability;
it is not offered as a working setting without that contract. No Sendico UI or
code is copied. Its pending-login concept informed the separation of steps.
Profile shows current protection status and verified enable/disable actions.
Cancel changes nothing. No optimistic protection toggle: the service result
must confirm the new setting. The existing stub has one account and resets on
reload. Production settings must be account-scoped and authenticated.
Login first validates credentials. A pending result is not a session or login
success; complete it only after consuming a matching approval. Clear the password
before opening the challenge. Cancel returns to login without successful state.
All top-ups, exchanges and payouts request a factor when service policy requires
it. The challenge binds to the quote ID; its one-use grant is checked by the
operation adapter before money moves. Editing, expired quotes, mismatched grants
and reuse cannot authorize a different transaction. Existing idempotency and
unknown-outcome reconciliation remain authoritative. Cancel returns to the review
with the draft preserved. Expired quotes require fresh review and confirmation.
Invalid code preserves context; retry stays in the same challenge. Email resends
have service-owned cooldowns and replace the previous challenge. Expired code,
rate limiting and unavailable service have localized recovery. Verification and
resend disable duplicate actions, clean up timers/controllers on disposal, and
ignore late results. Pasted codes, keyboard submission, live errors and EN/RU
mobile layouts must work.
Measurement extends existing typed journeys with factor delivery/verification and
settings outcome steps. Login completion waits for the final login result;
transaction completion still waits for settlement. Cancelled challenges do not
claim success. No code, email target, challenge ID, grant, secret, amount or
recipient enters analytics or route URLs. Events retain the simulated flag.
Actual MFA enforcement, OTP delivery/validation, enrollment and recovery must run
on the backend before production use.
## Measurement and review evidence
- The existing login/transaction attempt owns verification and retries. A factor
success is an intermediate step; only completed login, confirmed settings save
or transaction settlement can complete its parent journey.
- `factor_challenge` measures settings/transaction policy checks and challenge
creation failures. `factor_delivery` measures email resends;
`factor_verification` measures code checks and explicit cancellation;
`factor_settings` measures the confirmed setting update. Login's existing
credential step includes initial challenge creation and its failures.
- Product/support can compare failure and resend rates by typed step, and measure
completion/duration per distinct parent journey. Retries remain in that journey;
its existing timeout/explicit-navigation rules close abandoned attempts. Pending
money operations continue through the existing settlement recorder.
- Browser review: desktop 1280×720 and mobile 390×844; theme roles, heading, input,
buttons, invalid-code recovery, keyboard submission and confirmed setting status.
Widget tests additionally cover Russian at 320px and 1.6× text scale, all money
entry points, cancellation, service errors and missing route context.
- Analytics tests check correlation, one terminal login outcome, cancellation,
challenge creation failure and absence of codes in event payloads. Stub policy
remains simulated even if a credentials adapter returns a non-simulated result.
Backend/cross-device outcomes remain unavailable until services are integrated.
+3
View File
@@ -23,6 +23,9 @@ class AuthFeedback extends StatelessWidget {
AuthError.emailInUse => l.authEmailInUse, AuthError.emailInUse => l.authEmailInUse,
AuthError.unavailable => l.authUnavailable, AuthError.unavailable => l.authUnavailable,
AuthError.unexpected => l.authUnexpected, AuthError.unexpected => l.authUnexpected,
AuthError.factorLocked => l.factorLocked,
AuthError.factorCooldown => l.factorWait,
AuthError.factorRequired => l.factorRequired,
}; };
return Semantics( return Semantics(
liveRegion: true, liveRegion: true,
+25 -1
View File
@@ -9,6 +9,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../routing/routes.dart'; import '../routing/routes.dart';
import '../security/challenge.dart';
import 'feedback.dart'; import 'feedback.dart';
import 'validation.dart'; import 'validation.dart';
@@ -96,11 +97,34 @@ class _AuthFormState extends State<AuthForm> {
_confirmation.clear(); _confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email); context.goNamed(AppRoutes.verificationSent, extra: email);
} else { } else {
await context.read<LoginProvider>().login( final login = context.read<LoginProvider>();
await login.login(
email: _email.text, email: _email.text,
password: _password.text, password: _password.text,
locale: locale, locale: locale,
); );
if (!mounted || _signUp) return;
final challenge = login.state.result?.challenge;
if (challenge != null) {
_password.clear();
final grant = await verifyFactor(
context,
challenge: challenge,
service: context.read<TwoFactorService>(),
recorder: context.read<JourneyRecorder>(),
journey: JourneyKind.login,
);
if (!mounted || _signUp) return;
if (grant == null) {
login.reset();
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
return;
}
await login.complete(challenge, grant);
if (mounted && login.state.status == AuthStatus.success) {
context.go('/accounts');
}
}
} }
} }
@@ -1465,6 +1465,174 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Make another transaction'** /// **'Make another transaction'**
String get wAnother; String get wAnother;
/// No description provided for @factorTitle.
///
/// In en, this message translates to:
/// **'One more check.'**
String get factorTitle;
/// No description provided for @factorLogin.
///
/// In en, this message translates to:
/// **'Confirm it’s you to finish signing in.'**
String get factorLogin;
/// No description provided for @factorMoney.
///
/// In en, this message translates to:
/// **'Confirm this transaction. No money moves before confirmation.'**
String get factorMoney;
/// No description provided for @factorEnableHelp.
///
/// In en, this message translates to:
/// **'Confirm to turn on two-factor verification for sign-in and money operations.'**
String get factorEnableHelp;
/// No description provided for @factorDisableHelp.
///
/// In en, this message translates to:
/// **'Confirm to turn off two-factor verification. Sign-in and money operations will no longer ask for a second code.'**
String get factorDisableHelp;
/// No description provided for @factorEmail.
///
/// In en, this message translates to:
/// **'Enter the six-digit code sent to your email.'**
String get factorEmail;
/// No description provided for @factorAuthenticator.
///
/// In en, this message translates to:
/// **'Enter the current six-digit code from your authenticator app.'**
String get factorAuthenticator;
/// No description provided for @factorCode.
///
/// In en, this message translates to:
/// **'Verification code'**
String get factorCode;
/// No description provided for @factorConfirm.
///
/// In en, this message translates to:
/// **'Verify and continue'**
String get factorConfirm;
/// No description provided for @factorResend.
///
/// In en, this message translates to:
/// **'Send a new code'**
String get factorResend;
/// No description provided for @factorHelp.
///
/// In en, this message translates to:
/// **'Check your inbox and spam folder. A new code replaces the previous one.'**
String get factorHelp;
/// No description provided for @factorAppHelp.
///
/// In en, this message translates to:
/// **'Use the current code. If you cannot access your authenticator, cancel this action.'**
String get factorAppHelp;
/// No description provided for @factorInvalid.
///
/// In en, this message translates to:
/// **'That code is not valid. Check all six digits and try again.'**
String get factorInvalid;
/// No description provided for @factorExpired.
///
/// In en, this message translates to:
/// **'This verification has expired. Request a new code or return to start again.'**
String get factorExpired;
/// No description provided for @factorLocked.
///
/// In en, this message translates to:
/// **'Too many incorrect attempts. Wait five minutes, then start verification again.'**
String get factorLocked;
/// No description provided for @factorWait.
///
/// In en, this message translates to:
/// **'Please wait before requesting another code.'**
String get factorWait;
/// No description provided for @factorRequired.
///
/// In en, this message translates to:
/// **'Verification is needed again. Please confirm this action once more.'**
String get factorRequired;
/// No description provided for @factorUnavailable.
///
/// In en, this message translates to:
/// **'Could not complete verification. Try again or cancel this action.'**
String get factorUnavailable;
/// No description provided for @factorCooldown.
///
/// In en, this message translates to:
/// **'Send a new code in {seconds}s'**
String factorCooldown(int seconds);
/// No description provided for @factorSettings.
///
/// In en, this message translates to:
/// **'Two-factor verification'**
String get factorSettings;
/// No description provided for @factorSettingsHelp.
///
/// In en, this message translates to:
/// **'Add a verification code when you sign in, top up, exchange or pay out.'**
String get factorSettingsHelp;
/// No description provided for @factorOn.
///
/// In en, this message translates to:
/// **'On'**
String get factorOn;
/// No description provided for @factorOff.
///
/// In en, this message translates to:
/// **'Off'**
String get factorOff;
/// No description provided for @factorEnable.
///
/// In en, this message translates to:
/// **'Turn on'**
String get factorEnable;
/// No description provided for @factorDisable.
///
/// In en, this message translates to:
/// **'Turn off'**
String get factorDisable;
/// No description provided for @factorEmailMethod.
///
/// In en, this message translates to:
/// **'Email code'**
String get factorEmailMethod;
/// No description provided for @factorAppMethod.
///
/// In en, this message translates to:
/// **'Authenticator app'**
String get factorAppMethod;
/// No description provided for @factorAppExpired.
///
/// In en, this message translates to:
/// **'This verification has expired. Return and start again.'**
String get factorAppExpired;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
@@ -758,4 +758,103 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wAnother => 'Make another transaction'; String get wAnother => 'Make another transaction';
@override
String get factorTitle => 'One more check.';
@override
String get factorLogin => 'Confirm it’s you to finish signing in.';
@override
String get factorMoney =>
'Confirm this transaction. No money moves before confirmation.';
@override
String get factorEnableHelp =>
'Confirm to turn on two-factor verification for sign-in and money operations.';
@override
String get factorDisableHelp =>
'Confirm to turn off two-factor verification. Sign-in and money operations will no longer ask for a second code.';
@override
String get factorEmail => 'Enter the six-digit code sent to your email.';
@override
String get factorAuthenticator =>
'Enter the current six-digit code from your authenticator app.';
@override
String get factorCode => 'Verification code';
@override
String get factorConfirm => 'Verify and continue';
@override
String get factorResend => 'Send a new code';
@override
String get factorHelp =>
'Check your inbox and spam folder. A new code replaces the previous one.';
@override
String get factorAppHelp =>
'Use the current code. If you cannot access your authenticator, cancel this action.';
@override
String get factorInvalid =>
'That code is not valid. Check all six digits and try again.';
@override
String get factorExpired =>
'This verification has expired. Request a new code or return to start again.';
@override
String get factorLocked =>
'Too many incorrect attempts. Wait five minutes, then start verification again.';
@override
String get factorWait => 'Please wait before requesting another code.';
@override
String get factorRequired =>
'Verification is needed again. Please confirm this action once more.';
@override
String get factorUnavailable =>
'Could not complete verification. Try again or cancel this action.';
@override
String factorCooldown(int seconds) {
return 'Send a new code in ${seconds}s';
}
@override
String get factorSettings => 'Two-factor verification';
@override
String get factorSettingsHelp =>
'Add a verification code when you sign in, top up, exchange or pay out.';
@override
String get factorOn => 'On';
@override
String get factorOff => 'Off';
@override
String get factorEnable => 'Turn on';
@override
String get factorDisable => 'Turn off';
@override
String get factorEmailMethod => 'Email code';
@override
String get factorAppMethod => 'Authenticator app';
@override
String get factorAppExpired =>
'This verification has expired. Return and start again.';
} }
@@ -755,4 +755,103 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get wAnother => 'Создать новую операцию'; String get wAnother => 'Создать новую операцию';
@override
String get factorTitle => 'Ещё одна проверка.';
@override
String get factorLogin => 'Подтвердите, что это вы, чтобы завершить вход.';
@override
String get factorMoney =>
'Подтвердите эту операцию. До подтверждения деньги не переводятся.';
@override
String get factorEnableHelp =>
'Подтвердите включение двухфакторной проверки для входа и операций с деньгами.';
@override
String get factorDisableHelp =>
'Подтвердите отключение двухфакторной проверки. Для входа и операций больше не потребуется второй код.';
@override
String get factorEmail => 'Введите шестизначный код из письма.';
@override
String get factorAuthenticator =>
'Введите текущий шестизначный код из приложения-аутентификатора.';
@override
String get factorCode => 'Код подтверждения';
@override
String get factorConfirm => 'Подтвердить и продолжить';
@override
String get factorResend => 'Отправить новый код';
@override
String get factorHelp =>
'Проверьте входящие и папку «Спам». Новый код заменяет предыдущий.';
@override
String get factorAppHelp =>
'Используйте текущий код. Если приложение недоступно, отмените это действие.';
@override
String get factorInvalid =>
'Неверный код. Проверьте все шесть цифр и попробуйте ещё раз.';
@override
String get factorExpired =>
'Срок проверки истёк. Запросите новый код или вернитесь и начните снова.';
@override
String get factorLocked =>
'Слишком много неверных попыток. Подождите пять минут и начните проверку снова.';
@override
String get factorWait => 'Подождите перед повторным запросом кода.';
@override
String get factorRequired =>
'Нужна повторная проверка. Подтвердите это действие ещё раз.';
@override
String get factorUnavailable =>
'Не удалось завершить проверку. Повторите попытку или отмените действие.';
@override
String factorCooldown(int seconds) {
return 'Новый код через $seconds с';
}
@override
String get factorSettings => 'Двухфакторная проверка';
@override
String get factorSettingsHelp =>
'Подтверждайте кодом вход, пополнение, обмен и вывод средств.';
@override
String get factorOn => 'Включена';
@override
String get factorOff => 'Выключена';
@override
String get factorEnable => 'Включить';
@override
String get factorDisable => 'Выключить';
@override
String get factorEmailMethod => 'Код из письма';
@override
String get factorAppMethod => 'Приложение-аутентификатор';
@override
String get factorAppExpired =>
'Срок проверки истёк. Вернитесь и начните снова.';
} }
+36 -1
View File
@@ -227,5 +227,40 @@
"wLimitsScope": "Outgoing activity · EUR equivalent", "wLimitsScope": "Outgoing activity · EUR equivalent",
"wPendingHint": "Refresh to check the latest status. You can keep using your other accounts.", "wPendingHint": "Refresh to check the latest status. You can keep using your other accounts.",
"wFundsHeading": "Where is my money?", "wFundsHeading": "Where is my money?",
"wAnother": "Make another transaction" "wAnother": "Make another transaction",
"factorTitle": "One more check.",
"factorLogin": "Confirm it’s you to finish signing in.",
"factorMoney": "Confirm this transaction. No money moves before confirmation.",
"factorEnableHelp": "Confirm to turn on two-factor verification for sign-in and money operations.",
"factorDisableHelp": "Confirm to turn off two-factor verification. Sign-in and money operations will no longer ask for a second code.",
"factorEmail": "Enter the six-digit code sent to your email.",
"factorAuthenticator": "Enter the current six-digit code from your authenticator app.",
"factorCode": "Verification code",
"factorConfirm": "Verify and continue",
"factorResend": "Send a new code",
"factorHelp": "Check your inbox and spam folder. A new code replaces the previous one.",
"factorAppHelp": "Use the current code. If you cannot access your authenticator, cancel this action.",
"factorInvalid": "That code is not valid. Check all six digits and try again.",
"factorExpired": "This verification has expired. Request a new code or return to start again.",
"factorLocked": "Too many incorrect attempts. Wait five minutes, then start verification again.",
"factorWait": "Please wait before requesting another code.",
"factorRequired": "Verification is needed again. Please confirm this action once more.",
"factorUnavailable": "Could not complete verification. Try again or cancel this action.",
"factorCooldown": "Send a new code in {seconds}s",
"factorSettings": "Two-factor verification",
"factorSettingsHelp": "Add a verification code when you sign in, top up, exchange or pay out.",
"factorOn": "On",
"factorOff": "Off",
"factorEnable": "Turn on",
"factorDisable": "Turn off",
"@factorCooldown": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"factorEmailMethod": "Email code",
"factorAppMethod": "Authenticator app",
"factorAppExpired": "This verification has expired. Return and start again."
} }
+36 -1
View File
@@ -227,5 +227,40 @@
"wLimitsScope": "Исходящие операции · эквивалент в EUR", "wLimitsScope": "Исходящие операции · эквивалент в EUR",
"wPendingHint": "Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.", "wPendingHint": "Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.",
"wFundsHeading": "Где мои деньги?", "wFundsHeading": "Где мои деньги?",
"wAnother": "Создать новую операцию" "wAnother": "Создать новую операцию",
"factorTitle": "Ещё одна проверка.",
"factorLogin": "Подтвердите, что это вы, чтобы завершить вход.",
"factorMoney": "Подтвердите эту операцию. До подтверждения деньги не переводятся.",
"factorEnableHelp": "Подтвердите включение двухфакторной проверки для входа и операций с деньгами.",
"factorDisableHelp": "Подтвердите отключение двухфакторной проверки. Для входа и операций больше не потребуется второй код.",
"factorEmail": "Введите шестизначный код из письма.",
"factorAuthenticator": "Введите текущий шестизначный код из приложения-аутентификатора.",
"factorCode": "Код подтверждения",
"factorConfirm": "Подтвердить и продолжить",
"factorResend": "Отправить новый код",
"factorHelp": "Проверьте входящие и папку «Спам». Новый код заменяет предыдущий.",
"factorAppHelp": "Используйте текущий код. Если приложение недоступно, отмените это действие.",
"factorInvalid": "Неверный код. Проверьте все шесть цифр и попробуйте ещё раз.",
"factorExpired": "Срок проверки истёк. Запросите новый код или вернитесь и начните снова.",
"factorLocked": "Слишком много неверных попыток. Подождите пять минут и начните проверку снова.",
"factorWait": "Подождите перед повторным запросом кода.",
"factorRequired": "Нужна повторная проверка. Подтвердите это действие ещё раз.",
"factorUnavailable": "Не удалось завершить проверку. Повторите попытку или отмените действие.",
"factorCooldown": "Новый код через {seconds} с",
"factorSettings": "Двухфакторная проверка",
"factorSettingsHelp": "Подтверждайте кодом вход, пополнение, обмен и вывод средств.",
"factorOn": "Включена",
"factorOff": "Выключена",
"factorEnable": "Включить",
"factorDisable": "Выключить",
"@factorCooldown": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"factorEmailMethod": "Код из письма",
"factorAppMethod": "Приложение-аутентификатор",
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова."
} }
+19 -4
View File
@@ -31,6 +31,7 @@ class SxchngeApp extends StatefulWidget {
this.identityService, this.identityService,
this.limitsService, this.limitsService,
this.walletService, this.walletService,
this.twoFactorService,
this.photoService = const FilePhotoService(), this.photoService = const FilePhotoService(),
this.loginService = const DummyLoginService(), this.loginService = const DummyLoginService(),
this.signupService = const DummySignupService(), this.signupService = const DummySignupService(),
@@ -41,6 +42,7 @@ class SxchngeApp extends StatefulWidget {
final IdentityService? identityService; final IdentityService? identityService;
final LimitsService? limitsService; final LimitsService? limitsService;
final WalletService? walletService; final WalletService? walletService;
final TwoFactorService? twoFactorService;
final PhotoService photoService; final PhotoService photoService;
final LoginService loginService; final LoginService loginService;
final SignupService signupService; final SignupService signupService;
@@ -54,11 +56,21 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider(); late final _locale = widget.localeProvider ?? LocaleProvider();
late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
late final _factors = widget.twoFactorService ?? DummyTwoFactorService();
late final _security = SecurityProvider(_factors, _analytics);
late final _identity = widget.identityService ?? DummyIdentityService(); late final _identity = widget.identityService ?? DummyIdentityService();
late final _walletService = late final _walletService =
widget.walletService ?? widget.walletService ??
DummyWalletService(_identity, initialLocale: _locale.locale.languageCode); DummyWalletService(
late final _wallet = WalletProvider(_walletService, _analytics); _identity,
initialLocale: _locale.locale.languageCode,
factors: _factors,
);
late final _wallet = WalletProvider(
_walletService,
_analytics,
factors: _factors,
);
late final _limits = late final _limits =
widget.limitsService ?? widget.limitsService ??
(_walletService is DummyWalletService (_walletService is DummyWalletService
@@ -67,7 +79,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _journeys = JourneyRecorder(_analytics); late final _journeys = JourneyRecorder(_analytics);
late final _services = InstrumentedAuthServices( late final _services = InstrumentedAuthServices(
recorder: _journeys, recorder: _journeys,
loginService: widget.loginService, loginService: GuardedLoginService(widget.loginService, _factors),
signupService: widget.signupService, signupService: widget.signupService,
verificationService: widget.verificationService, verificationService: widget.verificationService,
); );
@@ -83,7 +95,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
void _routeChanged() { void _routeChanged() {
final path = _router.routeInformationProvider.value.uri.path; final path = _router.routeInformationProvider.value.uri.path;
if (path == '/' && _previousPath != '/') { if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
_journeys.abandon(JourneyExit.navigation); _journeys.abandon(JourneyExit.navigation);
} }
_previousPath = path; _previousPath = path;
@@ -95,6 +107,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
_router.dispose(); _router.dispose();
_journeys.dispose(); _journeys.dispose();
_wallet.dispose(); _wallet.dispose();
_security.dispose();
_locale.dispose(); _locale.dispose();
super.dispose(); super.dispose();
} }
@@ -103,6 +116,8 @@ class _SxchngeAppState extends State<SxchngeApp> {
Widget build(BuildContext context) => MultiProvider( Widget build(BuildContext context) => MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value(value: _wallet), ChangeNotifierProvider.value(value: _wallet),
ChangeNotifierProvider.value(value: _security),
Provider<TwoFactorService>.value(value: _factors),
Provider<JourneyRecorder>.value(value: _journeys), Provider<JourneyRecorder>.value(value: _journeys),
Provider<AnalyticsService>.value(value: _analytics), Provider<AnalyticsService>.value(value: _analytics),
Provider<IdentityService>.value(value: _identity), Provider<IdentityService>.value(value: _identity),
+11
View File
@@ -8,6 +8,7 @@ import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../auth/page.dart'; import '../auth/page.dart';
import '../kyc/page.dart'; import '../kyc/page.dart';
import '../security/challenge.dart';
import '../verification/page.dart'; import '../verification/page.dart';
import '../workspace/accounts.dart'; import '../workspace/accounts.dart';
import '../workspace/activity.dart'; import '../workspace/activity.dart';
@@ -27,6 +28,16 @@ GoRouter createAppRouter() {
// Signup email is an in-memory navigation extra, never browser-history data. // Signup email is an in-memory navigation extra, never browser-history data.
extraCodec: const _TransientExtras(), extraCodec: const _TransientExtras(),
routes: [ routes: [
GoRoute(
path: '/two-factor',
redirect: (context, state) =>
state.extra is FactorRouteRequest ? null : '/',
builder: (context, state) =>
FactorRoute(request: state.extra as FactorRouteRequest),
onExit: (context, state) =>
state.extra is! FactorRouteRequest ||
!(state.extra as FactorRouteRequest).provider.busy,
),
ShellRoute( ShellRoute(
builder: (context, state, child) => builder: (context, state, child) =>
WorkspaceShell(path: state.uri.path, child: child), WorkspaceShell(path: state.uri.path, child: child),
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../generated/i18n/app_localizations.dart';
class FactorRouteRequest {
const FactorRouteRequest(this.provider, this.summary);
final TwoFactorProvider provider;
final Widget? summary;
}
Future<FactorGrant?> verifyFactor(
BuildContext context, {
required FactorChallenge challenge,
required TwoFactorService service,
required JourneyRecorder recorder,
required JourneyKind journey,
Widget? summary,
}) async {
final provider = TwoFactorProvider(
service,
challenge,
recorder: recorder,
journey: journey,
);
FactorGrant? result;
try {
result = await context.push<FactorGrant>(
'/two-factor',
extra: FactorRouteRequest(provider, summary),
);
return result;
} finally {
if (result == null) {
final step = recorder.begin(
journey,
JourneyStep.factorVerification,
simulated: service.isSimulated,
);
recorder.finish(step, cancelled: true, simulated: service.isSimulated);
}
provider.dispose();
}
}
String factorErrorText(AppLocalizations l, FactorError error) =>
switch (error) {
FactorError.invalid => l.factorInvalid,
FactorError.expired => l.factorExpired,
FactorError.locked => l.factorLocked,
FactorError.cooldown => l.factorWait,
FactorError.required => l.factorRequired,
FactorError.unavailable => l.factorUnavailable,
};
class FactorRoute extends StatefulWidget {
const FactorRoute({super.key, required this.request});
final FactorRouteRequest request;
@override
State<FactorRoute> createState() => _FactorRouteState();
}
class _FactorRouteState extends State<FactorRoute> {
FactorRouteRequest get request => widget.request;
@override
void dispose() {
request.provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final purpose = request.provider.challenge.purpose;
final purposeText = switch (purpose) {
FactorPurpose.login => l.factorLogin,
FactorPurpose.enable => l.factorEnableHelp,
FactorPurpose.disable => l.factorDisableHelp,
_ => l.factorMoney,
};
final methodText = request.provider.challenge.method == FactorMethod.email
? l.factorEmail
: l.factorAuthenticator;
return TwoFactorPage(
provider: request.provider,
summary: request.summary,
labels: TwoFactorLabels(
title: l.factorTitle,
description: '$purposeText $methodText',
code: l.factorCode,
confirm: l.factorConfirm,
cancel: l.wClose,
resend: l.factorResend,
expired: request.provider.challenge.method == FactorMethod.email
? l.factorExpired
: l.factorAppExpired,
help: request.provider.challenge.method == FactorMethod.email
? l.factorHelp
: l.factorAppHelp,
errorText: (error) =>
error == FactorError.expired &&
request.provider.challenge.method == FactorMethod.authenticator
? l.factorAppExpired
: factorErrorText(l, error),
cooldownText: (seconds) => l.factorCooldown(seconds),
),
onVerified: (grant) => context.pop(grant),
onCancel: () => context.pop(),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'challenge.dart';
import '../generated/i18n/app_localizations.dart';
class SecuritySettings extends StatefulWidget {
const SecuritySettings({super.key});
@override
State<SecuritySettings> createState() => _SecuritySettingsState();
}
class _SecuritySettingsState extends State<SecuritySettings> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<SecurityProvider>().refresh();
});
}
Future<void> _change(SecurityProvider p, bool enabled) async {
final challenge = await p.prepare(enabled);
if (!mounted || challenge == null) return;
final grant = await verifyFactor(
context,
challenge: challenge,
service: p.service,
recorder: p.recorder,
journey: JourneyKind.security,
);
if (mounted && grant != null) await p.apply(enabled, grant);
}
@override
Widget build(BuildContext context) {
final p = context.watch<SecurityProvider>();
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.factorSettings, style: t.textTheme.titleLarge),
const SizedBox(height: 12),
Text(l.factorSettingsHelp),
const SizedBox(height: 16),
if (p.busy) const LinearProgressIndicator(),
if (p.data != null) ...[
Semantics(
liveRegion: true,
child: Text(
p.data!.enabled ? l.factorOn : l.factorOff,
key: const ValueKey('two-factor-status'),
style: t.textTheme.titleMedium,
),
),
const SizedBox(height: 8),
Text(
p.data!.method == FactorMethod.email
? l.factorEmailMethod
: l.factorAppMethod,
),
const SizedBox(height: 16),
FilledButton.tonal(
key: const ValueKey('two-factor-setting'),
onPressed: p.busy ? null : () => _change(p, !p.data!.enabled),
child: Text(p.data!.enabled ? l.factorDisable : l.factorEnable),
),
],
if (p.error != null) ...[
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
factorErrorText(l, p.error!),
style: TextStyle(color: t.colorScheme.error),
),
),
TextButton(
onPressed: p.busy ? null : p.refresh,
child: Text(l.wRefresh),
),
],
],
),
),
);
}
}
+2 -13
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../providers/locale.dart'; import '../providers/locale.dart';
import '../security/settings.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
@@ -105,19 +106,7 @@ class SettingsPage extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Card( const SecuritySettings(),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.wSecurity, style: t.textTheme.titleLarge),
const SizedBox(height: 12),
Text(l.wSecurityBody),
],
),
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
TextButton( TextButton(
onPressed: () => context.go('/'), onPressed: () => context.go('/'),
+35 -1
View File
@@ -6,8 +6,12 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../security/challenge.dart';
import 'copy.dart'; import 'copy.dart';
import 'summary.dart'; import 'summary.dart';
@@ -64,6 +68,25 @@ class TransferPageState extends State<TransferPage> {
} }
} }
Future<void> _confirm() async {
final p = _provider;
final challenge = await p.challenge();
if (!mounted || p.factorError != null || p.error != null) return;
FactorGrant? grant;
if (challenge != null) {
grant = await verifyFactor(
context,
challenge: challenge,
service: p.wallet.factors!,
recorder: p.recorder,
journey: p.journey,
summary: TransferSummary(quote: p.quote!),
);
if (!mounted || grant == null) return;
}
await p.submit(approval: grant);
}
Future<bool> allowExit() async { Future<bool> allowExit() async {
if (_provider.result != null) return true; if (_provider.result != null) return true;
if (_provider.busy || _provider.uncertain) return false; if (_provider.busy || _provider.uncertain) return false;
@@ -163,6 +186,17 @@ class TransferPageState extends State<TransferPage> {
), ),
), ),
), ),
if (p.factorError != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Semantics(
liveRegion: true,
child: Text(
factorErrorText(l, p.factorError!),
style: TextStyle(color: t.colorScheme.error),
),
),
),
if (p.noSubmission) Text(l.wNoSubmission), if (p.noSubmission) Text(l.wNoSubmission),
if (p.uncertain) ...[ if (p.uncertain) ...[
Text(l.wUncertain), Text(l.wUncertain),
@@ -183,7 +217,7 @@ class TransferPageState extends State<TransferPage> {
else else
FilledButton( FilledButton(
key: const ValueKey('transfer-confirm'), key: const ValueKey('transfer-confirm'),
onPressed: p.busy ? null : p.submit, onPressed: p.busy ? null : _confirm,
child: Text(l.wConfirm), child: Text(l.wConfirm),
), ),
TextButton( TextButton(
+7 -1
View File
@@ -60,7 +60,13 @@ void main() {
final ru = jsonDecode(File('lib/l10n/ru.arb').readAsStringSync()) as Map; final ru = jsonDecode(File('lib/l10n/ru.arb').readAsStringSync()) as Map;
expect(ru.keys.toSet(), en.keys.toSet()); expect(ru.keys.toSet(), en.keys.toSet());
expect( expect(
ru.values.every((value) => value is String && value.trim().isNotEmpty), ru.entries
.where((entry) => !entry.key.toString().startsWith('@'))
.every(
(entry) =>
entry.value is String &&
(entry.value as String).trim().isNotEmpty,
),
isTrue, isTrue,
); );
}); });
+243
View File
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
Future<void> tapFactor(WidgetTester t, String key) async {
final finder = find.byKey(ValueKey(key));
await t.ensureVisible(finder);
await t.tap(finder);
await t.pumpAndSettle();
}
Future<DummyTwoFactorService> openFactor(
WidgetTester t, {
String path = '/settings',
bool enabled = false,
String locale = 'en',
FactorMethod method = FactorMethod.email,
DummyAnalyticsService? analytics,
}) async {
t.platformDispatcher.defaultRouteNameTestValue = path;
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final service = DummyTwoFactorService(
enabled: enabled,
delay: Duration.zero,
method: method,
);
await t.pumpWidget(
SxchngeApp(
twoFactorService: service,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
),
);
await t.pumpAndSettle();
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
return service;
}
Future<void> signIn(WidgetTester t) async {
await t.enterText(find.byKey(const ValueKey('email')), 'person@example.com');
await t.enterText(find.byKey(const ValueKey('password')), 'example-password');
await tapFactor(t, 'submit');
}
void main() {
testWidgets('settings enable and disable only after verified code', (
t,
) async {
final service = await openFactor(t);
expect(find.text('Off'), findsOneWidget);
await tapFactor(t, 'two-factor-setting');
expect((await t.runAsync(service.fetch))!.enabled, false);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
expect((await t.runAsync(service.fetch))!.enabled, false);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
await tapFactor(t, 'two-factor-confirm');
expect(find.text('On'), findsOneWidget);
expect((await t.runAsync(service.fetch))!.enabled, true);
await tapFactor(t, 'two-factor-setting');
expect(find.textContaining('turn off two-factor'), findsOneWidget);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
await tapFactor(t, 'two-factor-confirm');
expect(find.text('Off'), findsOneWidget);
expect((await t.runAsync(service.fetch))!.enabled, false);
});
for (final entry in {
FactorError.expired: 'This verification has expired.',
FactorError.locked: 'Too many incorrect attempts.',
FactorError.unavailable: 'Could not complete verification.',
}.entries) {
testWidgets(
'${entry.key.name} is recoverable without enabling protection',
(t) async {
final service = await openFactor(t);
await tapFactor(t, 'two-factor-setting');
service.nextError = entry.key;
await t.enterText(
find.byKey(const ValueKey('two-factor-code')),
'123456',
);
await tapFactor(t, 'two-factor-confirm');
expect(find.textContaining(entry.value), findsOneWidget);
await tapFactor(t, 'two-factor-cancel');
expect(find.text('Off'), findsOneWidget);
expect((await t.runAsync(service.fetch))!.enabled, false);
},
);
}
testWidgets('challenge creation failure is measured and can retry', (
t,
) async {
final analytics = DummyAnalyticsService();
final service = await openFactor(t, analytics: analytics);
service.nextError = FactorError.unavailable;
await tapFactor(t, 'two-factor-setting');
expect(
find.textContaining('Could not complete verification.'),
findsOneWidget,
);
expect(
analytics.events.any(
(e) =>
e.step == JourneyStep.factorChallenge &&
e.failure == JourneyFailure.unavailable,
),
true,
);
expect(analytics.events.where((e) => e.terminal), isEmpty);
await tapFactor(t, 'two-factor-setting');
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
await tapFactor(t, 'two-factor-cancel');
});
testWidgets('cancel enrollment leaves setting unchanged', (t) async {
final service = await openFactor(t);
await tapFactor(t, 'two-factor-setting');
await tapFactor(t, 'two-factor-cancel');
expect(find.text('Off'), findsOneWidget);
expect((await t.runAsync(service.fetch))!.enabled, false);
});
testWidgets('login is not terminal until factor and completion succeed', (
t,
) async {
final analytics = DummyAnalyticsService();
await openFactor(t, path: '/', enabled: true, analytics: analytics);
await signIn(t);
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
isEmpty,
);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
final login = analytics.events
.where((e) => e.kind == JourneyKind.login)
.toList();
expect(login.where((e) => e.terminal).length, 1);
expect(login.map((e) => e.journeyId).toSet().length, 1);
expect(
login.map((e) => e.properties).toString(),
isNot(contains('123456')),
);
});
testWidgets('cancel login factor returns without a successful session', (
t,
) async {
await openFactor(t, path: '/', enabled: true);
await signIn(t);
await tapFactor(t, 'two-factor-cancel');
expect(find.byKey(const ValueKey('email')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('password')))
.controller!
.text,
isEmpty,
);
});
for (final kind in ['top-up', 'exchange', 'payout']) {
testWidgets(
'$kind confirms specific transaction and cancel preserves review',
(t) async {
await openFactor(t, path: '/$kind', enabled: true);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
if (kind == 'payout') {
await t.enterText(
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
}
await tapFactor(t, 'transfer-review');
await tapFactor(t, 'transfer-confirm');
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
expect(find.textContaining('No money moves'), findsOneWidget);
expect(find.text('Fee'), findsOneWidget);
await tapFactor(t, 'two-factor-cancel');
expect(find.byKey(const ValueKey('transfer-confirm')), findsOneWidget);
expect(find.byKey(const ValueKey('operation-pending')), findsNothing);
},
);
}
testWidgets(
'money moves after verification and confirmation, without another submit',
(t) async {
await openFactor(t, path: '/exchange', enabled: true);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
await tapFactor(t, 'transfer-review');
await tapFactor(t, 'transfer-confirm');
await t.enterText(
find.byKey(const ValueKey('two-factor-code')),
'123456',
);
await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('operation-pending')), findsOneWidget);
expect(find.textContaining('amount and fee have left'), findsOneWidget);
},
);
testWidgets('authenticator uses SXCHNGE page without email or resend UI', (
t,
) async {
await openFactor(
t,
path: '/',
enabled: true,
method: FactorMethod.authenticator,
);
await signIn(t);
expect(find.textContaining('authenticator app'), findsOneWidget);
expect(find.byKey(const ValueKey('two-factor-resend')), findsNothing);
expect(find.text('a•••@example.com'), findsNothing);
});
testWidgets('Russian factor screen fits small viewport and enlarged text', (
t,
) async {
t.view.physicalSize = const Size(320, 1100);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
await openFactor(t, locale: 'ru');
await tapFactor(t, 'two-factor-setting');
expect(find.text('Ещё одна проверка.'), findsOneWidget);
expect(t.takeException(), null);
await tapFactor(t, 'two-factor-cancel');
});
testWidgets('missing challenge deep link returns to login', (t) async {
await openFactor(t, path: '/two-factor');
expect(find.byKey(const ValueKey('email')), findsOneWidget);
expect(find.byKey(const ValueKey('two-factor-code')), findsNothing);
});
}
@@ -2,7 +2,16 @@ import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:math'; import 'dart:math';
enum JourneyKind { login, signup, verification, kyc, topUp, exchange, payout } enum JourneyKind {
login,
signup,
verification,
kyc,
topUp,
exchange,
payout,
security,
}
enum JourneyStep { enum JourneyStep {
login, login,
@@ -18,6 +27,10 @@ enum JourneyStep {
transferQuote, transferQuote,
transferSubmission, transferSubmission,
settlement, settlement,
factorChallenge,
factorDelivery,
factorVerification,
factorSettings,
} }
enum JourneySignal { enum JourneySignal {
@@ -91,6 +104,7 @@ class AnalyticsEvent {
JourneyKind.topUp => 'top_up', JourneyKind.topUp => 'top_up',
JourneyKind.exchange => 'exchange', JourneyKind.exchange => 'exchange',
JourneyKind.payout => 'payout', JourneyKind.payout => 'payout',
JourneyKind.security => 'security',
_ => 'auth', _ => 'auth',
}; };
var stage = step == null ? 'journey' : _snake(step!.name); var stage = step == null ? 'journey' : _snake(step!.name);
+36 -3
View File
@@ -27,11 +27,11 @@ await login.login(
login.dispose(); 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 ## 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. 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. 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 ## 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 shared `JourneyRecorder` from `sxchnge_analytics`. It preserves results/exceptions
for providers while emitting only typed semantic outcomes. `SimulationInfo` for providers while emitting only typed semantic outcomes. `SimulationInfo`
allows decorators and UI to identify preview services before the first result. 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. /// Ephemeral input: never persist, log, or include passwords in provider state.
class LoginRequest { class LoginRequest {
const LoginRequest({ const LoginRequest({
@@ -25,12 +27,25 @@ class SignupRequest {
/// An operation result, not proof of a session or an authorization decision. /// An operation result, not proof of a session or an authorization decision.
class AuthResult { class AuthResult {
const AuthResult({required this.userId, required this.isSimulated}); const AuthResult({
required this.userId,
required this.isSimulated,
this.challenge,
});
final String userId; final String userId;
final FactorChallenge? challenge;
final bool isSimulated; final bool isSimulated;
} }
enum AuthError { invalidCredentials, emailInUse, unavailable, unexpected } enum AuthError {
invalidCredentials,
emailInUse,
unavailable,
unexpected,
factorLocked,
factorCooldown,
factorRequired,
}
class AuthException implements Exception { class AuthException implements Exception {
const AuthException(this.code); const AuthException(this.code);
@@ -39,7 +54,7 @@ class AuthException implements Exception {
String toString() => 'AuthException(${code.name})'; String toString() => 'AuthException(${code.name})';
} }
enum AuthStatus { idle, loading, success, error } enum AuthStatus { idle, loading, challenge, success, error }
class AuthState { class AuthState {
const AuthState._(this.status, {this.result, this.error}); const AuthState._(this.status, {this.result, this.error});
@@ -47,6 +62,8 @@ class AuthState {
const AuthState.loading() : this._(AuthStatus.loading); const AuthState.loading() : this._(AuthStatus.loading);
const AuthState.success(AuthResult result) const AuthState.success(AuthResult result)
: this._(AuthStatus.success, result: result); : this._(AuthStatus.success, result: result);
const AuthState.challenge(AuthResult result)
: this._(AuthStatus.challenge, result: result);
const AuthState.failure(AuthError error) const AuthState.failure(AuthError error)
: this._(AuthStatus.error, error: error); : this._(AuthStatus.error, error: error);
final AuthStatus status; 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 'package:flutter/foundation.dart';
import '../models/auth.dart'; import '../models/auth.dart';
import '../models/two_factor.dart';
/// Shared operation lifecycle adapted from Sendico's ChangeNotifier providers. /// Shared operation lifecycle adapted from Sendico's ChangeNotifier providers.
/// Reset invalidates pending results; it does not cancel service-side work. /// Reset invalidates pending results; it does not cancel service-side work.
@@ -19,13 +20,21 @@ abstract class AuthProvider extends ChangeNotifier {
try { try {
final result = await operation(); final result = await operation();
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
_state = AuthState.success(result); _state = result.challenge == null
? AuthState.success(result)
: AuthState.challenge(result);
} catch (error) { } catch (error) {
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
// Never retain or expose arbitrary exceptions that may contain credentials. // Never retain or expose arbitrary exceptions that may contain credentials.
_state = AuthState.failure( _state = AuthState.failure(switch (error) {
error is AuthException ? error.code : AuthError.unexpected, 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(); if (!_disposed && generation == _generation) notifyListeners();
} }
@@ -1,11 +1,21 @@
import '../models/auth.dart'; import '../models/auth.dart';
import '../models/two_factor.dart';
import '../services/contracts.dart'; import '../services/contracts.dart';
import '../services/guarded_login.dart';
import 'auth.dart'; import 'auth.dart';
class LoginProvider extends AuthProvider { class LoginProvider extends AuthProvider {
LoginProvider(this.service); LoginProvider(this.service);
final LoginService 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({ Future<void> login({
required String email, required String email,
required String password, 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 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/auth.dart'; import '../models/auth.dart';
import '../models/two_factor.dart';
import '../models/verification.dart'; import '../models/verification.dart';
import 'contracts.dart'; import 'contracts.dart';
import 'guarded_login.dart';
import 'verification.dart'; import 'verification.dart';
/// Decorates domain services, so widget rebuilds never produce analytics events. /// Decorates domain services, so widget rebuilds never produce analytics events.
class InstrumentedAuthServices class InstrumentedAuthServices
implements implements
LoginService, PendingLoginService,
SignupService, SignupService,
VerificationService, VerificationService,
SimulationInfo { SimulationInfo {
@@ -33,6 +35,7 @@ class InstrumentedAuthServices
Future<T> Function() action, Future<T> Function() action,
bool Function(T) simulated, { bool Function(T) simulated, {
bool terminal = false, bool terminal = false,
bool Function(T)? terminalWhen,
bool onboarding = false, bool onboarding = false,
bool simulation = false, bool simulation = false,
}) async { }) async {
@@ -47,7 +50,7 @@ class InstrumentedAuthServices
recorder.finish( recorder.finish(
operation, operation,
simulated: simulated(result), simulated: simulated(result),
terminal: terminal, terminal: terminalWhen?.call(result) ?? terminal,
); );
return result; return result;
} catch (error) { } catch (error) {
@@ -57,12 +60,17 @@ class InstrumentedAuthServices
AuthError.emailInUse => JourneyFailure.emailInUse, AuthError.emailInUse => JourneyFailure.emailInUse,
AuthError.unavailable => JourneyFailure.unavailable, AuthError.unavailable => JourneyFailure.unavailable,
AuthError.unexpected => JourneyFailure.unexpected, AuthError.unexpected => JourneyFailure.unexpected,
AuthError.factorLocked ||
AuthError.factorCooldown => JourneyFailure.unavailable,
AuthError.factorRequired => JourneyFailure.invalid,
}, },
VerificationException(code: final code) => switch (code) { VerificationException(code: final code) => switch (code) {
VerificationError.expired => JourneyFailure.expired, VerificationError.expired => JourneyFailure.expired,
VerificationError.invalid => JourneyFailure.invalid, VerificationError.invalid => JourneyFailure.invalid,
VerificationError.internal => JourneyFailure.internal, VerificationError.internal => JourneyFailure.internal,
}, },
FactorException(code: FactorError.expired) => JourneyFailure.expired,
FactorException() => JourneyFailure.unavailable,
_ => JourneyFailure.unexpected, _ => JourneyFailure.unexpected,
}; };
recorder.finish(operation, failure: failure); recorder.finish(operation, failure: failure);
@@ -79,6 +87,23 @@ class InstrumentedAuthServices
JourneyStep.login, JourneyStep.login,
() => loginService.login(request), () => loginService.login(request),
(r) => r.isSimulated, (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, terminal: true,
simulation: _simulation(loginService), 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/verification.dart';
export 'src/services/instrumented.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);
}
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../models/wallet.dart'; import '../models/wallet.dart';
import 'wallet.dart'; import 'wallet.dart';
@@ -33,6 +35,7 @@ class TransferProvider extends ChangeNotifier {
Quote? quote; Quote? quote;
WalletOperation? result; WalletOperation? result;
WalletError? error; WalletError? error;
FactorError? factorError;
bool noSubmission = false; bool noSubmission = false;
bool busy = false, uncertain = false, _disposed = false, _handedOff = false; bool busy = false, uncertain = false, _disposed = false, _handedOff = false;
int _revision = 0; int _revision = 0;
@@ -59,6 +62,7 @@ class TransferProvider extends ChangeNotifier {
quote = null; quote = null;
noSubmission = false; noSubmission = false;
error = null; error = null;
factorError = null;
_key = null; _key = null;
_revision++; _revision++;
notifyListeners(); notifyListeners();
@@ -68,6 +72,7 @@ class TransferProvider extends ChangeNotifier {
if (!busy && !uncertain) { if (!busy && !uncertain) {
quote = null; quote = null;
error = null; error = null;
factorError = null;
_key = null; _key = null;
notifyListeners(); notifyListeners();
} }
@@ -84,6 +89,7 @@ class TransferProvider extends ChangeNotifier {
busy = true; busy = true;
noSubmission = false; noSubmission = false;
error = null; error = null;
factorError = null;
notifyListeners(); notifyListeners();
final revision = ++_revision; final revision = ++_revision;
final op = recorder.begin( final op = recorder.begin(
@@ -115,7 +121,45 @@ class TransferProvider extends ChangeNotifier {
notifyListeners(); 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 (busy || quote == null || uncertain) return;
if (expired) { if (expired) {
error = WalletError.expiredQuote; error = WalletError.expiredQuote;
@@ -125,6 +169,7 @@ class TransferProvider extends ChangeNotifier {
busy = true; busy = true;
noSubmission = false; noSubmission = false;
error = null; error = null;
factorError = null;
notifyListeners(); notifyListeners();
_key ??= List.generate( _key ??= List.generate(
16, 16,
@@ -136,18 +181,31 @@ class TransferProvider extends ChangeNotifier {
simulated: wallet.service.isSimulated, simulated: wallet.service.isSimulated,
); );
try { 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; if (_disposed) return;
_accepted(r, op); _accepted(r, op);
} catch (e) { } catch (e) {
if (_disposed) return; 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. // All transport/unexpected failures are ambiguous after execution started.
uncertain = [ uncertain = [
WalletError.unavailable, WalletError.unavailable,
WalletError.uncertain, WalletError.uncertain,
].contains(error); ].contains(error);
recorder.finish(op, failure: _failure(error!)); recorder.finish(
op,
failure: error == null ? JourneyFailure.invalid : _failure(error!),
);
} }
if (_disposed) return; if (_disposed) return;
busy = false; busy = false;
@@ -158,6 +216,7 @@ class TransferProvider extends ChangeNotifier {
result = value; result = value;
uncertain = false; uncertain = false;
error = null; error = null;
factorError = null;
recipient = ''; recipient = '';
recorder.finish(op, simulated: wallet.service.isSimulated); recorder.finish(op, simulated: wallet.service.isSimulated);
wallet.track(value, recorder, journey); wallet.track(value, recorder, journey);
@@ -169,6 +228,7 @@ class TransferProvider extends ChangeNotifier {
busy = true; busy = true;
noSubmission = false; noSubmission = false;
error = null; error = null;
factorError = null;
notifyListeners(); notifyListeners();
final op = recorder.begin( final op = recorder.begin(
journey, journey,
@@ -4,12 +4,15 @@ import 'package:flutter/foundation.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../models/wallet.dart'; import '../models/wallet.dart';
import '../services/contracts.dart'; import '../services/contracts.dart';
class WalletProvider extends ChangeNotifier { class WalletProvider extends ChangeNotifier {
WalletProvider(this.service, this.analytics); WalletProvider(this.service, this.analytics, {this.factors});
final WalletService service; final WalletService service;
final TwoFactorService? factors;
final AnalyticsService analytics; final AnalyticsService analytics;
WalletSnapshot? data; WalletSnapshot? data;
bool loading = false, bool loading = false,
@@ -6,7 +6,11 @@ abstract interface class WalletService {
Future<Quote> quote(TransferRequest request); Future<Quote> quote(TransferRequest request);
/// Same key must reconcile uncertain submissions without duplicate execution. /// 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<WalletOperation?> findSubmission(String idempotencyKey);
Future<AccountProfile> profile(); Future<AccountProfile> profile();
Future<AccountProfile> saveLocale(String locale); Future<AccountProfile> saveLocale(String locale);
@@ -1,5 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_kyc/limits_transport.dart'; import 'package:sxchnge_kyc/limits_transport.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
@@ -11,6 +13,7 @@ import 'contracts.dart';
class DummyWalletService implements WalletService { class DummyWalletService implements WalletService {
DummyWalletService( DummyWalletService(
this.identity, { this.identity, {
this.factors,
this.delay = const Duration(milliseconds: 350), this.delay = const Duration(milliseconds: 350),
this.settlement = const Duration(seconds: 2), this.settlement = const Duration(seconds: 2),
DateTime Function()? now, DateTime Function()? now,
@@ -18,6 +21,7 @@ class DummyWalletService implements WalletService {
}) : now = now ?? DateTime.now, }) : now = now ?? DateTime.now,
_locale = initialLocale; _locale = initialLocale;
final IdentityService identity; final IdentityService identity;
final TwoFactorService? factors;
final Duration delay, settlement; final Duration delay, settlement;
final DateTime Function() now; final DateTime Function() now;
final _accounts = [ final _accounts = [
@@ -159,13 +163,28 @@ class DummyWalletService implements WalletService {
} }
@override @override
Future<WalletOperation> submit(String quoteId, String idempotencyKey) async { Future<WalletOperation> submit(
String quoteId,
String idempotencyKey, {
String? approval,
}) async {
await _wait(); await _wait();
if (_submissions.containsKey(idempotencyKey)) if (_submissions.containsKey(idempotencyKey))
return WalletMapper.operation(_submissions[idempotencyKey]!); return WalletMapper.operation(_submissions[idempotencyKey]!);
final q = _quotes[quoteId]; final q = _quotes[quoteId];
if (q == null) throw const WalletException(WalletError.expiredQuote); if (q == null) throw const WalletException(WalletError.expiredQuote);
final verified = await _verified(); 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(); _settle();
if (_submissions.containsKey(idempotencyKey)) if (_submissions.containsKey(idempotencyKey))
return WalletMapper.operation(_submissions[idempotencyKey]!); return WalletMapper.operation(_submissions[idempotencyKey]!);
@@ -8,6 +8,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
sxchnge_auth:
path: ../sxchnge_auth
sxchnge_analytics: sxchnge_analytics:
path: ../sxchnge_analytics path: ../sxchnge_analytics
sxchnge_kyc: 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);
},
);
}