Add opt-in TOTP 2FA for local accounts

New card in Настройки → Аккаунт: scan a QR code (otplib + qrcode), confirm
with a live code, get 8 one-time recovery codes shown once. Only offered
for authSource="local" — LDAP accounts already have their own MFA story at
the directory level and are turned away with a clear message if they somehow
hit the setup endpoint directly.

Login flow: a 2FA account's password check now creates a short-lived
"login challenge" (separate table from `sessions`, 5-minute TTL, capped at
6 verify attempts) instead of a real session, and the login page swaps to a
second screen asking for a code — TOTP or a recovery code, either works.
The LDAP branch of the login route is untouched; it returns before ever
reaching the 2FA check.

totpSecret is encrypted at rest via the existing lib/crypto/credentials.ts
helper (same one already used for mailbox/LDAP bind passwords) rather than
adding a second encryption scheme. Recovery codes are hashed, not stored
plaintext, and each is single-use (marked usedAt, not deleted).

Verified against the real deployment end-to-end with Playwright against a
throwaway test account (created via the real admin-users API, fully
deleted after): setup → confirm → recovery-code issuance → second login
correctly prompted for a code → wrong code rejected → correct code and a
recovery code both worked → a reused recovery code was correctly rejected
→ disable (password-gated) → subsequent login went straight through again.
Also added unit tests for the TOTP/recovery-code helpers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-20 08:25:15 +00:00
1 parent 95522fdedd
commit 4ecb0e7698
17 files changed
+2285 -55

No files matched your search

+3
View File
@@ -26,6 +26,8 @@
"mailparser": "^3.9.14",
"next": "16.2.12",
"nodemailer": "^9.0.3",
"otplib": "^13.4.1",
"qrcode": "^1.5.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.13.1",
@@ -40,6 +42,7 @@
"@types/mailparser": "^3.4.6",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",