Files
Wabenhain/src/app/konto/actions/registration.ts
T
Nick Szaunig c7927f2883 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.
2026-08-17 14:30:28 +02:00

94 lines
3.7 KiB
TypeScript

"use server";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { createSession, getCurrentCustomer, hashPassword } from "@/lib/auth";
import { registerSchema } from "@/lib/validation";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { createAndSendVerificationEmail } from "@/lib/email-verification";
import { verifyTurnstileToken } from "@/lib/turnstile";
import { isPasswordBreached } from "@/lib/password-breach-check";
import { fail } from "./_shared";
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
const VERIFY_RESEND_ATTEMPTS_PER_WINDOW = 5;
const VERIFY_RESEND_WINDOW_MS = 60 * 60 * 1000;
export async function registerAction(formData: FormData): Promise<void> {
const ip = await getClientIp();
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
}
const turnstileToken = String(formData.get("turnstileToken") ?? "");
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
const parsed = registerSchema.safeParse({
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) {
fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
if (formData.get("password") !== formData.get("passwordConfirm")) {
fail("/konto", "Die Passwörter stimmen nicht überein.");
}
if (await isPasswordBreached(parsed.data.password)) {
fail(
"/konto",
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
);
}
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (existing) {
// Absichtlich keine eindeutige "Konto existiert bereits"-Meldung und
// absichtlich derselbe bcrypt-Aufwand wie im Erfolgsfall (Zeile unten) —
// beides wuerde sonst verraten, welche E-Mail-Adressen registriert sind
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
await hashPassword(parsed.data.password);
fail(
"/konto",
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
);
}
// Rolle wird bewusst nicht aus formData gelesen — neue Konten sind immer
// KUNDE (Prisma-Default). Admin-Rechte werden ausschliesslich ueber die
// Datenbank vergeben, nie ueber ein Client-Feld.
const customer = await prisma.customer.create({
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
email: parsed.data.email,
passwordHash: await hashPassword(parsed.data.password),
},
});
await createAndSendVerificationEmail(customer);
await createSession(customer.id, customer.role, customer.sessionVersion);
redirect("/konto");
}
/** Rate-limitiert erneutes Zusenden des Bestaetigungslinks, falls die erste Mail verloren ging. */
export async function resendVerificationEmailAction(): Promise<void> {
const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an.");
if (customer.emailVerifiedAt) redirect("/konto");
const ip = await getClientIp();
if (!rateLimit(`verify-resend:${ip}:${customer.id}`, VERIFY_RESEND_ATTEMPTS_PER_WINDOW, VERIFY_RESEND_WINDOW_MS)) {
fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
}
await createAndSendVerificationEmail(customer);
redirect("/konto?message=" + encodeURIComponent("Bestätigungslink wurde erneut gesendet."));
}