refactor(auth): single digest registry, extracted 2FA and login-log, dep bumps
CI / check (push) Successful in 4m26s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m28s

- 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:
openhands committed 2026-09-17 15:26:25 +02:00
1 parent 5d7c9fccdc
commit 2e25b39364
6 files changed
+157 -122

No files matched your search

+5 -66
View File
@@ -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<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({
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 {
+18 -13
View File
@@ -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<string>;
}> = [
{ 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<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.
* md5(md5(pass)) (UberCMS/Butterfly), md5(sha1(pass)), sha1(md5(pass)),
@@ -133,7 +138,7 @@ export async function isSaltedDigestOf(
): Promise<boolean> {
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) };
}
}
+62
View File
@@ -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;
}
+24
View File
@@ -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 });
}
}