Files
sxchnge/docs/architecture/two-factor.md
T

157 lines
9.4 KiB
Markdown

# Second-factor service
Task [#40](https://git.sendico.io/tech/sxchnge/issues/40),
[PR #41](https://git.sendico.io/tech/sxchnge/pulls/41), 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`.