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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -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."));
|
||||
}
|
||||
Reference in new issue
Block a user