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.
94 lines
3.7 KiB
TypeScript
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."));
|
|
}
|