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
+55 -13
View File
@@ -1,6 +1,6 @@
"use server";
import crypto from "crypto";
import { hashToken, newToken } from "@/lib/tokens";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
@@ -9,6 +9,7 @@ import { z } from "zod";
import { InviteEmail } from "@/emails/InviteEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
const INVITE_TOKEN_TTL_DAYS = 7;
@@ -115,7 +116,7 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
}
function createInviteToken() {
const token = crypto.randomUUID();
const token = newToken();
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
@@ -153,6 +154,8 @@ export async function addFamilyAction(
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
if (!parsedFamily.success) {
return { errors: parsedFamily.error.flatten().fieldErrors };
@@ -245,7 +248,7 @@ export async function addFamilyAction(
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
token: hashToken(invite.token),
expires: invite.expires,
},
});
@@ -320,6 +323,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
@@ -347,7 +351,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
where: { id: familyId, kitaId },
select: {
id: true,
users: { select: { id: true } },
users: { select: { id: true, email: true } },
children: { select: { id: true } },
},
});
@@ -356,6 +360,13 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Familie wurde nicht gefunden." };
}
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
if (needsEmail) {
const configError = getAppEmailConfigError();
if (configError) return { error: configError };
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
}
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
@@ -382,14 +393,20 @@ export async function updateFamilyAction(rawPayload: unknown) {
});
for (const parent of parents) {
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
if (!current) throw new Error("Parent assignment changed");
const emailChanged = current.email !== parent.email;
await tx.user.update({
where: { id: parent.id },
data: {
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
},
where: { id: parent.id, kitaId, familyId },
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
});
if (emailChanged) {
const invite = createInviteToken();
await tx.verificationToken.deleteMany({ where: { identifier: parent.id } });
await tx.verificationToken.create({ data: { identifier: parent.id, token: hashToken(invite.token), expires: invite.expires } });
inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl });
}
}
for (const parent of newParents) {
@@ -413,7 +430,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
token: hashToken(invite.token),
expires: invite.expires,
},
});
@@ -421,7 +438,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
for (const child of activeChildren) {
await tx.child.update({
where: { id: child.id },
where: { id: child.id, kitaId, familyId },
data: {
firstName: child.firstName,
lastName: child.lastName,
@@ -430,7 +447,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
}
for (const childId of removedChildIds) {
await tx.child.delete({ where: { id: childId } });
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
}
for (const child of newChildren) {
@@ -469,3 +486,28 @@ export async function updateFamilyAction(rawPayload: unknown) {
revalidatePath("/dashboard");
return { success: true };
}
export async function resendParentInvite(userId: string) {
const session = await requireRole([UserRole.ADMIN]);
if (typeof userId !== "string" || !userId || userId.length > 64) return { error: "Ungültige Eingabedaten." };
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
const user = await prisma.user.findFirst({ where: { id: userId, kitaId: session.user.kitaId, emailVerifiedAt: null, passwordHash: "", disabledAt: null },
select: { id: true, email: true, firstName: true, lastName: true, kita: { select: { name: true } } } });
if (!user?.kita) return { error: "Keine offene Einladung gefunden." };
const configError = getAppEmailConfigError(); if (configError) return { error: configError };
const invite = createInviteToken();
const renewed = await prisma.$transaction(async tx => {
// Lock and re-check the account before issuing a link for the selected mailbox.
// A concurrent address change/activation must invalidate this resend.
const eligible = await tx.user.updateMany({ where: { id: user.id, email: user.email, kitaId: session.user.kitaId,
emailVerifiedAt: null, passwordHash: "", disabledAt: null }, data: { sessionVersion: { increment: 1 } } });
if (eligible.count !== 1) return false;
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
await tx.verificationToken.create({ data: { identifier: user.id, token: hashToken(invite.token), expires: invite.expires } });
return true;
});
if (!renewed) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
const error = await sendInviteJobs(user.kita.name, [{ to: user.email, parentName: `${user.firstName} ${user.lastName}`, inviteUrl: invite.inviteUrl }]);
return error ? { error } : { success: true };
}