9.4 KiB
Second-factor service
Task #40,
PR #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.