Separate second-factor verification and delivery from auth (#40)
This commit is contained in:
1 parent
12a38cd654
commit
ecb63b61c6
33 files changed
+1772
-123
No files matched your search
@@ -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`.
|
||||
Reference in new issue
Block a user