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:
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user