feat: Wabenhain Honig-Shop Anwendung

Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
"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<void> {
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<void> {
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."));
}