Files
admin/_docs/02_tasks/done/AZ-534_totp_2fa_login.md
T
Oleksandr Bezdieniezhnykh 1e1ded73f5
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
[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>
2026-05-14 06:21:28 +03:00

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 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).