import type { Benutzer, Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort"; import { z } from "@/lib/validierung"; import type { Aufrufer } from "@/server/auth/zugang"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; import { protokollieren } from "@/server/services/protokoll"; /** * Benutzerverwaltung (nur Admins) und das eigene Passwort. Zwei Schutzregeln: der letzte aktive * Admin bleibt Admin und aktiv, und niemand sperrt sich selbst aus. Passwortwechsel und Sperre * beenden alle bestehenden Sitzungen (`sitzungenAb`); API-Tokens prüfen den Aktiv-Status je Anfrage. */ export type BenutzerSicht = { id: string; benutzername: string; anzeigename: string; email: string | null; rolle: "ADMIN" | "OPERATOR"; aktiv: boolean; /** Lokales Passwort gesetzt (sonst nur OIDC). */ passwort: boolean; oidc: boolean; erstelltAm: string; tokens: number; }; const Passwort = z .string() .min(PASSWORT_MINDESTLAENGE, `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`) .max(1024, "Höchstens 1024 Zeichen."); export const BenutzerAnlegen = z.object({ benutzername: z .string() .trim() .toLowerCase() .regex( /^[a-z][a-z0-9._-]{1,31}$/, "2–32 Zeichen: Kleinbuchstaben, Ziffern, Punkt, _ und -; vorne ein Buchstabe.", ), anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80), email: z .email("Keine gültige E-Mail-Adresse.") .trim() .optional() .or(z.literal("").transform(() => undefined)), rolle: z.enum(["ADMIN", "OPERATOR"]).default("OPERATOR"), passwort: Passwort, }); export const BenutzerAendern = z.object({ anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80).optional(), email: z .email("Keine gültige E-Mail-Adresse.") .trim() .nullable() .optional() .or(z.literal("").transform(() => null)), rolle: z.enum(["ADMIN", "OPERATOR"]).optional(), aktiv: z.boolean().optional(), }); const MIT_TOKENS = { _count: { select: { tokens: true } } } as const; function sicht(b: Benutzer & { _count: { tokens: number } }): BenutzerSicht { return { id: b.id, benutzername: b.benutzername, anzeigename: b.anzeigename, email: b.email, rolle: b.rolle, aktiv: b.aktiv, passwort: Boolean(b.passwortHash), oidc: Boolean(b.oidcSubjekt), erstelltAm: b.erstelltAm.toISOString(), tokens: b._count.tokens, }; } /** Auf volle Sekunden: JWT-`iat` hat Sekunden, eine im selben Augenblick neu ausgestellte Sitzung gilt. */ const jetztSekunde = () => new Date(Math.floor(Date.now() / 1000) * 1000); export async function benutzerListe(): Promise { const liste = await prisma.benutzer.findMany({ include: MIT_TOKENS, orderBy: { benutzername: "asc" } }); return liste.map(sicht); } export async function benutzerAnlegen( roh: unknown, aufrufer: Aufrufer, ip: string | null, ): Promise { const e = BenutzerAnlegen.parse(roh); try { const neu = await prisma.$transaction(async (tx) => { const b = await tx.benutzer.create({ data: { benutzername: e.benutzername, anzeigename: e.anzeigename, email: e.email ?? null, rolle: e.rolle, passwortHash: await passwortHashen(e.passwort), }, include: MIT_TOKENS, }); await protokollieren( { benutzerId: aufrufer.benutzer.id, quelle: aufrufer.quelle, aktion: "Benutzer angelegt", ziel: `benutzer:${b.benutzername}`, details: { rolle: b.rolle }, ip, }, tx, ); return b; }); return sicht(neu); } catch (fehler) { if ((fehler as { code?: string }).code === "P2002") { throw new AnwendungsFehler(409, "vergeben", `„${e.benutzername}“ gibt es schon.`, undefined, { benutzername: "Benutzername schon vergeben.", }); } throw fehler; } } async function letzterAdmin(tx: Prisma.TransactionClient, ohne: string): Promise { return (await tx.benutzer.count({ where: { rolle: "ADMIN", aktiv: true, id: { not: ohne } } })) === 0; } export async function benutzerAendern( id: string, roh: unknown, aufrufer: Aufrufer, ip: string | null, ): Promise { const e = BenutzerAendern.parse(roh); const ergebnis = await prisma.$transaction(async (tx) => { // Sperre auf die Admin-Zeilen: zwei gleichzeitige Herabstufungen dürfen nicht beide durchgehen. await tx.$queryRaw`SELECT id FROM "Benutzer" WHERE rolle = 'ADMIN' FOR UPDATE`; const alt = await tx.benutzer.findUnique({ where: { id } }); if (!alt) throw nichtGefunden("Den Benutzer"); const selbst = alt.id === aufrufer.benutzer.id; if (selbst && e.aktiv === false) { throw new AnwendungsFehler(409, "selbst", "Das eigene Konto lässt sich nicht sperren."); } const verliertAdmin = alt.rolle === "ADMIN" && alt.aktiv && (e.rolle === "OPERATOR" || e.aktiv === false); if (verliertAdmin && (await letzterAdmin(tx, alt.id))) { throw new AnwendungsFehler( 409, "letzter_admin", "Das ist der letzte aktive Admin.", "Erst einen anderen Benutzer zum Admin machen.", ); } const gesperrt = e.aktiv === false && alt.aktiv; const neu = await tx.benutzer.update({ where: { id }, data: { ...(e.anzeigename !== undefined ? { anzeigename: e.anzeigename } : {}), ...(e.email !== undefined ? { email: e.email } : {}), ...(e.rolle !== undefined ? { rolle: e.rolle } : {}), ...(e.aktiv !== undefined ? { aktiv: e.aktiv } : {}), ...(gesperrt ? { sitzungenAb: jetztSekunde() } : {}), }, include: MIT_TOKENS, }); const aenderungen: Record = {}; if (e.rolle !== undefined && e.rolle !== alt.rolle) aenderungen["rolle"] = [alt.rolle, e.rolle]; if (e.aktiv !== undefined && e.aktiv !== alt.aktiv) aenderungen["aktiv"] = [alt.aktiv, e.aktiv]; if (e.anzeigename !== undefined && e.anzeigename !== alt.anzeigename) aenderungen["anzeigename"] = true; if (e.email !== undefined && e.email !== alt.email) aenderungen["email"] = true; if (Object.keys(aenderungen).length > 0) { await protokollieren( { benutzerId: aufrufer.benutzer.id, quelle: aufrufer.quelle, aktion: gesperrt ? "Benutzer gesperrt" : e.aktiv === true && !alt.aktiv ? "Benutzer entsperrt" : "Benutzer geändert", ziel: `benutzer:${alt.benutzername}`, details: aenderungen as Prisma.InputJsonValue, ip, }, tx, ); } return neu; }); return sicht(ergebnis); } /** Admin setzt ein neues Passwort (z. B. vergessen); alle Sitzungen des Benutzers enden. */ export async function passwortZuruecksetzen( id: string, roh: unknown, aufrufer: Aufrufer, ip: string | null, ): Promise { const { passwort } = z.object({ passwort: Passwort }).parse(roh); const ziel = await prisma.benutzer.findUnique({ where: { id } }); if (!ziel) throw nichtGefunden("Den Benutzer"); const hashWert = await passwortHashen(passwort); await prisma.$transaction(async (tx) => { await tx.benutzer.update({ where: { id }, data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() }, }); await protokollieren( { benutzerId: aufrufer.benutzer.id, quelle: aufrufer.quelle, aktion: "Passwort zurückgesetzt", ziel: `benutzer:${ziel.benutzername}`, ip, }, tx, ); }); } /** Eigenes Passwort ändern – mit dem alten. Andere Sitzungen enden; die aufrufende stellt der Handler neu aus. */ export async function eigenesPasswortAendern( roh: unknown, aufrufer: Aufrufer, ip: string | null, ): Promise { const e = z .object({ alt: z.string().min(1, "Bisheriges Passwort fehlt.").max(1024), neu: Passwort }) .parse(roh); const ich = await prisma.benutzer.findUniqueOrThrow({ where: { id: aufrufer.benutzer.id } }); if (!ich.passwortHash || !(await passwortPruefen(ich.passwortHash, e.alt))) { throw new AnwendungsFehler(400, "ungueltig", "Das bisherige Passwort stimmt nicht.", undefined, { alt: "Stimmt nicht.", }); } if (e.alt === e.neu) { throw new AnwendungsFehler(400, "ungueltig", "Das neue Passwort ist das alte.", undefined, { neu: "Bitte ein anderes Passwort wählen.", }); } const hashWert = await passwortHashen(e.neu); await prisma.$transaction(async (tx) => { await tx.benutzer.update({ where: { id: ich.id }, data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() }, }); await protokollieren( { benutzerId: ich.id, quelle: aufrufer.quelle, aktion: "Eigenes Passwort geändert", ziel: `benutzer:${ich.benutzername}`, ip, }, tx, ); }); }