From 2e25b393649d8435125776a9c2c9cc3350c376d6 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 17 Sep 2026 15:25:33 +0200 Subject: [PATCH] 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). --- package.json | 10 ++-- pnpm-lock.yaml | 81 ++++++++++++++------------ src/lib/auth.ts | 71 ++-------------------- src/lib/auth/password.ts | 31 +++++----- src/lib/auth/twofactor-verification.ts | 62 ++++++++++++++++++++ src/lib/auth/website-login-log.ts | 24 ++++++++ 6 files changed, 157 insertions(+), 122 deletions(-) create mode 100644 src/lib/auth/twofactor-verification.ts create mode 100644 src/lib/auth/website-login-log.ts diff --git a/package.json b/package.json index 3d23dd68..0f283cab 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,9 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@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", - "@tanstack/react-query": "5.103.0", + "@tanstack/react-query": "5.103.1", "@tanstack/react-virtual": "3.14.13", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -62,10 +62,10 @@ "isomorphic-dompurify": "^4.2.0", "jpeg-js": "0.4.4", "jsonc-parser": "3.3.1", - "jszip": "3.10.1", - "lucide-react": "1.46.0", + "jszip": "3.10.2", + "lucide-react": "1.47.0", "lzma-wasm": "1.0.7", - "motion": "13.3.0", + "motion": "13.4.0", "music-metadata": "11.15.0", "mysql2": "3.24.4", "next": "16.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41d9002d..da4340d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,14 +26,14 @@ importers: specifier: 3.2.2 version: 3.2.2(react@19.3.0) '@formatjs/icu-messageformat-parser': - specifier: 3.5.17 - version: 3.5.17 + specifier: 3.5.19 + version: 3.5.19 '@hookform/resolvers': specifier: 5.9.1 version: 5.9.1(react-hook-form@7.88.0(react@19.3.0))(zod@4.6.5) '@tanstack/react-query': - specifier: 5.103.0 - version: 5.103.0(react@19.3.0) + specifier: 5.103.1 + version: 5.103.1(react@19.3.0) '@tanstack/react-virtual': specifier: 3.14.13 version: 3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -68,17 +68,17 @@ importers: specifier: 3.3.1 version: 3.3.1 jszip: - specifier: 3.10.1 - version: 3.10.1 + specifier: 3.10.2 + version: 3.10.2 lucide-react: - specifier: 1.46.0 - version: 1.46.0(react@19.3.0) + specifier: 1.47.0 + version: 1.47.0(react@19.3.0) lzma-wasm: specifier: 1.0.7 version: 1.0.7 motion: - specifier: 13.3.0 - version: 13.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 13.4.0 + version: 13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) music-metadata: specifier: 11.15.0 version: 11.15.0 @@ -770,15 +770,18 @@ packages: '@formatjs/fast-memoize@3.1.7': resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} - '@formatjs/icu-messageformat-parser@3.5.17': - resolution: {integrity: sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==} - '@formatjs/icu-messageformat-parser@3.5.18': resolution: {integrity: sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==} + '@formatjs/icu-messageformat-parser@3.5.19': + resolution: {integrity: sha512-a5PAqgd3QPUge635SxrjPLIlTErcGwm3AMyTdmZXl0IqWXTdCx9AnuioEmvEZAV2HpeFdynLPXMvHv7dXGEWWw==} + '@formatjs/icu-skeleton-parser@2.1.11': resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} + '@formatjs/icu-skeleton-parser@2.1.12': + resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==} + '@formatjs/intl-localematcher@0.8.14': resolution: {integrity: sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==} @@ -1791,11 +1794,11 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' - '@tanstack/query-core@5.103.0': - resolution: {integrity: sha512-PfafnHQHEu7mZDsSalxSW/EBxPgF77k3atIJeQbqbb1Z7DmK2mH6YLJyqrvbbDk5M6Ua/egs7LLEs04a8robxA==} + '@tanstack/query-core@5.103.1': + resolution: {integrity: sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==} - '@tanstack/react-query@5.103.0': - resolution: {integrity: sha512-Kn/cvTrNwFYpS/q1MYghMsVTSinmAKV1U2AnM3/x07PiczvC+nMoySqNpQK1jgDzVCOIHk5LlF2FhTlJKQmPAQ==} + '@tanstack/react-query@5.103.1': + resolution: {integrity: sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==} peerDependencies: react: ^18 || ^19 @@ -2492,8 +2495,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - framer-motion@13.3.0: - resolution: {integrity: sha512-nry9figMPgd/7tTy9zzGRsD+kJ9tjZ74sKJ3jFBa7j6DTBIM04T6eBgneFLXCB0151wychIxsiojPlcG4JxeoA==} + framer-motion@13.4.0: + resolution: {integrity: sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -2644,8 +2647,8 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jszip@3.10.1: - resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jszip@3.10.2: + resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} @@ -2822,8 +2825,8 @@ packages: resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - lucide-react@1.46.0: - resolution: {integrity: sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA==} + lucide-react@1.47.0: + resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2880,8 +2883,8 @@ packages: motion-utils@13.3.0: resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} - motion@13.3.0: - resolution: {integrity: sha512-WHLzq1EQCoJO9S+HSDl9WlJYUpCsSAGYPoI0R4qO9ZScx7uLhpYzpoF8iobYvG248idzkl35pAShfWftEUjsdQ==} + motion@13.4.0: + resolution: {integrity: sha512-aiNSA29N+vgV5p5cJSB5cFzx8KTuuxB99z8zGtxsaqlwKtBK6sq8YgPRpkVQLkbJMOcLDU0q/ShdT95qpBy/0g==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -4077,16 +4080,18 @@ snapshots: '@formatjs/fast-memoize@3.1.7': {} - '@formatjs/icu-messageformat-parser@3.5.17': - dependencies: - '@formatjs/icu-skeleton-parser': 2.1.11 - '@formatjs/icu-messageformat-parser@3.5.18': dependencies: '@formatjs/icu-skeleton-parser': 2.1.11 + '@formatjs/icu-messageformat-parser@3.5.19': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.12 + '@formatjs/icu-skeleton-parser@2.1.11': {} + '@formatjs/icu-skeleton-parser@2.1.12': {} + '@formatjs/intl-localematcher@0.8.14': dependencies: '@formatjs/fast-memoize': 3.1.7 @@ -4788,11 +4793,11 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.3.3 - '@tanstack/query-core@5.103.0': {} + '@tanstack/query-core@5.103.1': {} - '@tanstack/react-query@5.103.0(react@19.3.0)': + '@tanstack/react-query@5.103.1(react@19.3.0)': dependencies: - '@tanstack/query-core': 5.103.0 + '@tanstack/query-core': 5.103.1 react: 19.3.0 '@tanstack/react-virtual@3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': @@ -5377,7 +5382,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - framer-motion@13.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + framer-motion@13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: motion-dom: 13.3.0 motion-utils: 13.3.0 @@ -5439,7 +5444,7 @@ snapshots: icu-minify@4.14.5: dependencies: - '@formatjs/icu-messageformat-parser': 3.5.17 + '@formatjs/icu-messageformat-parser': 3.5.19 ieee754@1.2.1: {} @@ -5535,7 +5540,7 @@ snapshots: jsonc-parser@3.3.1: {} - jszip@3.10.1: + jszip@3.10.2: dependencies: lie: 3.3.0 pako: 1.0.11 @@ -5660,7 +5665,7 @@ snapshots: lru.min@1.1.5: {} - lucide-react@1.46.0(react@19.3.0): + lucide-react@1.47.0(react@19.3.0): dependencies: react: 19.3.0 @@ -5708,9 +5713,9 @@ snapshots: motion-utils@13.3.0: {} - motion@13.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + motion@13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - framer-motion: 13.3.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + framer-motion: 13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) tslib: 2.8.1 optionalDependencies: react: 19.3.0 diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7e5b42a7..b720accb 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -14,59 +14,12 @@ import { export { invalidateLoginCache }; -import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; -import { verifyTotp } from "@/lib/auth/totp"; -import { db, User, WebsiteLoginLogs } from "@/lib/db"; +import { verify2faChallenge } from "@/lib/auth/twofactor-verification"; +import { recordWebsiteLogin } from "@/lib/auth/website-login-log"; +import { db, User } from "@/lib/db"; import { logger } from "@/lib/logger"; import { clientIp, rateLimit } from "@/lib/rate-limit"; -async function verify2faCode(userId: number, code: string): Promise { - 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({ trustHost: true, secret: env.AUTH_SECRET, @@ -130,25 +83,11 @@ export const { handlers, signOut, auth } = NextAuth({ // even when the attacker rotates IPs or knows the password. 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. - // Best-effort — never let logging block or fail the sign-in. - 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, - }); - } + await recordWebsiteLogin(user.id, ip); const jwtVersion = await getCachedJwtVersion(user.id); return { diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 79c87745..05165b97 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -44,6 +44,21 @@ async function isHexDigestOf( 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; +}> = [ + { length: 32, hash: md5Hex }, + { length: 40, hash: sha1Hex }, + { length: 64, hash: sha256Hex }, + { length: 128, hash: sha512Hex }, +]; + export async function isMd5Of( password: string, stored: string, @@ -72,16 +87,6 @@ export async function isSha512Of( return isHexDigestOf(password, stored, 128, sha512Hex); } -const DIGEST_HEX_LENGTHS: ReadonlyArray<{ - length: number; - hash: (input: string) => Promise; -}> = [ - { 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. * md5(md5(pass)) (UberCMS/Butterfly), md5(sha1(pass)), sha1(md5(pass)), @@ -133,7 +138,7 @@ export async function isSaltedDigestOf( ): Promise { 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( 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. - for (const check of [isMd5Of, isSha1Of, isSha256Of, isSha512Of]) { - if (await check(password, stored)) { + for (const { length, hash: hashFn } of DIGEST_SCHEMES) { + if (await isHexDigestOf(password, stored, length, hashFn)) { return { valid: true, upgradedHash: await hashPassword(password) }; } } diff --git a/src/lib/auth/twofactor-verification.ts b/src/lib/auth/twofactor-verification.ts new file mode 100644 index 00000000..7e75d8f2 --- /dev/null +++ b/src/lib/auth/twofactor-verification.ts @@ -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 { + 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; +} diff --git a/src/lib/auth/website-login-log.ts b/src/lib/auth/website-login-log.ts new file mode 100644 index 00000000..8e36021f --- /dev/null +++ b/src/lib/auth/website-login-log.ts @@ -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 { + 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 }); + } +}