Skip to content

Add TOTP 2FA with security hardening (encryption, recovery codes, HttpOnly cookies) - #147

Open
vmfasimoes wants to merge 2 commits into
technomancer702:mainfrom
vmfasimoes:feature/totp-2fa
Open

vmfasimoes wants to merge 2 commits into
technomancer702:mainfrom
vmfasimoes:feature/totp-2fa

Conversation

@vmfasimoes

Copy link
Copy Markdown

Summary

This PR introduces TOTP-based two-factor authentication (RFC 6238) with full security hardening. It is split across two commits: the initial 2FA implementation and a subsequent security hardening pass addressing all issues raised in the first review.


What was implemented

Initial implementation

  • TOTP 2FA via speakeasy — RFC 6238 compliant, replaces the incompatible otplib v13
  • Two-step login flow — password authentication issues a short-lived temp token (5 min); the full JWT is only issued after a successful TOTP verification
  • Per-user 2FA management from the admin Users panel — no separate Account tab
    • Setup 2FA (QR code + manual key)
    • Reset 2FA (invalidates current secret, generates a new QR — secret never exposed)
    • Force-disable 2FA
  • Users table shows a 2FA status badge (On / Off) per row
  • Sensitive fields (totpSecret, totpPendingSecret) stripped from GET /api/auth/users responses

Security hardening (6 points)

1. Safe JWT secret handling

  • JWT_SECRET now throws at startup if missing in NODE_ENV=production
  • Development falls back to a per-restart crypto.randomBytes(32) secret with a console warning

2. TOTP secret retrieval endpoint removed

  • GET /users/:id/2fa/qr (which returned the active TOTP secret) was removed entirely
  • Replaced with POST /users/:id/2fa/reset, which immediately invalidates the current secret and returns only a new QR code — the active secret is never returned over the API

3. Rate limiting on TOTP verification

  • express-rate-limit applied to POST /2fa/verify: 5 failed attempts per 15 minutes per IP (skipSuccessfulRequests: true)
  • app.set('trust proxy', true) changed to trust proxy: 1 — prevents trivial IP spoofing via X-Forwarded-For that would bypass rate limiting

4. TOTP secrets encrypted at rest (AES-256-GCM)

  • New encryptSecret() / decryptSecret() helpers in server/auth.js
  • Storage format: enc:<iv_hex>:<authTag_hex>:<ciphertext_hex> (authenticated encryption)
  • All writes to totpSecret / totpPendingSecret pass through encryptSecret()
  • All reads before verification pass through decryptSecret()
  • TOTP_ENCRYPTION_KEY env var (32-byte hex) required in production; dev stores plaintext with a warning
  • Graceful migration: values without the enc: prefix are accepted as-is (dev / legacy)

5. Single-use recovery codes

  • generateRecoveryCodes() produces 8 codes in XXXXX-XXXXX format (hex, bcrypt-hashed, stored server-side)
  • Plaintext codes returned to the user once only, at 2FA enrollment, via the admin modal
  • matchRecoveryCode() iterates hashed codes and returns the matched index; used slots are set to null (single-use enforced)
  • POST /2fa/verify accepts either a 6-digit TOTP code or a recovery code in the same field
  • Disable / reset flows clear recoveryCodes; GET /users strips the field from responses
  • Login page adds a "Use a recovery code" toggle that switches the input field between TOTP mode (6-digit numeric) and recovery code mode (XXXXX-XXXXX)

6. Auth tokens migrated from localStorage to HttpOnly cookies

  • cookie-parser middleware added to server/index.js
  • JWT Passport strategy updated to extract token from req.cookies.token first, then Authorization: Bearer (backward-compatible with API clients)
  • Login (POST /login), initial setup (POST /setup), POST /2fa/verify, and the OIDC callback all set an HttpOnly; SameSite=Lax cookie; logout calls res.clearCookie()
  • OIDC callback no longer appends ?token= to the redirect URL
  • Frontend api.js uses credentials: 'same-origin'; all localStorage.getItem/setItem('authToken') and Authorization: Bearer header logic removed
  • auth.js init() now always calls GET /api/auth/me (cookie is sent automatically by the browser); no more token presence check before the request

Files changed

File Change
server/auth.js JWT fail-fast, TOTP encryption helpers, recovery code helpers, cookie-aware JWT strategy
server/routes/auth.js Rate limiting, encrypt/decrypt on all secret reads/writes, recovery code generation and verification, cookie set/clear on login/logout/OIDC
server/index.js cookie-parser middleware, trust proxy: 1
public/js/api.js Removed Authorization header and localStorage token management; credentials: same-origin
public/js/app.js checkAuth and logout rewritten to use cookie (no localStorage)
public/js/auth.js init() always calls /me; removed API.setToken calls
public/js/pages/Settings.js Recovery codes panel shown after 2FA enable; reset UI clears recovery panel
public/login.html Removed localStorage token save; added "Use a recovery code" toggle
public/index.html Recovery codes panel in edit-user modal; removed ?token= SSO handler

