Separate second-factor verification and delivery from auth (#40)

This commit is contained in:
Stephan D committed 2026-09-12 20:36:35 +02:00
1 parent 12a38cd654
commit ecb63b61c6
33 files changed
+1772 -123

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
# Second-factor service
Task [#40](https://git.sendico.io/tech/sxchnge/issues/40), branch `twofactor-40`.
## Ownership
`backend/product/twofactor` is a domain service inside the product executable.
It owns verified factors, preferred delivery channels, challenge snapshots,
issuance limits, code verification and single-use authorization proofs.
Its domain has no dependency on auth HTTP, JWTs or sessions. Shared Mongo
wrappers supply persistence and transactions; notifications only deliver messages.
Auth verifies the password, account and client, creates a pending JWT bound to the
client/device, and passes its hash as the login operation context. The service
selects the account's verified preferred factor and returns a masked challenge.
Auth calls `Complete` with the code and a callback that creates the session.
The service verifies the code, issues and consumes a proof, and commits the
callback's database changes in one transaction. Rejected guesses commit attempt
counters; callback failures roll back the code, proof and session together.
The HTTP response is written only after commit.
`Verify` and `Consume` also support separate domain steps for an operation that
needs a short-lived proof. Proofs bind the customer, purpose, operation and
challenge; they expire within two minutes and never outlive the challenge.
`Consume` checks the current profile and consumes the proof with the owner's
callback. Callbacks must only use this shared Mongo transaction: no external I/O,
response writes or irreversible side effects. Mongo may retry the callback.
An eventual remote service would need an explicit distributed completion protocol;
the callback contract cannot simply be translated into an RPC.
Signup email activation remains in `auth/flow`, with separate purpose and link
semantics. API-client secret login retains its existing separate behavior.
No generic public endpoint can choose an arbitrary authorization purpose.
## Sendico reuse
The token engine is the existing locally owned copy in
`backend/product/verification/store`. Its source is Sendico
`S_I/sendico` commit `8bb20dcd622dd8eb92d289b6b34411e7eccab083`, already recorded
in `backend/PROVENANCE.json` and `backend/PROVENANCE.md`. The new service uses
`NewOTPRequest`, `NewLinkRequest`, `CreateWithResult`, `Consume`, context binding,
idempotency, cooldown, max retries and issuance limits from that engine. It
retains SXCHNGE's subsequent random-key, empty-target-query and safe-log fixes.
No alternative OTP generator or token-consumption database was introduced.
The committed Sendico handlers inspected under
`api/edge/product/internal/server/verificationimp/` include `request.go`,
`verify.go`, `sendcode.go`, `target.go`, `service.go`, `types.go` and `token.go`.
They mix verification with session creation and private platform messaging and
allow recipient resolution unsuitable for a verified-factor boundary. Those
handlers were not copied. The orchestration, profile store, delivery interface
and HTTP settings adapter are new local code. There is no Sendico runtime,
private messaging or database dependency.
## Factors and policy
An active account's activated primary email is the initial verified factor.
Profiles are persisted in `two_factor_profiles`; challenge snapshots in
`two_factor_challenges` bind the chosen factor, locale, profile revision and
operation deadline. Snapshots cannot redirect a code during resend. Profile
changes invalidate outstanding challenges and proofs. A transactional profile
write serializes proof consumption against concurrent preference changes.
Challenge documents expire through a TTL index one day after their logical expiry;
logical expiry is checked synchronously and does not depend on TTL cleanup.
No existing customer migration is required: the initial profile is resolved
from the verified account and persisted on first completed verification.
Email is the installed channel driver. Users can enroll an additional email,
prove ownership and select it. Selection requires a fresh code at the current
preferred factor; enrollment alone cannot replace it. At most five factors
(including the primary email) are enrolled. Registered drivers form the supported
channel allow-list; unsupported or unavailable channels fail closed. There is no
automatic fallback, disable endpoint, factor removal or account recovery flow.
SMS, push, authenticator apps and passkeys require concrete implementations and
client support before use. The Flutter settings screen is still the existing
preview; these authenticated settings APIs are ready for a separate UI adapter.
Login, payout and preference scopes are recognized internally. Payout integration
is not added here; the payout owner must validate its immutable operation details
and call this service. An auth proof cannot authorize money operations.
## HTTP settings contract
All routes below are under `/api/v1/accounts` and require an existing active
account session through the standard `AccountHandler`. Pending login credentials
cannot access them. Account identity and operation scope come from the server,
not request fields. Responses use the existing `data` envelope and `no-store`.
| Method/path | Body | Result (202) |
| --- | --- | --- |
| GET `/two-factor` | none | `required: true`, `preferred`, `factors: [{id, channel, target}]` |
| POST `/two-factor/enroll` | `channel`, `address`, `idempotencyKey` | Challenge at the proposed address |
| POST `/two-factor/enroll/verify` | `challengeId`, `code` | Settings after ownership verification |
| POST `/two-factor/preference` | `factorId`, `idempotencyKey` | Challenge at the current preferred factor |
| POST `/two-factor/preference/verify` | `factorId`, `challengeId`, `code` | Settings after preference changes |
| POST `/two-factor/resend` | `challengeId`, `idempotencyKey` | Same settings operation and target, renewed code |
Challenge responses contain `challengeId`, `channel`, masked `target`, `expiresAt`
and `resendAt`. Login responses additionally include the pending JWT; login
resend/verify now require both `challengeId` and `pendingToken`. Codes, raw proofs
and full enrolled addresses are never returned in these responses.
Codes expire within ten minutes with five guesses per issued code. New issuance
uses a one-minute cooldown per challenge, plus shared fixed-window limits of five
per customer and twenty per requesting IP per hour across purposes. New challenges
cannot reset these delivery budgets. Same-key delivery retries recover the same
code, notification key and expiry; notification enqueueing deduplicates them.
A new accepted resend invalidates the prior code. A locked/expired operation must
restart. Replaying a used code or proof cannot execute the callback again.
## Rollout and recovery
Load revision 1 of `twofactor.code` alongside `auth.activation` and `auth.login`
from `backend/notifications/templates.example.json`; all have English/Russian
variants. Existing notification TLS/caller configuration and the private signing
key supply delivery and secret-derived issuance keys. No new service deployment
or network credentials are required. Mongo must support transactions (replica set).
The application creates the two new collections/index on startup.
Deploy backend and Flutter contract changes together. Earlier pending login codes
have no new challenge record and must restart password login after rollout.
Existing sessions and activation links are preserved. Key rotation invalidates
outstanding factor snapshots/proofs. Keep factors on an available driver when
changing configured drivers; there is no silent fallback.
A failed session write permits retrying the same code. A lost HTTP response after
a successful commit cannot replay the operation: restart login. Enrollment and
preference confirmation can be reconciled with GET settings after an ambiguous
response. Tokens and challenges commit before notification Send; a product crash
before Send still requires resend. No product delivery outbox or inbox receipts
are claimed. No live emails were sent during tests.
## Verification and observability
Real Mongo tests cover failed delivery replay, scope/customer isolation, expiry,
inactive accounts, attempts committed through outer transactions, proof replay,
callback rollback, concurrent completion, enrollment and preference invalidation.
The signup/login integration test exercises the new auth boundary. Transport
checks reject caller identity/purpose/destination overrides. Architecture tests
prevent the 2FA domain importing auth or sessions. Flutter adapter tests exercise
server challenge binding, resend recovery and unsupported channels.
Existing auth HTTP counters/timers and frontend journey events remain authoritative
for login outcomes. Domain logs record queued challenges, rejected codes and
completed operations with challenge IDs and purpose/channel, without codes,
proofs, keys or destinations. Prepared proof logs are debug-level and do not
represent a committed operation. See `docs/ux/auth-journeys.md` for attempt windows,
retry/abandonment rules and the existing production analytics export gap.
No new settings UI journey or production analytics sink is claimed.
The UI composition is unchanged. The shared DTO/mapper/service adapter preserves
masked delivery targets, provider error/retry handling and in-memory credential
storage. Existing widget tests cover mobile forms, keyboard behavior, loading,
cooldown and recovery; new adapter tests cover the changed backend contract.
Required completion checks and delivery are recorded in `docs/implementation.md`.
+12 -10
View File
@@ -2,14 +2,14 @@
Task [#38](https://git.sendico.io/tech/sxchnge/issues/38), [PR #39](https://git.sendico.io/tech/sxchnge/pulls/39), branch `auth-38`.
Primary module: auth; secondary modules: notification templates and Flutter auth.
This change stacks on notifications PR #37, which stacks on BFF PR #35.
Merge #35, then #37, then this contribution, retargeting each dependent PR to main.
The initial integration is merged. Task #40 separates login verification into
the [second-factor service](architecture/two-factor.md).
## Configuration
Load the `auth.activation` and `auth.login` revision 1 templates from
Load the `auth.activation`, `auth.login` and `twofactor.code` revision 1 templates from
`backend/notifications/templates.example.json` into the notification catalog.
Both templates have English and Russian variants; product account locale selects
These templates have English and Russian variants; product account locale selects
Russian for `ru` variants and English otherwise. Publish subsequent template
changes under a new revision rather than mutating deployed content.
@@ -66,9 +66,9 @@ envelope. Credentials, codes and activation tokens are sent in POST bodies.
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after activation email queue acceptance |
| POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts |
| POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits |
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, masked `target`, `expiresAt`, `resendAt` |
| POST `/login/resend` | `pendingToken`, `idempotencyKey` | 202 with the challenge timing and same pending credential |
| POST `/login/verify` | `pendingToken`, six-digit `code` | 200 with account and existing access/refresh response |
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
| POST `/login/resend` | `pendingToken`, `challengeId`, `idempotencyKey` | 202 with the challenge timing and same pending credential |
| POST `/login/verify` | `pendingToken`, `challengeId`, six-digit `code` | 200 with account and existing access/refresh response |
The pending JWT binds the customer, client and device. The verification record
binds its code to a hash of that JWT. Password login does not create a session.
@@ -79,7 +79,8 @@ and commits the account transition with token consumption in one transaction.
Login codes expire within 10 minutes and cannot outlive their pending JWT.
Resending uses a one-minute cooldown, invalidates the previous code in that
challenge, and allows five deliveries per destination per hour. Incorrect codes
challenge, and allows five deliveries per customer and twenty per requesting IP per hour
across second-factor purposes. Incorrect codes
consume the existing five-attempt budget. Restart login after a pending credential
expires or a challenge locks. Signup/resend request limits also apply per email
(10/hour) and IP (30/hour), using shared atomic fixed-window counters.
@@ -102,8 +103,9 @@ retries can create replacement messages; they are not distributed exactly-once
operations. Tokens are stored before the RPC, so a process crash between those
steps requires user recovery; there is no product-side delivery outbox.
Code consumption precedes session creation. If session creation or its HTTP
response fails, restart password login; the code is not reusable. The adapter
Code verification, one-use proof consumption and session creation now commit in
one transaction. A session-write failure leaves the code retryable; a lost HTTP
response after commit requires restarting password login. The adapter
retains a confirmed session in memory and has no refresh/restore-on-reload flow
in this contribution. Backend authorization remains authoritative for future
protected APIs. Notification provider failures after queue acceptance are visible
+44
View File
@@ -536,3 +536,47 @@ frontend API URL/public client before enabling real auth.
Delivered in [PR #39](https://git.sendico.io/tech/sxchnge/pulls/39). Merge order: #35 → #37 → #39. The auth branch incorporates notification completion commit `4b14649`; only the delivery-log append conflicted, and both task records are preserved.
## Dedicated second-factor service (#40)
Task [#40](https://git.sendico.io/tech/sxchnge/issues/40), branch `twofactor-40`,
based on main after merged PR #39. Primary module: twofactor; secondary modules:
auth, verification purpose definitions, notification templates and Flutter auth.
`product/twofactor` now owns challenge issuance/delivery, verified destinations,
preferences, attempt/cooldown limits and single-use proofs. It reuses the
Sendico-derived verification store at the existing pinned revision. No new
Sendico handler or private messaging code was copied. Auth owns credentials,
pending JWTs and session creation; signup activation remains in `auth/flow`.
The service's `Complete` transaction commits code verification, proof consumption
and the owner's session write together. Review fixed rejected-attempt rollback,
ensured failed session writes preserve retryability, retained activation replay
key compatibility and removed the unused verification-store injection from auth
HTTP. A profile write serializes consumption against preference changes.
Authenticated APIs enroll additional verified emails and change the preferred
factor only after verifying the current factor. Unsupported channels fail closed.
Flutter's shared DTO/mapper/service adapter sends the server challenge ID with
the pending JWT, preserves resend keys after failure and rejects unknown channels.
No widgets or provider/analytics boundaries changed. Existing login journey
measurement and error/recovery UI remain in use; settings UI integration is not
part of this separation.
Validation passed: `make test` (all Go race suites and `make test-frontend`),
`make lint-backend`, `flutter analyze` in `frontend/packages/sxchnge_auth`, and
Dart format verification for all four changed Dart files. New real Mongo tests
cover single-use proofs, customer/purpose/context isolation, delivery retries,
committed failed guesses, expiry, inactive accounts, operation rollback,
concurrent completion, verified enrollment, preference selection and old-proof
invalidation. The updated signup/login test covers the full auth boundary;
transport and architecture tests cover scope injection and domain dependencies.
Frontend tests cover challenge binding, resend recovery and unsupported channels.
No live email or optional security scan was run.
See [ownership, Sendico provenance, HTTP contract and rollout](architecture/two-factor.md).
Deploy backend and client together, load `twofactor.code` revision 1, and restart
previously pending logins after rollout. Email is the implemented channel.
Existing settings screens still use preview adapters; SMS/push/TOTP/passkeys,
factor removal/recovery, payout integration and a separate process are not
implemented. Successful-commit response loss still requires login restart;
notification queue acceptance is not inbox delivery.