Two-Factor Authentication (F5.17)
TOTP second factor (authenticator-app compatible) layered on the existing Argon2id + Redis session auth. Sessions are untouched — 2FA adds a challenge step before session creation.
How it works
POST /api/auth/loginverifies email + password as before.- If the user has
totpEnabled, the API does not create a session. Instead it stores a challenge (2fa-challenge:<token>→{ userId }in Redis, 32 random bytes, single-use, 5-min TTL) and returns202 { requires2FA: true, challengeToken }. - The frontend shows a code form.
POST /api/auth/2fa/challengewith{ challengeToken, code }verifies the code (TOTP first, then unused recovery code, which is marked used) and only then callscreateSession(). Failures return401 2FA_INVALID_CODEwith no cookie. - Users without 2FA log in exactly as before (password → session).
Setup is a separate authenticated flow: POST /api/auth/2fa/setup generates a secret (encrypted with AES-256-GCM via apps/api/src/utils/encryption.ts, stored with totpEnabled: false), returns { otpauthUrl, qrDataUrl, manualKey }. POST /api/auth/2fa/verify-setup with a valid 6-digit code flips totpEnabled: true and returns the recovery codes once. Disable (POST /api/auth/2fa/disable) requires { password, codeOrRecovery } and wipes secret + codes.
Core logic lives in apps/api/src/modules/auth/two-factor-service.ts (TwoFactorService); routes stay thin per CLAUDE.md §6. Challenge and verify-setup endpoints are rate-limited at the login tier (5 per 15 min per IP). Secrets, TOTP codes, and recovery codes are never logged; PUBLIC_USER_SELECT exposes only totpEnabled (added in commit c091380); no secret material (totpSecretEncrypted, recovery hashes) reaches /me.
Env: REQUIRE_ADMIN_2FA
# Require TOTP 2FA for the ADMIN role at login. "false" = optional for everyone.
REQUIRE_ADMIN_2FA=falseValidated in apps/api/src/config/env.ts (boolean after transform) and surfaced to the frontend via GET /api/auth/config as requireAdmin2FA. When true, an ADMIN without 2FA gets 403 ADMIN_2FA_REQUIRED at login instead of a session — non-admin users are unaffected.
Recovery model
- 10 codes generated at setup, shown once in the Settings UI (copy button, "I saved these" dismiss). Only argon2 hashes persist (
recovery_codestable,onDelete: Cascade); plaintext never touches the DB. - Each code is single-use:
verifyLoginCode()marks the rowusedAton success, so reuse returns401. - Lost authenticator + lost codes = ADMIN reset (below). There is no email-based recovery (out of scope until F5.23 transactional email).
Admin reset + audit
POST /api/users/:id/2fa/reset (ADMIN only, requireRole("ADMIN")) clears the target's secret + codes and forces totpEnabled: false. Audit-logged with actor attribution:
| Event | Action string |
|---|---|
| User enables 2FA | user.2fa_enabled |
| User disables 2FA | user.2fa_disabled |
| ADMIN resets a user's 2FA | user.2fa_reset |
No secret material lands in audit metadata. Resetting your own 2FA via this endpoint is allowed but logged like any other reset.
Test checklist (from spec §8)
Per CLAUDE.md: happy + error + auth paths.
- Enable → login requires code; valid TOTP → session;
/meworks. - Wrong/expired code →
401, no cookie; reused recovery code →401. - No-2FA user → login unchanged (password → session).
REQUIRE_ADMIN_2FA=true→ ADMIN without 2FA blocked withADMIN_2FA_REQUIRED.- ADMIN reset → target's 2FA cleared, audit entry attributed to the admin.
- Manual: two authenticator apps (Google Authenticator + 1Password) + QR and manual-key setup paths.
Unit/integration coverage: two-factor-service.test.ts (service + challenge store) and two-factor-routes.test.ts (challenge shape), run via pnpm --filter @vexlyx/api test.
Note for future workers: shared package builds
apps/api imports 2FA Zod schemas from @vexlyx/shared. If typecheck complains about unbuilt shared dist, run pnpm --filter @vexlyx/shared build first, then re-run typecheck.