From 4ecb0e7698abf59e0c4c3663686774ab2e0a56ff Mon Sep 17 00:00:00 2001 From: Oleg Date: Thu, 20 Aug 2026 08:25:15 +0000 Subject: [PATCH] Add opt-in TOTP 2FA for local accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u --- package-lock.json | 338 ++++- package.json | 3 + src/app/(admin)/settings/account/page.tsx | 10 + .../settings/account/two-factor-settings.tsx | 211 +++ src/app/(auth)/login/page.tsx | 178 ++- src/app/api/auth/login/route.ts | 9 + src/app/api/auth/totp/confirm/route.ts | 41 + src/app/api/auth/totp/disable/route.ts | 37 + src/app/api/auth/totp/setup/route.ts | 35 + src/app/api/auth/verify-totp/route.ts | 61 + src/lib/auth/login-challenge.ts | 70 + src/lib/auth/totp.test.ts | 41 + src/lib/auth/totp.ts | 43 + .../migrations/0010_curved_jack_murdock.sql | 20 + src/lib/db/migrations/meta/0010_snapshot.json | 1194 +++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 42 + 17 files changed, 2285 insertions(+), 55 deletions(-) create mode 100644 src/app/(admin)/settings/account/two-factor-settings.tsx create mode 100644 src/app/api/auth/totp/confirm/route.ts create mode 100644 src/app/api/auth/totp/disable/route.ts create mode 100644 src/app/api/auth/totp/setup/route.ts create mode 100644 src/app/api/auth/verify-totp/route.ts create mode 100644 src/lib/auth/login-challenge.ts create mode 100644 src/lib/auth/totp.test.ts create mode 100644 src/lib/auth/totp.ts create mode 100644 src/lib/db/migrations/0010_curved_jack_murdock.sql create mode 100644 src/lib/db/migrations/meta/0010_snapshot.json diff --git a/package-lock.json b/package-lock.json index e06fd1b..6cf994f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5ed0b67..c8a81fd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(admin)/settings/account/page.tsx b/src/app/(admin)/settings/account/page.tsx index 7301876..ef3ae98 100644 --- a/src/app/(admin)/settings/account/page.tsx +++ b/src/app/(admin)/settings/account/page.tsx @@ -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() {

Аккаунт

{session?.user.email}

+ {session?.user.authSource === "local" ? ( +
+ +
+ ) : ( +

+ Двухфакторная аутентификация недоступна для LDAP-аккаунтов — вход защищён на уровне домена. +

+ )} ); } diff --git a/src/app/(admin)/settings/account/two-factor-settings.tsx b/src/app/(admin)/settings/account/two-factor-settings.tsx new file mode 100644 index 0000000..bd38e31 --- /dev/null +++ b/src/app/(admin)/settings/account/two-factor-settings.tsx @@ -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("idle"); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [secret, setSecret] = useState(null); + const [code, setCode] = useState(""); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [copied, setCopied] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(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 ( +
+

+ + 2FA включена +

+

+ Сохраните эти резервные коды в надёжном месте — каждый работает один раз, если телефон с приложением + потеряется. Второй раз их показать не получится. +

+
+          {recoveryCodes.join("\n")}
+        
+
+ + +
+
+ ); + } + + if (stage === "setting-up") { + return ( +
+

Настройка 2FA

+

+ Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Authy и т.п.) и введите код, чтобы + подтвердить. +

+ {qrDataUrl && ( + // eslint-disable-next-line @next/next/no-img-element -- a locally generated data: URI QR code, not worth next/image config + QR-код для настройки 2FA + )} + {secret && ( +

+ {secret} +

+ )} + + {error &&

{error}

} +
+ + +
+
+ ); + } + + if (stage === "disabling") { + return ( +
+

Отключить 2FA

+ + {error &&

{error}

} +
+ + +
+
+ ); + } + + return ( +
+

+ {enabled ? : } + Двухфакторная аутентификация +

+

+ {enabled ? "Включена — при входе потребуется код из приложения." : "Не включена."} +

+ {error &&

{error}

} + {enabled ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index c67e218..85ae12f 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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(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,61 +73,107 @@ export default function LoginPage() { background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)", }} > - -
-
- + {totpRequired ? ( + +
+
+ +
+ top-tickets
- top-tickets -
-

Вход для агентов

-

Войдите, чтобы открыть дашборд заявок.

+

Двухфакторная аутентификация

+

Введите код из приложения-аутентификатора или резервный код.

- + - + {error && ( + + {error} + + )} - {error && ( - - {error} - - )} + + + ) : ( + +
+
+ +
+ top-tickets +
- -
+

Вход для агентов

+

Войдите, чтобы открыть дашборд заявок.

+ + + + + + {error && ( + + {error} + + )} + + + + )} ); } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 64ab77e..621f84d 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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); diff --git a/src/app/api/auth/totp/confirm/route.ts b/src/app/api/auth/totp/confirm/route.ts new file mode 100644 index 0000000..4f5a4c3 --- /dev/null +++ b/src/app/api/auth/totp/confirm/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/totp/disable/route.ts b/src/app/api/auth/totp/disable/route.ts new file mode 100644 index 0000000..252f7ae --- /dev/null +++ b/src/app/api/auth/totp/disable/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/totp/setup/route.ts b/src/app/api/auth/totp/setup/route.ts new file mode 100644 index 0000000..af217ad --- /dev/null +++ b/src/app/api/auth/totp/setup/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/verify-totp/route.ts b/src/app/api/auth/verify-totp/route.ts new file mode 100644 index 0000000..6fe487a --- /dev/null +++ b/src/app/api/auth/verify-totp/route.ts @@ -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 } }); +} diff --git a/src/lib/auth/login-challenge.ts b/src/lib/auth/login-challenge.ts new file mode 100644 index 0000000..1bc1b7e --- /dev/null +++ b/src/lib/auth/login-challenge.ts @@ -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 { + 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 { + 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 { + 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 { + await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash)); +} diff --git a/src/lib/auth/totp.test.ts b/src/lib/auth/totp.test.ts new file mode 100644 index 0000000..f8f5c53 --- /dev/null +++ b/src/lib/auth/totp.test.ts @@ -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])); + }); +}); diff --git a/src/lib/auth/totp.ts b/src/lib/auth/totp.ts new file mode 100644 index 0000000..bf48d2c --- /dev/null +++ b/src/lib/auth/totp.ts @@ -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 { + const uri = generateURI({ issuer: ISSUER, label: email, secret }); + return QRCode.toDataURL(uri); +} + +export async function verifyTotpCode(secret: string, code: string): Promise { + 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"); +} diff --git a/src/lib/db/migrations/0010_curved_jack_murdock.sql b/src/lib/db/migrations/0010_curved_jack_murdock.sql new file mode 100644 index 0000000..774847c --- /dev/null +++ b/src/lib/db/migrations/0010_curved_jack_murdock.sql @@ -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; \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0010_snapshot.json b/src/lib/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..8a595f0 --- /dev/null +++ b/src/lib/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,1194 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a7a6e5be-42a8-4647-9afd-584e40132feb", + "prevId": "fc9cf8d7-8d7f-44d3-80e5-80f2f705c22c", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_inline": { + "name": "is_inline", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "attachments_message_id_messages_id_fk": { + "name": "attachments_message_id_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canned_responses": { + "name": "canned_responses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customers": { + "name": "customers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "portal_token": { + "name": "portal_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "customers_telegram_chat_id_unique": { + "name": "customers_telegram_chat_id_unique", + "columns": [ + "telegram_chat_id" + ], + "isUnique": true + }, + "customers_portal_token_unique": { + "name": "customers_portal_token_unique", + "columns": [ + "portal_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ldap_config": { + "name": "ldap_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 389 + }, + "use_tls": { + "name": "use_tls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bind_dn": { + "name": "bind_dn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bind_password_enc": { + "name": "bind_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_dn": { + "name": "base_dn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_filter": { + "name": "user_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'(mail={{email}})'" + }, + "list_filter": { + "name": "list_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'(objectClass=person)'" + }, + "default_role": { + "name": "default_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_challenges": { + "name": "login_challenges", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "login_challenges_user_id_users_id_fk": { + "name": "login_challenges_user_id_users_id_fk", + "tableFrom": "login_challenges", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mailbox_config": { + "name": "mailbox_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "imap_host": { + "name": "imap_host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imap_port": { + "name": "imap_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 993 + }, + "smtp_host": { + "name": "smtp_host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 587 + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow_insecure_tls": { + "name": "allow_insecure_tls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ticket_id": { + "name": "ticket_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_type": { + "name": "author_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_ticket_id_tickets_id_fk": { + "name": "messages_ticket_id_tickets_id_fk", + "tableFrom": "messages", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": [ + "endpoint" + ], + "isUnique": true + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'accent'" + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "tags_name_unique": { + "name": "tags_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "telegram_config": { + "name": "telegram_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_token_enc": { + "name": "bot_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bot_username": { + "name": "bot_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ticket_tags": { + "name": "ticket_tags", + "columns": { + "ticket_id": { + "name": "ticket_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ticket_tags_ticket_id_tickets_id_fk": { + "name": "ticket_tags_ticket_id_tickets_id_fk", + "tableFrom": "ticket_tags", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ticket_tags_tag_id_tags_id_fk": { + "name": "ticket_tags_tag_id_tags_id_fk", + "tableFrom": "ticket_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ticket_tags_ticket_id_tag_id_pk": { + "columns": [ + "ticket_id", + "tag_id" + ], + "name": "ticket_tags_ticket_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tickets": { + "name": "tickets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "tickets_customer_id_customers_id_fk": { + "name": "tickets_customer_id_customers_id_fk", + "tableFrom": "tickets", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tickets_assignee_id_users_id_fk": { + "name": "tickets_assignee_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "totp_recovery_codes": { + "name": "totp_recovery_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "totp_recovery_codes_user_id_users_id_fk": { + "name": "totp_recovery_codes_user_id_users_id_fk", + "tableFrom": "totp_recovery_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "auth_source": { + "name": "auth_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "widget_sites": { + "name": "widget_sites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_key": { + "name": "site_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_origin": { + "name": "allowed_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "widget_sites_site_key_unique": { + "name": "widget_sites_site_key_unique", + "columns": [ + "site_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 56f7e8d..1c86166 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 7c363ca..539cef1 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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(),