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