Merge pull request 'Add shared two-factor verification to login and money operations' (#33) from two-factor-32 into main
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
commit
1e191fca42
41 files changed
+2443
-43
No files matched your search
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
'Срок проверки истёк. Вернитесь и начните снова.';
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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": "Срок проверки истёк. Вернитесь и начните снова."
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/'),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user