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
+2236 -6

No files matched your search

+333 -5
View File
@@ -20,6 +20,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",
@@ -34,6 +36,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",
@@ -2090,6 +2093,17 @@
"node": ">= 10"
}
},
"node_modules/@noble/hashes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2134,6 +2148,56 @@
"node": ">=12.4.0"
}
},
"node_modules/@otplib/core": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz",
"integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA=="
},
"node_modules/@otplib/hotp": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz",
"integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==",
"dependencies": {
"@otplib/core": "13.4.1",
"@otplib/uri": "13.4.1"
}
},
"node_modules/@otplib/plugin-base32-scure": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz",
"integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==",
"dependencies": {
"@otplib/core": "13.4.1",
"@scure/base": "^2.2.0"
}
},
"node_modules/@otplib/plugin-crypto-noble": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz",
"integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==",
"dependencies": {
"@noble/hashes": "^2.2.0",
"@otplib/core": "13.4.1"
}
},
"node_modules/@otplib/totp": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz",
"integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==",
"dependencies": {
"@otplib/core": "13.4.1",
"@otplib/hotp": "13.4.1",
"@otplib/uri": "13.4.1"
}
},
"node_modules/@otplib/uri": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz",
"integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==",
"dependencies": {
"@otplib/core": "13.4.1"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -2478,6 +2542,14 @@
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
"dev": true
},
"node_modules/@scure/base": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz",
"integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
@@ -2864,6 +2936,15 @@
"@types/node": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -3659,11 +3740,18 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@@ -4201,6 +4289,14 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
@@ -4311,11 +4407,20 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -4326,8 +4431,7 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/concat-map": {
"version": "0.0.1",
@@ -4480,6 +4584,14 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -4575,6 +4687,11 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -5797,6 +5914,14 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -6406,6 +6531,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -7684,6 +7817,19 @@
"node": ">= 0.8.0"
}
},
"node_modules/otplib": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz",
"integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==",
"dependencies": {
"@otplib/core": "13.4.1",
"@otplib/hotp": "13.4.1",
"@otplib/plugin-base32-scure": "13.4.1",
"@otplib/plugin-crypto-noble": "13.4.1",
"@otplib/totp": "13.4.1",
"@otplib/uri": "13.4.1"
}
},
"node_modules/own-keys": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
@@ -7740,6 +7886,14 @@
"node": ">=10"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7818,7 +7972,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -7911,6 +8064,14 @@
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -8042,6 +8203,22 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -8177,6 +8354,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -8451,6 +8641,11 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -8797,6 +8992,24 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8905,6 +9118,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -10335,6 +10559,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/which-typed-array": {
"version": "1.1.22",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
@@ -10381,17 +10610,116 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+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",
+10
View File
@@ -1,5 +1,6 @@
import { getCurrentSession } from "@/lib/auth/session";
import { ChangePasswordForm } from "./change-password-form";
import { TwoFactorSettings } from "./two-factor-settings";
export default async function AccountSettingsPage() {
const session = await getCurrentSession();
@@ -9,6 +10,15 @@ export default async function AccountSettingsPage() {
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Аккаунт</h1>
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
<ChangePasswordForm />
{session?.user.authSource === "local" ? (
<div className="mt-4">
<TwoFactorSettings initialEnabled={session.user.totpEnabled} />
</div>
) : (
<p className="mt-4 rounded-md border border-border bg-surface-hover px-3 py-2 text-sm text-text-muted">
Двухфакторная аутентификация недоступна для LDAP-аккаунтов — вход защищён на уровне домена.
</p>
)}
</div>
);
}
@@ -0,0 +1,211 @@
"use client";
import { useState } from "react";
import { ShieldCheck, ShieldOff, Copy, Check } from "lucide-react";
type Stage = "idle" | "setting-up" | "recovery-codes" | "disabling";
export function TwoFactorSettings({ initialEnabled }: { initialEnabled: boolean }) {
const [enabled, setEnabled] = useState(initialEnabled);
const [stage, setStage] = useState<Stage>("idle");
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [secret, setSecret] = useState<string | null>(null);
const [code, setCode] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [copied, setCopied] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
function reset() {
setStage("idle");
setQrDataUrl(null);
setSecret(null);
setCode("");
setPassword("");
setError(null);
}
async function startSetup() {
setLoading(true);
setError(null);
const res = await fetch("/api/auth/totp/setup", { method: "POST" });
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось начать настройку 2FA");
return;
}
const data = await res.json();
setQrDataUrl(data.qrDataUrl);
setSecret(data.secret);
setStage("setting-up");
}
async function confirmSetup(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/totp/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось подтвердить код");
return;
}
const data = await res.json();
setRecoveryCodes(data.recoveryCodes);
setEnabled(true);
setStage("recovery-codes");
}
async function disable2fa(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/totp/disable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось отключить 2FA");
return;
}
setEnabled(false);
reset();
}
function copyRecoveryCodes() {
if (!recoveryCodes) return;
navigator.clipboard.writeText(recoveryCodes.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (stage === "recovery-codes" && recoveryCodes) {
return (
<div className="card p-4">
<p className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-success-soft-text">
<ShieldCheck size={15} />
2FA включена
</p>
<p className="mb-3 text-sm text-text-muted">
Сохраните эти резервные коды в надёжном месте — каждый работает один раз, если телефон с приложением
потеряется. Второй раз их показать не получится.
</p>
<pre className="mb-3 whitespace-pre-wrap rounded-md border border-border bg-surface p-3 text-sm">
{recoveryCodes.join("\n")}
</pre>
<div className="flex gap-2">
<button type="button" onClick={copyRecoveryCodes} className="btn btn-ghost">
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Скопировано" : "Скопировать"}
</button>
<button type="button" onClick={reset} className="btn btn-primary flex-1 justify-center">
Готово
</button>
</div>
</div>
);
}
if (stage === "setting-up") {
return (
<form onSubmit={confirmSetup} className="card p-4">
<p className="mb-3 text-sm font-semibold">Настройка 2FA</p>
<p className="mb-3 text-sm text-text-muted">
Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Authy и т.п.) и введите код, чтобы
подтвердить.
</p>
{qrDataUrl && (
// eslint-disable-next-line @next/next/no-img-element -- a locally generated data: URI QR code, not worth next/image config
<img src={qrDataUrl} alt="QR-код для настройки 2FA" className="mb-3 h-40 w-40" />
)}
{secret && (
<p className="mb-3 break-all rounded-md border border-border bg-surface px-2 py-1.5 font-mono text-xs text-text-muted">
{secret}
</p>
)}
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Код из приложения</span>
<input
required
inputMode="numeric"
pattern="\d{6}"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
autoFocus
/>
</label>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={reset} className="btn btn-ghost">
Отмена
</button>
<button type="submit" disabled={loading} className="btn btn-primary flex-1 justify-center">
Подтвердить
</button>
</div>
</form>
);
}
if (stage === "disabling") {
return (
<form onSubmit={disable2fa} className="card p-4">
<p className="mb-3 text-sm font-semibold">Отключить 2FA</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Текущий пароль</span>
<input
required
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
autoFocus
/>
</label>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={reset} className="btn btn-ghost">
Отмена
</button>
<button type="submit" disabled={loading} className="btn bg-danger text-white hover:brightness-95 flex-1 justify-center">
Отключить
</button>
</div>
</form>
);
}
return (
<div className="card p-4">
<p className="mb-1 flex items-center gap-1.5 text-sm font-semibold">
{enabled ? <ShieldCheck size={15} className="text-success-soft-text" /> : <ShieldOff size={15} className="text-text-faint" />}
Двухфакторная аутентификация
</p>
<p className="mb-3 text-sm text-text-muted">
{enabled ? "Включена — при входе потребуется код из приложения." : "Не включена."}
</p>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{enabled ? (
<button type="button" onClick={() => setStage("disabling")} className="btn btn-ghost w-full justify-center">
Отключить
</button>
) : (
<button type="button" onClick={startSetup} disabled={loading} className="btn btn-primary w-full justify-center">
Включить
</button>
)}
</div>
);
}
+79 -1
View File
@@ -3,12 +3,14 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { Ticket, LogIn } from "lucide-react";
import { Ticket, LogIn, ShieldCheck } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpRequired, setTotpRequired] = useState(false);
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -30,6 +32,36 @@ export default function LoginPage() {
return;
}
const data = await res.json();
setLoading(false);
if (data.totpRequired) {
setTotpRequired(true);
return;
}
router.push("/dashboard");
router.refresh();
}
async function handleVerifyTotp(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/verify-totp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось войти");
setLoading(false);
return;
}
router.push("/dashboard");
router.refresh();
}
@@ -41,6 +73,51 @@ export default function LoginPage() {
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
}}
>
{totpRequired ? (
<motion.form
onSubmit={handleVerifyTotp}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="card w-full max-w-sm p-8"
>
<div className="mb-6 flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
<ShieldCheck size={18} strokeWidth={2.5} />
</div>
<span className="font-display text-lg font-bold tracking-tight">top-tickets</span>
</div>
<h1 className="mb-1 font-display text-xl font-semibold">Двухфакторная аутентификация</h1>
<p className="mb-6 text-sm text-text-muted">Введите код из приложения-аутентификатора или резервный код.</p>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Код</span>
<input
required
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
{error && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
>
{error}
</motion.p>
)}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<LogIn size={16} />
{loading ? "Проверяем…" : "Войти"}
</button>
</motion.form>
) : (
<motion.form
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12 }}
@@ -96,6 +173,7 @@ export default function LoginPage() {
{loading ? "Входим…" : "Войти"}
</button>
</motion.form>
)}
</main>
);
}
+9
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { createLoginChallenge, setLoginChallengeCookie } from "@/lib/auth/login-challenge";
import { authenticateLdapUser } from "@/lib/ldap/client";
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
import { getLdapSettings } from "@/lib/auth/ldap-config";
@@ -78,6 +79,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
// 2FA only ever applies to local password accounts (see api/auth/totp/setup) —
// an LDAP login never reaches this branch at all, it returns above.
if (user.totpEnabled) {
const challengeToken = await createLoginChallenge(user.id);
await setLoginChallengeCookie(challengeToken);
return NextResponse.json({ ok: true, totpRequired: true });
}
const token = await createSession(user.id);
await setSessionCookie(token);
+41
View File
@@ -0,0 +1,41 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { requireSession } from "@/lib/auth/require";
import { db } from "@/lib/db/client";
import { users, totpRecoveryCodes } from "@/lib/db/schema";
import { decryptTotpSecret, verifyTotpCode, generateRecoveryCodes, hashRecoveryCode } from "@/lib/auth/totp";
const schema = z.object({ code: z.string().min(6).max(6) });
/** Flips totpEnabled on after the user proves they can generate a live code, and issues recovery codes — shown once, never retrievable again. */
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Введите 6-значный код" }, { status: 400 });
}
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
if (!user?.totpSecret) {
return NextResponse.json({ error: "Сначала начните настройку 2FA" }, { status: 400 });
}
const ok = await verifyTotpCode(decryptTotpSecret(user.totpSecret), parsed.data.code);
if (!ok) {
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
}
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, user.id));
// Replace any codes from a previous enable/disable cycle.
await db.delete(totpRecoveryCodes).where(eq(totpRecoveryCodes.userId, user.id));
const recoveryCodes = generateRecoveryCodes();
await db.insert(totpRecoveryCodes).values(recoveryCodes.map((code) => ({ userId: user.id, codeHash: hashRecoveryCode(code) })));
return NextResponse.json({ ok: true, recoveryCodes });
}
+37
View File
@@ -0,0 +1,37 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { requireSession } from "@/lib/auth/require";
import { verifyPassword } from "@/lib/auth/password";
import { db } from "@/lib/db/client";
import { users, totpRecoveryCodes } from "@/lib/db/schema";
const schema = z.object({ password: z.string().min(1) });
/** Requires the current password (not a TOTP code) — matches change-password's confirmation pattern, and means a stolen live session alone still can't turn off 2FA. */
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
if (!user?.passwordHash) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const ok = await verifyPassword(user.passwordHash, parsed.data.password);
if (!ok) {
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
}
await db.update(users).set({ totpSecret: null, totpEnabled: false }).where(eq(users.id, user.id));
await db.delete(totpRecoveryCodes).where(eq(totpRecoveryCodes.userId, user.id));
return NextResponse.json({ ok: true });
}
+35
View File
@@ -0,0 +1,35 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { requireSession } from "@/lib/auth/require";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { generateTotpSecret, encryptTotpSecret, buildTotpQrCode } from "@/lib/auth/totp";
/**
* Starts (or restarts) 2FA setup — generates a fresh secret and stores it
* encrypted, but leaves totpEnabled false until /confirm proves the user
* actually scanned it (see users.totpEnabled comment in schema.ts). Safe to
* call again if the user abandons setup partway — it just overwrites the
* unconfirmed secret with a new one.
*/
export async function POST() {
const { session, response } = await requireSession();
if (!session) return response;
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
if (user.authSource !== "local") {
return NextResponse.json(
{ error: "2FA доступна только для локальных аккаунтов — вход через LDAP защищён на уровне домена" },
{ status: 400 },
);
}
const secret = generateTotpSecret();
await db.update(users).set({ totpSecret: encryptTotpSecret(secret), totpEnabled: false }).where(eq(users.id, user.id));
const qrDataUrl = await buildTotpQrCode(secret, user.email);
return NextResponse.json({ secret, qrDataUrl });
}
+61
View File
@@ -0,0 +1,61 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { eq, and, isNull } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { users, totpRecoveryCodes } from "@/lib/db/schema";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { consumeLoginChallengeAttempt, deleteLoginChallenge, clearLoginChallengeCookie } from "@/lib/auth/login-challenge";
import { decryptTotpSecret, verifyTotpCode, hashRecoveryCode } from "@/lib/auth/totp";
const schema = z.object({ code: z.string().min(6).max(16) });
// Second step of login for a 2FA account — reads the pending-login cookie
// set by /api/auth/login, so the client never has to carry the challenge
// token itself. Accepts either a live 6-digit TOTP code or a recovery code.
export async function POST(request: Request) {
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
// Burns one attempt regardless of whether the code below turns out to be
// right — the whole point is capping brute-force *tries*, not just wrong ones.
const challenge = await consumeLoginChallengeAttempt();
if (!challenge) {
return NextResponse.json({ error: "Сессия входа истекла — войдите заново" }, { status: 401 });
}
const user = await db.query.users.findFirst({ where: eq(users.id, challenge.userId) });
if (!user || !user.totpEnabled || !user.totpSecret) {
await deleteLoginChallenge(challenge.tokenHash);
return NextResponse.json({ error: "2FA не настроена для этого аккаунта" }, { status: 400 });
}
const code = parsed.data.code.trim();
let verified = /^\d{6}$/.test(code) && (await verifyTotpCode(decryptTotpSecret(user.totpSecret), code));
if (!verified) {
const codeHash = hashRecoveryCode(code);
const match = await db.query.totpRecoveryCodes.findFirst({
where: and(eq(totpRecoveryCodes.userId, user.id), eq(totpRecoveryCodes.codeHash, codeHash), isNull(totpRecoveryCodes.usedAt)),
});
if (match) {
await db.update(totpRecoveryCodes).set({ usedAt: new Date() }).where(eq(totpRecoveryCodes.id, match.id));
verified = true;
}
}
if (!verified) {
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
}
await deleteLoginChallenge(challenge.tokenHash);
await clearLoginChallengeCookie();
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
}
+70
View File
@@ -0,0 +1,70 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { loginChallenges } from "@/lib/db/schema";
// The "you passed the password check, now prove the TOTP code" state for a
// 2FA account — separate from the real `sessions` table (see schema.ts) so
// nothing can mistake a pending challenge for an authenticated session.
const CHALLENGE_COOKIE = "pending_login";
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
const MAX_ATTEMPTS = 6;
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function createLoginChallenge(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS);
await db.insert(loginChallenges).values({ tokenHash, userId, expiresAt });
return token;
}
export async function setLoginChallengeCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(CHALLENGE_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production" && process.env.COOKIE_ALLOW_INSECURE !== "true",
sameSite: "lax",
path: "/",
maxAge: CHALLENGE_TTL_MS / 1000,
});
}
export async function clearLoginChallengeCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(CHALLENGE_COOKIE);
}
/**
* Validates the pending-login cookie and burns one attempt against it —
* called once per verify-totp POST, before the code itself is even checked,
* so a guessing script can't rack up unlimited tries by never calling this.
* Returns null (nothing to resume) if the cookie is missing, the challenge
* doesn't exist, it's expired, or attempts are exhausted — the last two
* also delete the row so it can't be retried after the fact.
*/
export async function consumeLoginChallengeAttempt(): Promise<{ userId: string; tokenHash: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get(CHALLENGE_COOKIE)?.value;
if (!token) return null;
const tokenHash = hashToken(token);
const challenge = await db.query.loginChallenges.findFirst({ where: eq(loginChallenges.tokenHash, tokenHash) });
if (!challenge) return null;
if (challenge.expiresAt.getTime() < Date.now() || challenge.attempts >= MAX_ATTEMPTS) {
await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash));
return null;
}
await db.update(loginChallenges).set({ attempts: challenge.attempts + 1 }).where(eq(loginChallenges.tokenHash, tokenHash));
return { userId: challenge.userId, tokenHash };
}
export async function deleteLoginChallenge(tokenHash: string): Promise<void> {
await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash));
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it, beforeAll } from "vitest";
import crypto from "node:crypto";
beforeAll(() => {
// encryptTotpSecret/decryptTotpSecret delegate to lib/crypto/credentials.ts,
// which reads this lazily (only when actually encrypting/decrypting) — a
// throwaway key here keeps the test independent of .env.
process.env.CREDENTIALS_ENCRYPTION_KEY = crypto.randomBytes(32).toString("base64");
});
describe("totp", () => {
it("generates a code that verifies against its own secret", async () => {
const { generateTotpSecret, verifyTotpCode } = await import("./totp");
const { generate } = await import("otplib");
const secret = generateTotpSecret();
const code = await generate({ secret });
expect(await verifyTotpCode(secret, code)).toBe(true);
expect(await verifyTotpCode(secret, "000000")).toBe(false);
});
it("round-trips a secret through encryption", async () => {
const { generateTotpSecret, encryptTotpSecret, decryptTotpSecret } = await import("./totp");
const secret = generateTotpSecret();
const encrypted = encryptTotpSecret(secret);
expect(encrypted).not.toContain(secret);
expect(decryptTotpSecret(encrypted)).toBe(secret);
});
it("generates unique, human-typeable recovery codes and hashes deterministically", async () => {
const { generateRecoveryCodes, hashRecoveryCode } = await import("./totp");
const codes = generateRecoveryCodes(8);
expect(codes).toHaveLength(8);
expect(new Set(codes).size).toBe(8);
for (const code of codes) expect(code).toMatch(/^[0-9a-f]{5}-[0-9a-f]{5}$/);
expect(hashRecoveryCode(codes[0])).toBe(hashRecoveryCode(codes[0]));
expect(hashRecoveryCode(codes[0])).toBe(hashRecoveryCode(codes[0].toUpperCase()));
expect(hashRecoveryCode(codes[0])).not.toBe(hashRecoveryCode(codes[1]));
});
});
+43
View File
@@ -0,0 +1,43 @@
import crypto from "node:crypto";
import { generateSecret, verify, generateURI } from "otplib";
import QRCode from "qrcode";
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
const ISSUER = "top-tickets";
const RECOVERY_CODE_COUNT = 8;
export function generateTotpSecret(): string {
return generateSecret();
}
export function encryptTotpSecret(secret: string): string {
return encryptCredential(secret);
}
export function decryptTotpSecret(encrypted: string): string {
return decryptCredential(encrypted);
}
export async function buildTotpQrCode(secret: string, email: string): Promise<string> {
const uri = generateURI({ issuer: ISSUER, label: email, secret });
return QRCode.toDataURL(uri);
}
export async function verifyTotpCode(secret: string, code: string): Promise<boolean> {
const result = await verify({ secret, token: code.trim() });
return result.valid;
}
/** Human-typeable: groups of 5 lowercase hex chars, e.g. "a1b2c-d3e4f". */
function formatRecoveryCode(): string {
const raw = crypto.randomBytes(5).toString("hex");
return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
}
export function generateRecoveryCodes(count = RECOVERY_CODE_COUNT): string[] {
return Array.from({ length: count }, formatRecoveryCode);
}
export function hashRecoveryCode(code: string): string {
return crypto.createHash("sha256").update(code.trim().toLowerCase()).digest("hex");
}
@@ -0,0 +1,20 @@
CREATE TABLE `login_challenges` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`attempts` integer DEFAULT 0 NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `totp_recovery_codes` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`code_hash` text NOT NULL,
`used_at` integer,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `users` ADD `totp_secret` text;--> statement-breakpoint
ALTER TABLE `users` ADD `totp_enabled` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1787129904542,
"tag": "0009_little_natasha_romanoff",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1787213219014,
"tag": "0010_curved_jack_murdock",
"breakpoints": true
}
]
}
+42
View File
@@ -30,6 +30,13 @@ export const users = sqliteTable("users", {
authSource: text("auth_source", { enum: ["local", "ldap"] })
.notNull()
.default("local"),
// Opt-in TOTP 2FA — local accounts only (see api/auth/totp/setup). The
// secret is AES-256-GCM encrypted at rest via lib/crypto/credentials.ts,
// same as every other stored credential in this app. Set as soon as
// "setup" generates a secret, but totpEnabled stays false until the user
// proves they scanned it right by confirming one live code.
totpSecret: text("totp_secret"),
totpEnabled: integer("totp_enabled", { mode: "boolean" }).notNull().default(false),
createdAt: timestamps.createdAt,
});
@@ -44,6 +51,41 @@ export const sessions = sqliteTable("sessions", {
createdAt: timestamps.createdAt,
});
/**
* One-time recovery codes, issued when 2FA is confirmed — SHA-256 hashed
* (they're low-entropy compared to a password, but only ever checked
* against a rate-limited login-challenge attempt counter, same as a TOTP
* code guess would be). usedAt marks a code as spent, not deleted, so the
* user can see in principle how many they've burned through — nothing
* currently surfaces that, but there's no reason to throw the row away.
*/
export const totpRecoveryCodes = sqliteTable("totp_recovery_codes", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
codeHash: text("code_hash").notNull(),
usedAt: integer("used_at", { mode: "timestamp_ms" }),
createdAt: timestamps.createdAt,
});
/**
* The "you passed step 1 (password), now prove step 2 (TOTP)" state for a
* 2FA-enabled account — deliberately not the same table as `sessions`,
* since a login challenge must never be usable as a real session no matter
* what bug might otherwise conflate the two. Short TTL (5 min) and a capped
* attempt counter so it can't be brute-forced; see lib/auth/login-challenge.ts.
*/
export const loginChallenges = sqliteTable("login_challenges", {
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
attempts: integer("attempts").notNull().default(0),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: timestamps.createdAt,
});
/** People who file tickets — identified by whichever channel they came in on. */
export const customers = sqliteTable("customers", {
id: id(),