# 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: }` 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).