Test plan

  • Login without 2FA — username/password login sets HttpOnly cookie; browser redirects to /; no token visible in DevTools → Application → Local Storage
  • Login with 2FA enabled — step 1 returns requires2fa: true; step 2 with correct TOTP code sets cookie and redirects
  • Login with 2FA — wrong code — returns 401; after 5 consecutive failures the endpoint returns 429 for 15 minutes
  • Login with recovery code — click "Use a recovery code" on login page; enter a valid XXXXX-XXXXX code; login succeeds; same code cannot be used a second time
  • 2FA setup (admin) — open edit-user modal → Setup 2FA → scan QR in authenticator app → enter code → recovery codes panel appears with 8 codes; close with "I have saved my codes"
  • 2FA reset (admin) — Reset 2FA on an enabled user; new QR shown; old TOTP code no longer works; user must re-enroll
  • 2FA disable (admin) — Disable 2FA; user can log in again with only password
  • TOTP secrets encrypted in DB — after enabling 2FA, inspect the raw database file; totpSecret value should start with enc: (not a plain base32 string)
  • Recovery codes cleared on disable — disable 2FA and re-enable; old recovery codes must not work
  • SSO login — OIDC callback sets cookie and redirects to /; no ?token= in URL
  • Logout — clicking logout calls POST /api/auth/logout; cookie is cleared; subsequent requests to /api/auth/me return 401

Vitor Simões and others added 2 commits June 13, 2026 00:29
- Login flow now issues a short-lived temp token (5 min) when 2FA is
  enabled; full JWT is only issued after successful TOTP verification
- Admin panel: per-user 2FA management (setup, view QR, force-disable)
- Users table shows 2FA status badge (On/Off) per row
- Edit user modal includes 2FA section with QR code setup and viewer
- Sensitive TOTP fields (totpSecret, totpPendingSecret) stripped from
  GET /api/auth/users responses
…imiting

Security hardening applied to the existing TOTP 2FA feature:

1. Fix unsafe JWT fallback in production
   - JWT_SECRET now throws at startup if missing in production
   - Development uses a per-restart random secret with a console warning

2. Remove TOTP secret retrieval endpoint
   - Removed GET /users/:id/2fa/qr that returned the active secret
   - Replaced with POST /users/:id/2fa/reset, which immediately invalidates
     the current secret and returns only a new QR code (secret never exposed)

3. Rate-limit TOTP verification
   - Added express-rate-limit (5 attempts / 15 min, skipSuccessfulRequests)
     on POST /2fa/verify to prevent brute-force attacks
   - Fixed trust proxy from true to 1 so IP-based limiting is not bypassable

4. Encrypt TOTP secrets at rest (AES-256-GCM)
   - New encryptSecret / decryptSecret helpers in server/auth.js
   - All writes to totpSecret / totpPendingSecret go through encryptSecret()
   - All reads before TOTP verification go through decryptSecret()
   - Stored format: enc:<iv>:<authTag>:<ciphertext> (hex-encoded)
   - TOTP_ENCRYPTION_KEY env var required in production; dev falls back to
     plaintext with a warning (graceful migration via enc: prefix detection)

5. Single-use recovery codes
   - generateRecoveryCodes() produces 8 codes (XXXXX-XXXXX format, bcrypt-hashed)
   - Returned to the user in plaintext only once, at 2FA enrollment
   - matchRecoveryCode() verifies and the used slot is nulled (single-use)
   - /2fa/verify accepts either a TOTP code or a recovery code in the same field
   - Disable / reset flows clear stored codes; GET /users strips recoveryCodes
   - Login page adds "Use a recovery code" toggle to switch input mode

6. Migrate auth tokens from localStorage to HttpOnly cookies
   - cookie-parser added; JWT strategy reads req.cookies.token first, then
     Authorization header (backward-compatible)
   - Login, setup, /2fa/verify, and OIDC callback set an HttpOnly cookie;
     logout calls res.clearCookie()
   - OIDC callback no longer appends ?token= to the redirect URL
   - Frontend api.js uses credentials: same-origin; removed all
     localStorage.getItem/setItem('authToken') and Authorization: Bearer headers
   - auth.js init() now always calls /me (cookie sent automatically)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant