feat(benutzer): Benutzerverwaltung, Passwort ändern, Audit-Log-Ansicht

Admins verwalten Benutzer (anlegen, Rolle, sperren, Passwort setzen); jeder ändert sein Passwort; das Audit-Log ist filterbar einsehbar. Sperre und Passwortwechsel beenden ältere Sitzungen über Benutzer.sitzungenAb. Der letzte Admin und das eigene Konto sind geschützt.

Closes #015

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:49:47 +02:00
co-authored by Claude Opus 5.5
parent 7f6cf00a0e
commit 99357d59bd
21 changed files with 1292 additions and 22 deletions
+265
View File
@@ -0,0 +1,265 @@
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<BenutzerSicht[]> {
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<BenutzerSicht> {
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<boolean> {
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<BenutzerSicht> {
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<string, unknown> = {};
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<void> {
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<void> {
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,
);
});
}