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`.
|
||||
+12
-10
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user