mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 12:11:09 +00:00
[AZ-534] TOTP-based 2FA at credential login
Add RFC 6238 TOTP enrollment, two-step /login flow, recovery codes, and
the amr=["pwd","mfa"] claim that propagates through refresh-token rotation.
- New endpoints: /users/me/mfa/{enroll,confirm,disable} and /login/mfa.
- /login short-circuits to a 5-min ES256 step-1 token (audience-pinned
azaion-mfa-step2) when the user has MFA enabled; real access+refresh
pair is minted only after /login/mfa.
- mfa_secret encrypted at rest via ASP.NET Core IDataProtector
(purpose=Azaion.Mfa.Secret.v1; key folder configurable via
DataProtection:KeysFolder for production persistence).
- Recovery codes (10 single-use, base32, ~80-bit entropy) hashed with
SHA-256 and stored as JSONB; constant-time compare on lookup.
- RFC 6238 §5.2 replay defense via mfa_last_used_window per user.
- Sessions carry mfa_authenticated so /token/refresh re-stamps the
amr claim correctly across the entire 30-day refresh window.
- New audit events: enroll, confirm, disable, login-success/failed,
recovery-used.
- Schema: env/db/10_users_mfa.sql adds users.mfa_* columns and
sessions.mfa_authenticated; mfa_recovery_codes mapped as BinaryJson
in AzaionDbSchemaHolder; disable path uses raw parameterised SQL to
avoid LinqToDB null-literal type-inference on jsonb columns.
E2E: 6 new tests in MfaLoginTests cover all six AC; full suite
82 passed / 0 failed / 3 intentional skips.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# TOTP-Based 2FA at Credential Login
|
||||
|
||||
**Task**: AZ-534_totp_2fa_login
|
||||
**Name**: TOTP-based 2FA at credential login
|
||||
**Description**: Add RFC 6238 TOTP enrollment, two-step `/login` flow, and recovery codes. MFA validated only at credential login (not on each refresh); access tokens stamp `amr` claim so future verifiers can require step-up MFA. Per-user opt-in initially; can be made mandatory by role via config.
|
||||
**Complexity**: 5 points
|
||||
**Dependencies**: None (touches `/login` so coordinate merge with AZ-537 rate-limit + AZ-531 dual-token)
|
||||
**Component**: Admin API + Services + DataAccess
|
||||
**Tracker**: AZ-534
|
||||
**Epic**: AZ-529
|
||||
|
||||
## Problem
|
||||
|
||||
`/login` accepts password-only auth. CMMC requires multi-factor authentication for privileged accounts. There is no second-factor support today.
|
||||
|
||||
## Outcome
|
||||
|
||||
- TOTP (RFC 6238) enrollment + validation flow at credential login. No SMS, no email codes — TOTP only (offline-friendly, phishing-resistant against bulk SMS attacks).
|
||||
- Recovery codes (10 single-use codes shown once at enrollment) for device-loss recovery.
|
||||
- 2FA validated **only at credential login** — NOT on every refresh. The refresh chain proves "MFA was done in this session" via the `amr` claim.
|
||||
- Access tokens stamp `amr: ["pwd","mfa"]` when MFA was completed; `amr: ["pwd"]` if password-only (e.g. CompanionPC service accounts that don't have MFA enrolled).
|
||||
- Per-user opt-in initially; admin policy can require MFA for `Role in (Admin, ApiAdmin)` from a config flag.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- New columns on `users`: `mfa_enabled (bool)`, `mfa_secret (text, encrypted)`, `mfa_recovery_codes (jsonb of hashed codes)`, `mfa_enrolled_at (timestamptz)`.
|
||||
- `POST /users/me/mfa/enroll` — returns `{ secret, otpauth_url, qr_png_base64, recovery_codes }`. Requires authenticated session, requires re-auth via password in same request body.
|
||||
- `POST /users/me/mfa/confirm` — body `{ code }`, completes enrollment by validating one TOTP code.
|
||||
- `POST /users/me/mfa/disable` — body `{ password, code }`, removes secret and recovery codes.
|
||||
- `/login` flow change: if user has `mfa_enabled=true`, return `{ mfa_required: true, mfa_token: <short-lived JWT> }` instead of access+refresh; client then calls `POST /login/mfa` with `{ mfa_token, code }` to get the real tokens.
|
||||
- Recovery-code consumption: a recovery code may substitute for a TOTP code at `/login/mfa`; consumed code is marked used (single-use).
|
||||
- Audit log entries for: enroll, confirm, disable, login-via-MFA, login-via-recovery-code.
|
||||
- TOTP library: prefer `Otp.NET` (mature, no transitive deps). Verify version compatibility with .NET 10.
|
||||
|
||||
### Excluded
|
||||
|
||||
- WebAuthn / FIDO2 / hardware-key MFA — future ticket.
|
||||
- Per-action step-up MFA (re-prompt for sensitive operations) — future ticket. AZ-533 mission-token issuance will start enforcing `amr=["pwd","mfa"]` after this lands.
|
||||
- Admin-side reset of another user's MFA ("my user lost their phone") — future ticket; for now they go through recovery codes or DB intervention.
|
||||
- UI changes — cross-workspace ticket later.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Enrollment returns a usable TOTP secret**
|
||||
Given an authenticated user without MFA
|
||||
When `POST /users/me/mfa/enroll` is called with the user's password
|
||||
Then response includes a 32-character base32 `secret`, a valid `otpauth://` URL, a PNG QR (base64), and 10 recovery codes (≥12 chars each, base32). DB has `mfa_enabled=false` until confirm.
|
||||
|
||||
**AC-2: Confirm completes enrollment**
|
||||
Given the user scanned the QR and got a 6-digit code
|
||||
When `POST /users/me/mfa/confirm` is called with the code
|
||||
Then MFA is activated (`mfa_enabled=true`); subsequent logins require step 2.
|
||||
|
||||
**AC-3: Login two-step flow**
|
||||
Given a user with MFA enabled
|
||||
When `POST /login` is called with valid credentials
|
||||
Then response is 200 with `{ mfa_required: true, mfa_token, expires_in: 300 }` — no access/refresh yet.
|
||||
When `POST /login/mfa` is called with `mfa_token` + valid TOTP code
|
||||
Then access + refresh tokens are issued; access token's `amr=["pwd","mfa"]`.
|
||||
|
||||
**AC-4: Recovery code works once**
|
||||
Given the same MFA-enabled user
|
||||
When `POST /login/mfa` is called with a recovery code instead of TOTP
|
||||
Then login succeeds; `amr=["pwd","mfa","recovery"]`; the same code on the next login fails.
|
||||
|
||||
**AC-5: Disable requires password + current code**
|
||||
Given a user with MFA enabled
|
||||
When `POST /users/me/mfa/disable` is called with password + a valid TOTP code
|
||||
Then MFA is disabled; subsequent `/login` returns access+refresh directly without step 2.
|
||||
|
||||
**AC-6: TOTP secret is encrypted at rest**
|
||||
Given an enrolled user
|
||||
When the `users.mfa_secret` column is read directly from Postgres
|
||||
Then the value is ciphertext (uses the same encryption infra already in admin for sensitive fields).
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|------------------------|-------------|-------------------|----------------|
|
||||
| AC-1 | Auth user, no MFA | POST /users/me/mfa/enroll | 200 with secret, otpauth, QR, 10 recovery codes | — |
|
||||
| AC-2 | Enrollment in progress | POST /users/me/mfa/confirm with valid code | MFA activated | — |
|
||||
| AC-3 | MFA-enabled user | POST /login then POST /login/mfa | Two-step flow; amr=[pwd,mfa] | — |
|
||||
| AC-4 | MFA-enabled user | POST /login/mfa with recovery code | Success once; amr=[pwd,mfa,recovery]; second use fails | — |
|
||||
| AC-5 | MFA-enabled user | POST /users/me/mfa/disable | MFA off; /login returns tokens directly | — |
|
||||
| AC-6 | Enrolled user | Read users.mfa_secret directly from DB | Ciphertext, not plaintext base32 | — |
|
||||
|
||||
## Risks / Notes
|
||||
|
||||
- TOTP code reuse: a single 30-second code window can be replayed within those 30 seconds. Mitigate by tracking last-used-time per user (small DB write per login). Optional in this ticket; flag for next hardening pass if not in scope.
|
||||
- The two-step `/login` change is a wire-shape change for clients. Coordinate with UI workspace via cross-workspace ticket once admin lands.
|
||||
- Touches the same `/login` code path as AZ-537 (rate limit) and AZ-531 (dual-token). Land AZ-531 first (changes response shape), then AZ-537 (adds limiter middleware), then this ticket (adds the two-step branch).
|
||||
Reference in New Issue
Block a user