Skip to content

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

POST /auditforge/auth/login
body: { email, password }

If the user has TOTP enrolled, this returns:

401 { "detail": { "mfa_required": true, "message": "MFA code required." } }

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

POST /auditforge/auth/totp/disable
body: { totp_code: "..." }  OR  { backup_code: "..." }

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.pyUser.totp_enabled / totp_secret / totp_backup_code_hashes / totp_enabled_at fields; 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_code
  • app/auditforge_endpoints.pyLoginRequest extended with totp_code + backup_code; TotpEnrollRequest / TotpConfirmRequest / TotpDisableRequest; 3 new endpoints
  • frontend/src/api/auditforge.tsMfaRequiredError, login accepts mfa param, totpEnroll / totpConfirm / totpDisable typed clients
  • frontend/src/components/AuditForgeLanding.tsx — MFA prompt + backup-code toggle inline in login form
  • frontend/src/components/MfaEnrollModal.tsx — three-step enrollment dialog
  • frontend/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)