Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
20 lines
891 B
SQL
20 lines
891 B
SQL
-- Migration 027 — TOTP two-factor auth.
|
|
--
|
|
-- totp_secret holds the base32 shared secret once enrollment is confirmed
|
|
-- (NULL while disabled or mid-enrollment). totp_enabled flips true only after
|
|
-- the user verifies their first code, so a half-finished enrollment never locks
|
|
-- anyone out. Recovery codes are one-time bcrypt-hashed fallbacks; used_at marks
|
|
-- a code as spent.
|
|
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
|
|
|
CREATE TABLE IF NOT EXISTS user_recovery_codes (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
|
code_hash TEXT NOT NULL,
|
|
used_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user ON user_recovery_codes(user_id);
|