refactor(auth): single digest registry, extracted 2FA and login-log, dep bumps
- password.ts: derive plain and salted digest detection from one DIGEST_SCHEMES table instead of parallel hardcoded lists, so adding a family is one row. - auth.ts: move 2FA challenge verification into twofactor-verification.ts and the website login-log insert into website-login-log.ts, slimming the NextAuth provider to orchestration only. - deps: bump @formatjs/icu-messageformat-parser, @tanstack/react-query, jszip, lucide-react, motion (patch/minor only). @types/react stay pinned per pnpm-workspace.yaml; next-auth is already at the newest available (v5 beta).
This commit is contained in:
1 parent
5d7c9fccdc
commit
2e25b39364
6 files changed
+157
-122
No files matched your search
+5
-5
@@ -48,9 +48,9 @@
|
|||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@formatjs/icu-messageformat-parser": "3.5.17",
|
"@formatjs/icu-messageformat-parser": "3.5.19",
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@tanstack/react-query": "5.103.0",
|
"@tanstack/react-query": "5.103.1",
|
||||||
"@tanstack/react-virtual": "3.14.13",
|
"@tanstack/react-virtual": "3.14.13",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -62,10 +62,10 @@
|
|||||||
"isomorphic-dompurify": "^4.2.0",
|
"isomorphic-dompurify": "^4.2.0",
|
||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.1",
|
"jszip": "3.10.2",
|
||||||
"lucide-react": "1.46.0",
|
"lucide-react": "1.47.0",
|
||||||
"lzma-wasm": "1.0.7",
|
"lzma-wasm": "1.0.7",
|
||||||
"motion": "13.3.0",
|
"motion": "13.4.0",
|
||||||
"music-metadata": "11.15.0",
|
"music-metadata": "11.15.0",
|
||||||
"mysql2": "3.24.4",
|
"mysql2": "3.24.4",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
|
|||||||
Generated
+43
-38
@@ -26,14 +26,14 @@ importers:
|
|||||||
specifier: 3.2.2
|
specifier: 3.2.2
|
||||||
version: 3.2.2([email protected])
|
version: 3.2.2([email protected])
|
||||||
'@formatjs/icu-messageformat-parser':
|
'@formatjs/icu-messageformat-parser':
|
||||||
specifier: 3.5.17
|
specifier: 3.5.19
|
||||||
version: 3.5.17
|
version: 3.5.19
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: 5.9.1
|
specifier: 5.9.1
|
||||||
version: 5.9.1([email protected]([email protected]))([email protected])
|
version: 5.9.1([email protected]([email protected]))([email protected])
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: 5.103.0
|
specifier: 5.103.1
|
||||||
version: 5.103.0([email protected])
|
version: 5.103.1([email protected])
|
||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: 3.14.13
|
specifier: 3.14.13
|
||||||
version: 3.14.13([email protected]([email protected]))([email protected])
|
version: 3.14.13([email protected]([email protected]))([email protected])
|
||||||
@@ -68,17 +68,17 @@ importers:
|
|||||||
specifier: 3.3.1
|
specifier: 3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
jszip:
|
jszip:
|
||||||
specifier: 3.10.1
|
specifier: 3.10.2
|
||||||
version: 3.10.1
|
version: 3.10.2
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: 1.46.0
|
specifier: 1.47.0
|
||||||
version: 1.46.0([email protected])
|
version: 1.47.0([email protected])
|
||||||
lzma-wasm:
|
lzma-wasm:
|
||||||
specifier: 1.0.7
|
specifier: 1.0.7
|
||||||
version: 1.0.7
|
version: 1.0.7
|
||||||
motion:
|
motion:
|
||||||
specifier: 13.3.0
|
specifier: 13.4.0
|
||||||
version: 13.3.0([email protected]([email protected]))([email protected])
|
version: 13.4.0([email protected]([email protected]))([email protected])
|
||||||
music-metadata:
|
music-metadata:
|
||||||
specifier: 11.15.0
|
specifier: 11.15.0
|
||||||
version: 11.15.0
|
version: 11.15.0
|
||||||
@@ -770,15 +770,18 @@ packages:
|
|||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==}
|
resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==}
|
||||||
|
|
||||||
'@formatjs/[email protected]':
|
|
||||||
resolution: {integrity: sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==}
|
|
||||||
|
|
||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
resolution: {integrity: sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==}
|
resolution: {integrity: sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==}
|
||||||
|
|
||||||
|
'@formatjs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-a5PAqgd3QPUge635SxrjPLIlTErcGwm3AMyTdmZXl0IqWXTdCx9AnuioEmvEZAV2HpeFdynLPXMvHv7dXGEWWw==}
|
||||||
|
|
||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==}
|
resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==}
|
||||||
|
|
||||||
|
'@formatjs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==}
|
||||||
|
|
||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
resolution: {integrity: sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==}
|
resolution: {integrity: sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==}
|
||||||
|
|
||||||
@@ -1791,11 +1794,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
||||||
|
|
||||||
'@tanstack/[email protected].0':
|
'@tanstack/[email protected].1':
|
||||||
resolution: {integrity: sha512-PfafnHQHEu7mZDsSalxSW/EBxPgF77k3atIJeQbqbb1Z7DmK2mH6YLJyqrvbbDk5M6Ua/egs7LLEs04a8robxA==}
|
resolution: {integrity: sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==}
|
||||||
|
|
||||||
'@tanstack/[email protected].0':
|
'@tanstack/[email protected].1':
|
||||||
resolution: {integrity: sha512-Kn/cvTrNwFYpS/q1MYghMsVTSinmAKV1U2AnM3/x07PiczvC+nMoySqNpQK1jgDzVCOIHk5LlF2FhTlJKQmPAQ==}
|
resolution: {integrity: sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19
|
react: ^18 || ^19
|
||||||
|
|
||||||
@@ -2492,8 +2495,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
framer-motion@13.3.0:
|
framer-motion@13.4.0:
|
||||||
resolution: {integrity: sha512-nry9figMPgd/7tTy9zzGRsD+kJ9tjZ74sKJ3jFBa7j6DTBIM04T6eBgneFLXCB0151wychIxsiojPlcG4JxeoA==}
|
resolution: {integrity: sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0.0 || ^19.0.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^18.0.0 || ^19.0.0
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
@@ -2644,8 +2647,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||||
|
|
||||||
[email protected].1:
|
[email protected].2:
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||||
@@ -2822,8 +2825,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==}
|
resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||||
|
|
||||||
[email protected]6.0:
|
[email protected]7.0:
|
||||||
resolution: {integrity: sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA==}
|
resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
@@ -2880,8 +2883,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==}
|
resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==}
|
||||||
|
|
||||||
motion@13.3.0:
|
motion@13.4.0:
|
||||||
resolution: {integrity: sha512-WHLzq1EQCoJO9S+HSDl9WlJYUpCsSAGYPoI0R4qO9ZScx7uLhpYzpoF8iobYvG248idzkl35pAShfWftEUjsdQ==}
|
resolution: {integrity: sha512-aiNSA29N+vgV5p5cJSB5cFzx8KTuuxB99z8zGtxsaqlwKtBK6sq8YgPRpkVQLkbJMOcLDU0q/ShdT95qpBy/0g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0.0 || ^19.0.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^18.0.0 || ^19.0.0
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
@@ -4077,16 +4080,18 @@ snapshots:
|
|||||||
|
|
||||||
'@formatjs/[email protected]': {}
|
'@formatjs/[email protected]': {}
|
||||||
|
|
||||||
'@formatjs/[email protected]':
|
|
||||||
dependencies:
|
|
||||||
'@formatjs/icu-skeleton-parser': 2.1.11
|
|
||||||
|
|
||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/icu-skeleton-parser': 2.1.11
|
'@formatjs/icu-skeleton-parser': 2.1.11
|
||||||
|
|
||||||
|
'@formatjs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@formatjs/icu-skeleton-parser': 2.1.12
|
||||||
|
|
||||||
'@formatjs/[email protected]': {}
|
'@formatjs/[email protected]': {}
|
||||||
|
|
||||||
|
'@formatjs/[email protected]': {}
|
||||||
|
|
||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/fast-memoize': 3.1.7
|
'@formatjs/fast-memoize': 3.1.7
|
||||||
@@ -4788,11 +4793,11 @@ snapshots:
|
|||||||
postcss-selector-parser: 6.0.10
|
postcss-selector-parser: 6.0.10
|
||||||
tailwindcss: 4.3.3
|
tailwindcss: 4.3.3
|
||||||
|
|
||||||
'@tanstack/[email protected].0': {}
|
'@tanstack/[email protected].1': {}
|
||||||
|
|
||||||
'@tanstack/[email protected].0([email protected])':
|
'@tanstack/[email protected].1([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.103.0
|
'@tanstack/query-core': 5.103.1
|
||||||
react: 19.3.0
|
react: 19.3.0
|
||||||
|
|
||||||
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
||||||
@@ -5377,7 +5382,7 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
framer-motion@13.3.0([email protected]([email protected]))([email protected]):
|
framer-motion@13.4.0([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-dom: 13.3.0
|
motion-dom: 13.3.0
|
||||||
motion-utils: 13.3.0
|
motion-utils: 13.3.0
|
||||||
@@ -5439,7 +5444,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/icu-messageformat-parser': 3.5.17
|
'@formatjs/icu-messageformat-parser': 3.5.19
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
@@ -5535,7 +5540,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected].1:
|
[email protected].2:
|
||||||
dependencies:
|
dependencies:
|
||||||
lie: 3.3.0
|
lie: 3.3.0
|
||||||
pako: 1.0.11
|
pako: 1.0.11
|
||||||
@@ -5660,7 +5665,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]6.0([email protected]):
|
[email protected]7.0([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.3.0
|
react: 19.3.0
|
||||||
|
|
||||||
@@ -5708,9 +5713,9 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
motion@13.3.0([email protected]([email protected]))([email protected]):
|
motion@13.4.0([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
framer-motion: 13.3.0([email protected]([email protected]))([email protected])
|
framer-motion: 13.4.0([email protected]([email protected]))([email protected])
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react: 19.3.0
|
react: 19.3.0
|
||||||
|
|||||||
+5
-66
@@ -14,59 +14,12 @@ import {
|
|||||||
|
|
||||||
export { invalidateLoginCache };
|
export { invalidateLoginCache };
|
||||||
|
|
||||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
import { verify2faChallenge } from "@/lib/auth/twofactor-verification";
|
||||||
import { verifyTotp } from "@/lib/auth/totp";
|
import { recordWebsiteLogin } from "@/lib/auth/website-login-log";
|
||||||
import { db, User, WebsiteLoginLogs } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
|
||||||
const [user] = await db
|
|
||||||
.select({
|
|
||||||
twoFactorSecret: User.twoFactorSecret,
|
|
||||||
twoFactorRecoveryCodes: User.twoFactorRecoveryCodes,
|
|
||||||
})
|
|
||||||
.from(User)
|
|
||||||
.where(eq(User.id, userId))
|
|
||||||
.limit(1);
|
|
||||||
if (!user?.twoFactorSecret) return false;
|
|
||||||
|
|
||||||
// Try TOTP first
|
|
||||||
try {
|
|
||||||
const appKey = env.APP_KEY;
|
|
||||||
if (!appKey) throw new Error("APP_KEY not configured");
|
|
||||||
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
|
|
||||||
if (verifyTotp(code, secret)) return true;
|
|
||||||
} catch {
|
|
||||||
logger.warn(
|
|
||||||
"2FA TOTP verification failed, falling through to recovery codes",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try recovery codes
|
|
||||||
if (user.twoFactorRecoveryCodes) {
|
|
||||||
let codes: string[];
|
|
||||||
try {
|
|
||||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
|
||||||
} catch {
|
|
||||||
logger.warn("Failed to parse 2FA recovery codes JSON");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const idx = codes.indexOf(code);
|
|
||||||
if (idx !== -1) {
|
|
||||||
codes.splice(idx, 1);
|
|
||||||
const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
|
|
||||||
await db
|
|
||||||
.update(User)
|
|
||||||
.set({ twoFactorRecoveryCodes: remaining })
|
|
||||||
.where(eq(User.id, userId));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { handlers, signOut, auth } = NextAuth({
|
export const { handlers, signOut, auth } = NextAuth({
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
secret: env.AUTH_SECRET,
|
secret: env.AUTH_SECRET,
|
||||||
@@ -130,25 +83,11 @@ export const { handlers, signOut, auth } = NextAuth({
|
|||||||
// even when the attacker rotates IPs or knows the password.
|
// even when the attacker rotates IPs or knows the password.
|
||||||
if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null;
|
if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null;
|
||||||
|
|
||||||
if (!(await verify2faCode(user.id, code))) return null;
|
if (!(await verify2faChallenge(user.id, code))) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the successful login for the user's "session logs" page.
|
// Record the successful login for the user's "session logs" page.
|
||||||
// Best-effort — never let logging block or fail the sign-in.
|
await recordWebsiteLogin(user.id, ip);
|
||||||
try {
|
|
||||||
const { headers } = await import("next/headers");
|
|
||||||
const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null;
|
|
||||||
await db.insert(WebsiteLoginLogs).values({
|
|
||||||
userId: user.id,
|
|
||||||
ip,
|
|
||||||
userAgent: ua,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
logger.warn("Failed to record login log for user", {
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const jwtVersion = await getCachedJwtVersion(user.id);
|
const jwtVersion = await getCachedJwtVersion(user.id);
|
||||||
return {
|
return {
|
||||||
|
|||||||
+18
-13
@@ -44,6 +44,21 @@ async function isHexDigestOf(
|
|||||||
return (await hashFn(password)) === stored.toLowerCase();
|
return (await hashFn(password)) === stored.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the supported unsalted digest families. Both the
|
||||||
|
* plain single-digest checks and the salted detection below derive from here,
|
||||||
|
* so adding a family (e.g. sha384) means adding a single row.
|
||||||
|
*/
|
||||||
|
const DIGEST_SCHEMES: ReadonlyArray<{
|
||||||
|
length: number;
|
||||||
|
hash: (input: string) => Promise<string>;
|
||||||
|
}> = [
|
||||||
|
{ length: 32, hash: md5Hex },
|
||||||
|
{ length: 40, hash: sha1Hex },
|
||||||
|
{ length: 64, hash: sha256Hex },
|
||||||
|
{ length: 128, hash: sha512Hex },
|
||||||
|
];
|
||||||
|
|
||||||
export async function isMd5Of(
|
export async function isMd5Of(
|
||||||
password: string,
|
password: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
@@ -72,16 +87,6 @@ export async function isSha512Of(
|
|||||||
return isHexDigestOf(password, stored, 128, sha512Hex);
|
return isHexDigestOf(password, stored, 128, sha512Hex);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIGEST_HEX_LENGTHS: ReadonlyArray<{
|
|
||||||
length: number;
|
|
||||||
hash: (input: string) => Promise<string>;
|
|
||||||
}> = [
|
|
||||||
{ length: 32, hash: md5Hex },
|
|
||||||
{ length: 40, hash: sha1Hex },
|
|
||||||
{ length: 64, hash: sha256Hex },
|
|
||||||
{ length: 128, hash: sha512Hex },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combined/double digest conventions used by legacy CMSes and forums, e.g.
|
* Combined/double digest conventions used by legacy CMSes and forums, e.g.
|
||||||
* md5(md5(pass)) (UberCMS/Butterfly), md5(sha1(pass)), sha1(md5(pass)),
|
* md5(md5(pass)) (UberCMS/Butterfly), md5(sha1(pass)), sha1(md5(pass)),
|
||||||
@@ -133,7 +138,7 @@ export async function isSaltedDigestOf(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!SALTED_HASH_DELIMITER_RE.test(stored)) return false;
|
if (!SALTED_HASH_DELIMITER_RE.test(stored)) return false;
|
||||||
|
|
||||||
for (const { length, hash: hashFn } of DIGEST_HEX_LENGTHS) {
|
for (const { length, hash: hashFn } of DIGEST_SCHEMES) {
|
||||||
const hashFirst = stored.match(
|
const hashFirst = stored.match(
|
||||||
new RegExp(`^([a-f0-9]{${length}})[:\$@_](.{1,64})$`, "i"),
|
new RegExp(`^([a-f0-9]{${length}})[:\$@_](.{1,64})$`, "i"),
|
||||||
);
|
);
|
||||||
@@ -225,8 +230,8 @@ export async function checkLogin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Legacy digest formats (hex) — verify + auto-upgrade to bcrypt.
|
// Legacy digest formats (hex) — verify + auto-upgrade to bcrypt.
|
||||||
for (const check of [isMd5Of, isSha1Of, isSha256Of, isSha512Of]) {
|
for (const { length, hash: hashFn } of DIGEST_SCHEMES) {
|
||||||
if (await check(password, stored)) {
|
if (await isHexDigestOf(password, stored, length, hashFn)) {
|
||||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { env } from "@/env";
|
||||||
|
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||||
|
import { verifyTotp } from "@/lib/auth/totp";
|
||||||
|
import { db, User } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a 2FA challenge (TOTP or one of the remaining recovery codes). TOTP
|
||||||
|
* is tried first; on any decrypt/verify failure the recovery codes are used.
|
||||||
|
* Consumes a used recovery code by removing it from the stored array.
|
||||||
|
*/
|
||||||
|
export async function verify2faChallenge(
|
||||||
|
userId: number,
|
||||||
|
code: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [user] = await db
|
||||||
|
.select({
|
||||||
|
twoFactorSecret: User.twoFactorSecret,
|
||||||
|
twoFactorRecoveryCodes: User.twoFactorRecoveryCodes,
|
||||||
|
})
|
||||||
|
.from(User)
|
||||||
|
.where(eq(User.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (!user?.twoFactorSecret) return false;
|
||||||
|
|
||||||
|
// Try TOTP first.
|
||||||
|
try {
|
||||||
|
const appKey = env.APP_KEY;
|
||||||
|
if (!appKey) throw new Error("APP_KEY not configured");
|
||||||
|
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
|
||||||
|
if (verifyTotp(code, secret)) return true;
|
||||||
|
} catch {
|
||||||
|
logger.warn(
|
||||||
|
"2FA TOTP verification failed, falling through to recovery codes",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try recovery codes.
|
||||||
|
if (user.twoFactorRecoveryCodes) {
|
||||||
|
let codes: string[];
|
||||||
|
try {
|
||||||
|
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
||||||
|
} catch {
|
||||||
|
logger.warn("Failed to parse 2FA recovery codes JSON");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const idx = codes.indexOf(code);
|
||||||
|
if (idx !== -1) {
|
||||||
|
codes.splice(idx, 1);
|
||||||
|
const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
|
||||||
|
await db
|
||||||
|
.update(User)
|
||||||
|
.set({ twoFactorRecoveryCodes: remaining })
|
||||||
|
.where(eq(User.id, userId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { db, WebsiteLoginLogs } from "@/lib/db";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a successful login for the user's "session logs" page. Best-effort:
|
||||||
|
* always produces a real User-Agent header value, never blocks sign-in.
|
||||||
|
*/
|
||||||
|
export async function recordWebsiteLogin(
|
||||||
|
userId: number,
|
||||||
|
ip: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null;
|
||||||
|
await db.insert(WebsiteLoginLogs).values({
|
||||||
|
userId,
|
||||||
|
ip,
|
||||||
|
userAgent: ua,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
logger.warn("Failed to record login log for user", { userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user