"use server"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { eq } from "drizzle-orm"; import { redirect } from "next/navigation"; import { env } from "@/env"; import { hashPassword } from "@/lib/auth/password"; import { db, PasswordReset, User } from "@/lib/db"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { logServerError } from "@/lib/server-log"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { sendMail } from "@/lib/services/email"; const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour function sha256(s: string): string { return createHash("sha256").update(s).digest("hex"); } export async function requestReset(formData: FormData): Promise { const email = String(formData.get("email") ?? "") .normalize("NFC") .trim() .toLowerCase(); const ip = await clientIp(); // CAPTCHA when a provider is configured (mirrors register). const cfg = await captchaConfig(); if (cfg.provider !== "none") { const token = String(formData.get(cfg.field) ?? "").normalize("NFC"); if (!(await verifyCaptcha(token, ip))) { redirect("/forgot?error=captcha"); } } // Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse. const allowed = (await rateLimit(`reset:${ip}`, 3, 15 * 60_000)).ok; // Always respond the same way so we don't reveal which emails exist. if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { try { const [user] = await db .select({ id: User.id }) .from(User) .where(eq(User.mail, email)) .limit(1); if (user) { const token = randomBytes(32).toString("hex"); const hashed = sha256(token); const createdAt = new Date(); await db .insert(PasswordReset) .values({ email, token: hashed, createdAt }) .onDuplicateKeyUpdate({ set: { token: hashed, createdAt } }); const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`; await sendMail( email, `${env.HOTEL_NAME} — password reset`, `

Click to reset your password (valid 1 hour):

${link}

`, ); } } catch { // swallow — generic response below } } redirect("/forgot?sent=1"); } export async function resetPassword(formData: FormData): Promise { const email = String(formData.get("email") ?? "") .normalize("NFC") .trim() .toLowerCase(); const token = String(formData.get("token") ?? "") .normalize("NFC") .trim(); const password = String(formData.get("password") ?? "").normalize("NFC"); // Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force. if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) { redirect( `/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`, ); } let error: string | null = null; if (password.length < 6) error = "Password must be at least 6 characters"; if (!error) { try { const [row] = await db .select({ token: PasswordReset.token, createdAt: PasswordReset.createdAt, }) .from(PasswordReset) .where(eq(PasswordReset.email, email)) .limit(1); const fresh = row?.createdAt ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS : false; const a = Buffer.from(sha256(token), "hex"); const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length); const match = row != null && a.length === b.length && timingSafeEqual(a, b); if (!row || !fresh || !match) { error = "This reset link is invalid or has expired"; } else { const [user] = await db .select({ id: User.id }) .from(User) .where(eq(User.mail, email)) .limit(1); if (!user) { error = "Account not found"; } else { await db .update(User) .set({ password: await hashPassword(password) }) .where(eq(User.id, user.id)); await db .delete(PasswordReset) .where(eq(PasswordReset.email, email)) .catch((error) => logServerError("password.reset_delete_tokens_failed", error, { email, }), ); } } } catch { error = "Could not reset the password — try again"; } } if (error) { redirect( `/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`, ); } redirect("/login?reset=1"); }