TOTP MFA (Phase 11)¶
What¶
Per-user two-factor authentication via TOTP (RFC 6238). After a user enables MFA, login requires both their password and a 6-digit code from an authenticator app (Google Authenticator, 1Password, Authy, etc.). Backup codes provide recovery if the device is lost.
No email dependency — works entirely under the AWS SES sandbox restriction. Compatible with every standard authenticator app via the otpauth:// provisioning URI.
Why¶
SOC 2 Type 2 strongly favors MFA on privileged accounts. Audit firms accessing client data via AuditForge represent an attractive target — partner credentials are valuable enough that password-only auth is insufficient. TOTP is the lowest-friction, highest-compatibility second factor available.
This also satisfies enterprise procurement reviews that flag "single-factor auth" as a control gap.
Enrollment flow (3-step)¶
1. POST /auditforge/auth/totp/enroll (authed, no body needed)
→ returns { secret: "<base32>", provisioning_uri: "otpauth://..." }
Server does NOT yet commit; an in-progress enrollment that's
never confirmed leaves no phantom credential.
2. User scans QR (or enters secret manually) into authenticator app.
3. POST /auditforge/auth/totp/confirm
body: { secret: "...", totp_code: "123456" }
→ on success: user.totp_enabled=true, secret stored, 10 backup
codes generated. Backup codes returned in plaintext ONCE.
Backup codes are 10-character hex with a hyphen for readability (e.g., a3f7c-9d2e1). They're argon2-hashed at rest; presenting one consumes it (single-use).
Login with MFA¶
If the user has TOTP enrolled, this returns:
The frontend detects this via the MfaRequiredError exception class, prompts for a code, and re-submits:
POST /auditforge/auth/login
body: { email, password, totp_code: "123456" }
OR { email, password, backup_code: "a3f7c-9d2e1" }
On success, returns the standard session token + user object (with totp_enabled: true so the frontend knows MFA is active).
Disable¶
Requires proof-of-possession via either a current code or a backup code — same as login. Admin-token callers cannot disable a user's MFA via this endpoint (recovery requires per-user session); a future admin-recovery endpoint is on the roadmap.
Threat model¶
| Attack | Mitigation |
|---|---|
| Stolen password (phishing, breach reuse) | TOTP code required; attacker doesn't have the device |
| Stolen authenticator device | Attacker still needs the password |
| TOTP secret leak via logs | Secret is never logged; only stored encrypted-at-rest in S3 (AES-256) |
| Replay of an old code | RFC 6238 windows are 30s; pyotp's default valid_window=1 accepts ±30s clock drift; replay outside that fails |
| Backup code theft | Each is single-use; consumption persisted server-side |
| Server-side secret tampering | Stored in users.json behind admin-only access; bucket access logs for audit |
Out of scope today (open follow-ups): - Hardware FIDO2 / WebAuthn (stronger than TOTP, but more setup friction) - SMS-based 2FA (compromised by SIM swapping; deliberately not supported) - Push-notification 2FA (requires mobile app; not on roadmap)
Endpoints¶
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /auth/login |
none | Now accepts optional totp_code or backup_code |
| POST | /auth/totp/enroll |
session | Step 1 — return secret + provisioning URI |
| POST | /auth/totp/confirm |
session | Step 2 — verify code, activate, issue backup codes |
| POST | /auth/totp/disable |
session | Disable with proof-of-possession |
Frontend¶
- AuditForgeLanding login form: when login returns 401 with
mfa_required, the form transitions to MFA-prompt mode (email + password fields disabled, MFA code field appears, "Verify MFA" button replaces "Sign in"). Toggle between TOTP code and backup code inline. - MfaEnrollModal: three-step modal — QR display + manual secret → 6-digit verification → backup-codes-shown-once. Modal can't be dismissed during the verification step (preventing half-completed enrollment confusion).
- AuditForge shell header: "Enable MFA" link visible only for session-token callers (admin-token callers have no user record to attach TOTP to).
Backup-code UX¶
After confirmation, backup codes are displayed in a 2-column grid with a "Copy all to clipboard" button. The user must explicitly click "I've saved them — done" to dismiss; this is intentional friction to discourage accidental loss.
The codes are NOT shown again. If the user loses both their device and their backup codes, an admin-token-recovery flow is required (open follow-up — admin endpoint to clear MFA on a user's behalf, with audit-log entry).
Cost¶
Zero. Pure server-side validation, no LLM calls, no external services.
Code¶
app/auditforge/users.py—User.totp_enabled/totp_secret/totp_backup_code_hashes/totp_enabled_atfields; TOTP helpers (generate_totp_secret,totp_provisioning_uri,verify_totp_code,generate_backup_codes,consume_backup_code);UserStore.set_totp/disable_totp/consume_totp_backup_codeapp/auditforge_endpoints.py—LoginRequestextended withtotp_code+backup_code;TotpEnrollRequest/TotpConfirmRequest/TotpDisableRequest; 3 new endpointsfrontend/src/api/auditforge.ts—MfaRequiredError,loginacceptsmfaparam,totpEnroll/totpConfirm/totpDisabletyped clientsfrontend/src/components/AuditForgeLanding.tsx— MFA prompt + backup-code toggle inline in login formfrontend/src/components/MfaEnrollModal.tsx— three-step enrollment dialogfrontend/src/components/AuditForge.tsx— "Enable MFA" link + modal mount
Open follow-ups¶
- Admin-recovery endpoint to clear MFA on a user (with audit log)
- Per-user MFA-required policy (firm admin requires all firm users to enroll)
- WebAuthn / FIDO2 alongside TOTP (strongest possible second factor)
- Authenticator setup wizard with brand-specific app suggestions
- "Trust this device for 30 days" cookie (lower friction for partner laptops)