"use server"; import { redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { createSession, hashPassword } from "@/lib/auth"; import { setTwoFactorChallengeCookie } from "@/lib/auth"; import { signTwoFactorChallenge } from "@/lib/session"; import { requestPasswordResetSchema, resetPasswordSchema } from "@/lib/validation"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { hashResetToken, createAndSendPasswordResetEmail } from "@/lib/password-reset"; import { isPasswordBreached } from "@/lib/password-breach-check"; import { sendMail } from "@/lib/mail"; import { securityAlertEmail } from "@/lib/email-templates"; import { getLocale } from "@/i18n/locale"; import { fail } from "./_shared"; const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5; const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000; const RESET_SUBMIT_ATTEMPTS_PER_WINDOW = 10; const RESET_SUBMIT_WINDOW_MS = 15 * 60 * 1000; // Mindestlaufzeit fuer requestPasswordResetAction, unabhaengig davon, ob ein // Konto existiert — siehe Kommentar dort. const MIN_RESET_REQUEST_RESPONSE_MS = 400; export async function requestPasswordResetAction(formData: FormData): Promise { const ip = await getClientIp(); if (!rateLimit(`reset-request:${ip}`, RESET_REQUEST_ATTEMPTS_PER_WINDOW, RESET_REQUEST_WINDOW_MS)) { fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut."); } const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") }); if (!parsed.success) { fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein."); } const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); // Immer derselbe Erfolgspfad UND dieselbe Mindestlaufzeit, unabhaengig // davon, ob ein Konto existiert — verraet sonst nicht nur per Seiteninhalt, // sondern auch per Antwortzeit (echter Pfad macht DB-Schreibvorgaenge + // SMTP-Versand, der andere sofort) welche E-Mail-Adressen registriert // sind. Promise.all erzwingt die Mindestlaufzeit auch dann, wenn der echte // Pfad schneller waere als MIN_RESET_REQUEST_RESPONSE_MS. const work = customer ? createAndSendPasswordResetEmail(customer) : Promise.resolve(); await Promise.all([work, new Promise((resolve) => setTimeout(resolve, MIN_RESET_REQUEST_RESPONSE_MS))]); redirect("/konto/passwort-vergessen?sent=1"); } export async function resetPasswordAction(formData: FormData): Promise { const ip = await getClientIp(); if (!rateLimit(`reset-submit:${ip}`, RESET_SUBMIT_ATTEMPTS_PER_WINDOW, RESET_SUBMIT_WINDOW_MS)) { fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut."); } const parsed = resetPasswordSchema.safeParse({ token: formData.get("token"), password: formData.get("password"), }); if (!parsed.success) { fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); } const resetToken = await prisma.passwordResetToken.findUnique({ where: { tokenHash: hashResetToken(parsed.data.token) }, }); const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date(); if (!isValid) { fail( "/konto/passwort-vergessen", "Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an." ); } const targetCustomer = await prisma.customer.findUnique({ where: { id: resetToken.customerId } }); // Ein Passwort-Reset darf eine admin-gesperrte Kundschaft nicht wieder // handlungsfaehig machen — sonst waere die Sperre per Reset-Link // eigenstaendig umgehbar. if (targetCustomer?.blockedAt) { fail( "/konto/passwort-vergessen", "Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an." ); } if (await isPasswordBreached(parsed.data.password)) { // Anders als bei fail(): der Token bleibt gueltig und wird in der // Weiterleitung mitgegeben, damit die Kundschaft direkt ein anderes // Passwort versuchen kann, statt einen komplett neuen Reset-Link // anfordern zu muessen. redirect( `/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}&error=${encodeURIComponent( "Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort." )}` ); } const passwordHash = await hashPassword(parsed.data.password); // sessionVersion wird hochgezaehlt, damit ein Passwort-Reset (z.B. weil das // Konto kompromittiert wurde) alle bestehenden Sessions ungueltig macht. // Ein erfolgreich ueber den zugesandten Link gesetztes Passwort beweist // ausserdem die Kontrolle ueber das Postfach — deckt sowohl "Passwort // vergessen" als auch admin-angelegte Konten (erste Anmeldung laeuft ueber // denselben Mechanismus) ab, ohne eine zusaetzliche Bestaetigungs-Mail zu // benoetigen. const [updatedCustomer] = await prisma.$transaction([ prisma.customer.update({ where: { id: resetToken.customerId }, data: { passwordHash, sessionVersion: { increment: 1 }, failedLoginAttempts: 0, lockedUntil: null, emailVerifiedAt: targetCustomer?.emailVerifiedAt ?? new Date(), }, }), // Alle offenen Reset-Tokens des Kontos entwerten, nicht nur das benutzte. prisma.passwordResetToken.deleteMany({ where: { customerId: resetToken.customerId } }), ]); const { subject, html, text } = await securityAlertEmail(await getLocale(), "passwordChanged"); await sendMail({ to: updatedCustomer.email, subject, html, text }); // Ein Passwort-Reset darf 2FA nicht umgehen — sonst wuerde der zweite // Faktor genau in dem Szenario ausgehebelt, fuer das er gedacht ist (Zugriff // auf das Postfach, z.B. weil das Passwort kompromittiert war). if (updatedCustomer.twoFactorEnabled) { const challengeToken = await signTwoFactorChallenge(updatedCustomer.id); await setTwoFactorChallengeCookie(challengeToken); redirect("/konto/zwei-faktor-code"); } await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion); redirect("/konto?message=" + encodeURIComponent("Dein Passwort wurde geändert.")); }