Files

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.