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>
6.0 KiB
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
amrclaim. - 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./loginflow change: if user hasmfa_enabled=true, return{ mfa_required: true, mfa_token: <short-lived JWT> }instead of access+refresh; client then callsPOST /login/mfawith{ 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
/loginchange is a wire-shape change for clients. Coordinate with UI workspace via cross-workspace ticket once admin lands. - Touches the same
/logincode 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).