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")}
+
+
+
+ {copied ? : }
+ {copied ? "Скопировано" : "Скопировать"}
+
+
+ Готово
+
+
+
+ );
+ }
+
+ if (stage === "setting-up") {
+ return (
+
+ );
+ }
+
+ if (stage === "disabling") {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {enabled ? : }
+ Двухфакторная аутентификация
+
+
+ {enabled ? "Включена — при входе потребуется код из приложения." : "Не включена."}
+
+ {error &&
{error}
}
+ {enabled ? (
+
setStage("disabling")} className="btn btn-ghost w-full justify-center">
+ Отключить
+
+ ) : (
+
+ Включить
+
+ )}
+
+ );
+}
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
-
-
Вход для агентов
-
Войдите, чтобы открыть дашборд заявок.
+
Двухфакторная аутентификация
+
Введите код из приложения-аутентификатора или резервный код.
-
- Email
- setEmail(e.target.value)}
- className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
- autoFocus
- />
-
+
+ Код
+ setCode(e.target.value)}
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
+ autoFocus
+ />
+
-
- Пароль
- setPassword(e.target.value)}
- className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
- />
-
+ {error && (
+
+ {error}
+
+ )}
- {error && (
-
- {error}
-
- )}
+
+
+ {loading ? "Проверяем…" : "Войти"}
+
+
+ ) : (
+
+
-
-
- {loading ? "Входим…" : "Войти"}
-
-
+
Вход для агентов
+
Войдите, чтобы открыть дашборд заявок.
+
+
+ Email
+ setEmail(e.target.value)}
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
+ autoFocus
+ />
+
+
+
+ Пароль
+ setPassword(e.target.value)}
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
+ />
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {loading ? "Входим…" : "Войти"}
+
+
+ )}
);
}
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(),