Harden authentication, tenant isolation and security boundaries
This commit is contained in:
+34
-59
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user