import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { prisma } from "@/lib/db"; import { authorizePasswordReset, createSession, destroyPasswordResetAuthorization, getPasswordResetSession, } from "@/lib/auth"; const PASSWORD_HASH_KEY = "admin_password_hash"; const RESET_TOKEN_KEY = "admin_password_reset"; const SESSION_GENERATION_KEY = "auth_session_generation"; const RESET_TOKEN_LIFETIME_MS = 15 * 60 * 1000; interface ResetTokenRecord { digest: string; nonce: string; expiresAt: string; } export type LoginResult = | { ok: true } | { ok: false; status: 401 | 423; error: string }; function getLockoutDuration(failedAttempts: number): number { if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; if (failedAttempts >= 15) return 30 * 60 * 1000; if (failedAttempts >= 10) return 5 * 60 * 1000; if (failedAttempts >= 5) return 60 * 1000; return 0; } function digestToken(token: string) { return createHash("sha256").update(token, "utf8").digest(); } function parseResetTokenRecord(value: string | undefined): ResetTokenRecord | null { if (!value) return null; try { const parsed: unknown = JSON.parse(value); if ( typeof parsed === "object" && parsed !== null && "digest" in parsed && "nonce" in parsed && "expiresAt" in parsed && typeof parsed.digest === "string" && typeof parsed.nonce === "string" && typeof parsed.expiresAt === "string" ) { return parsed as ResetTokenRecord; } } catch { // A malformed reset record is treated as invalid. } return null; } async function getSessionGeneration(): Promise { const setting = await prisma.setting.findUnique({ where: { key: SESSION_GENERATION_KEY }, }); const generation = Number.parseInt(setting?.value ?? "1", 10); return Number.isSafeInteger(generation) && generation > 0 ? generation : 1; } async function getOrCreateSecurityRecord() { return prisma.authSecurity.upsert({ where: { id: 1 }, update: {}, create: { id: 1 }, }); } export async function login(password: string): Promise { const security = await getOrCreateSecurityRecord(); if (security.lockedUntil && security.lockedUntil > new Date()) { const remainingMin = Math.ceil( (security.lockedUntil.getTime() - Date.now()) / 60_000 ); return { ok: false, status: 423, error: `Account locked. Try again in ${remainingMin} minute${remainingMin === 1 ? "" : "s"}.`, }; } const passwordSetting = await prisma.setting.findUnique({ where: { key: PASSWORD_HASH_KEY }, }); const argon2 = await import("argon2"); let isValid = false; if (!passwordSetting) { await prisma.setting.create({ data: { key: PASSWORD_HASH_KEY, value: await argon2.hash(password) }, }); isValid = true; } else { try { isValid = await argon2.verify(passwordSetting.value, password); } catch { isValid = false; } } if (!isValid) { const failedAttempts = security.failedAttempts + 1; const lockoutMs = getLockoutDuration(failedAttempts); await prisma.authSecurity.update({ where: { id: 1 }, data: { failedAttempts, lockedUntil: lockoutMs ? new Date(Date.now() + lockoutMs) : null, lastAttemptAt: new Date(), }, }); return { ok: false, status: 401, error: "Invalid password" }; } await prisma.authSecurity.update({ where: { id: 1 }, data: { failedAttempts: 0, lockedUntil: null, lastAttemptAt: new Date() }, }); await createSession(await getSessionGeneration()); return { ok: true }; } export async function requestPasswordReset(): Promise { const passwordSetting = await prisma.setting.findUnique({ where: { key: PASSWORD_HASH_KEY }, }); if (!passwordSetting) return false; const token = randomBytes(24).toString("base64url"); const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString(); const record: ResetTokenRecord = { digest: digestToken(token).toString("hex"), nonce: randomUUID(), expiresAt, }; await prisma.setting.upsert({ where: { key: RESET_TOKEN_KEY }, update: { value: JSON.stringify(record) }, create: { key: RESET_TOKEN_KEY, value: JSON.stringify(record) }, }); console.info(`[password-reset] Token: ${token} (expires ${expiresAt})`); return true; } export async function verifyPasswordResetToken(token: string): Promise { const setting = await prisma.setting.findUnique({ where: { key: RESET_TOKEN_KEY } }); const record = parseResetTokenRecord(setting?.value); if (!record || new Date(record.expiresAt).getTime() <= Date.now()) return false; const submittedDigest = digestToken(token); const storedDigest = Buffer.from(record.digest, "hex"); if ( submittedDigest.length !== storedDigest.length || !timingSafeEqual(submittedDigest, storedDigest) ) { return false; } await authorizePasswordReset(record.nonce, record.expiresAt); return true; } export async function completePasswordReset(password: string): Promise { const resetSession = await getPasswordResetSession(); if (!resetSession.nonce || !resetSession.expiresAt) { await destroyPasswordResetAuthorization(); return false; } const argon2 = await import("argon2"); const passwordHash = await argon2.hash(password); const completed = await prisma.$transaction(async (transaction) => { const setting = await transaction.setting.findUnique({ where: { key: RESET_TOKEN_KEY }, }); const record = parseResetTokenRecord(setting?.value); if ( !record || resetSession.nonce !== record.nonce || resetSession.expiresAt !== record.expiresAt || new Date(record.expiresAt).getTime() <= Date.now() ) { return false; } const generationSetting = await transaction.setting.findUnique({ where: { key: SESSION_GENERATION_KEY }, }); const parsedGeneration = Number.parseInt(generationSetting?.value ?? "1", 10); const currentGeneration = Number.isSafeInteger(parsedGeneration) && parsedGeneration > 0 ? parsedGeneration : 1; await transaction.setting.upsert({ where: { key: PASSWORD_HASH_KEY }, update: { value: passwordHash }, create: { key: PASSWORD_HASH_KEY, value: passwordHash }, }); await transaction.setting.upsert({ where: { key: SESSION_GENERATION_KEY }, update: { value: String(currentGeneration + 1) }, create: { key: SESSION_GENERATION_KEY, value: String(currentGeneration + 1) }, }); await transaction.setting.delete({ where: { key: RESET_TOKEN_KEY } }); await transaction.authSecurity.upsert({ where: { id: 1 }, update: { failedAttempts: 0, lockedUntil: null, lastAttemptAt: new Date() }, create: { id: 1 }, }); return true; }); await destroyPasswordResetAuthorization(); return completed; }