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,93 @@
|
||||
"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."));
|
||||
}
|
||||
Reference in new issue
Block a user