Harden authentication, tenant isolation and security boundaries

This commit is contained in:
t.indorf
2026-10-09 22:49:55 +02:00
parent ec3f4f933d
commit 656ada80bd
71 changed files with 3107 additions and 1200 deletions
+34 -59
View File
@@ -1,26 +1,23 @@
"use server";
import { hash } from "bcryptjs";
import { Prisma, UserRole } from "@prisma/client";
import { createElement } from "react";
import { z } from "zod";
import { signIn } from "@/auth";
import { hashToken, newToken } from "@/lib/tokens";
import { limitPublicAction } from "@/lib/rate-limit";
import { sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /register · Server Action
// ---------------------------------------------------------------------
// Legt einen "heimatlosen" Admin-Account an (kitaId === null) und loggt
// ihn direkt ein. Die Tenant-Erstellung folgt im Onboarding-Wizard.
// Speichert eine kurzlebige Registrierung und versendet einen Bestätigungslink.
// Konto und Passwort entstehen erst nach dem Nachweis des Postfachbesitzes.
// =====================================================================
const registerSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
// bcrypt akzeptiert max. 72 Bytes — wir capen davor zur Sicherheit
password: z
.string()
.min(8, "Mindestens 8 Zeichen.")
.max(72, "Maximal 72 Zeichen."),
email: z.string().trim().max(254).email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase(),
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
// HTML-Checkboxen senden "on" wenn aktiviert, sonst nichts
@@ -29,12 +26,12 @@ const registerSchema = z.object({
}),
});
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type RegisterState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
message?: string;
// Echoed inputs (sans password) so failed submits don't wipe the form.
values?: {
firstName?: string;
@@ -54,9 +51,9 @@ export type RegisterState = {
function echoValues(formData: FormData): RegisterState["values"] {
return {
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
email: String(formData.get("email") ?? ""),
firstName: String(formData.get("firstName") ?? "").slice(0, 100),
lastName: String(formData.get("lastName") ?? "").slice(0, 100),
email: String(formData.get("email") ?? "").slice(0, 254),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
};
}
@@ -74,50 +71,28 @@ export async function registerAction(
errors: parsed.error.flatten().fieldErrors,
};
}
const { email, password, firstName, lastName } = parsed.data;
const passwordHash = await hash(password, 12);
const now = new Date();
try {
await prisma.user.create({
data: {
email,
passwordHash,
firstName,
lastName,
// Gründer wird "heimatloser" Admin — beim Onboarding-Abschluss
// wird die Rolle bestätigt und der User mit einer Kita verknüpft.
role: UserRole.ADMIN,
kitaId: null,
privacyPolicyAcceptedAt: now,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
emailVerifiedAt: now,
},
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return {
attempt,
values: echoValues(formData),
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
},
};
}
throw err;
const { email, firstName, lastName } = parsed.data;
const message = "Falls die Adresse registriert werden kann, erhältst du eine E-Mail. Öffne den Link, um dein Passwort zu wählen und deine Kita einzurichten. Der Link ist 30 Minuten gültig.";
if (!(await limitPublicAction("register", email))) {
return { attempt, errors: { _form: ["Zu viele Anfragen. Bitte versuche es später erneut."] } };
}
// Direkt einloggen → wirft NEXT_REDIRECT, das wir nicht abfangen.
await signIn("credentials", {
email,
password,
redirectTo: "/onboarding",
const existing = await prisma.user.findUnique({ where: { email }, select: { role: true, kitaId: true, emailVerifiedAt: true, disabledAt: true } });
if (existing && (existing.role !== "ADMIN" || existing.kitaId || existing.emailVerifiedAt || existing.disabledAt)) return { attempt, message };
const token = newToken();
await prisma.pendingRegistration.deleteMany({ where: { expiresAt: { lt: new Date() } } });
await prisma.pendingRegistration.upsert({
where: { email },
create: { email, firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) },
update: { firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) },
});
// Unreachable – signIn redirected.
return { attempt };
const base = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000";
const result = await sendAppEmail({
to: email,
subject: "E-Mail bestätigen · Kita-Planer",
react: createElement("div", null,
createElement("p", null, "Bestätige deine E-Mail-Adresse, um dein Konto einzurichten. Falls du dich nicht registriert hast, kannst du diese Nachricht ignorieren."),
createElement("a", { href: `${base}/register/verify/${token}` }, "Registrierung abschließen")),
});
if (!result.success) return { attempt, errors: { _form: ["Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut."] } };
return { attempt, message };
}