feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-09-25 01:35:02 +00:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit 40e95dbe5f
43 changed files with 3426 additions and 56 deletions
+429
View File
@@ -0,0 +1,429 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
AnmeldeFehler,
aktivSetzen,
benutzerAusOidcUebernehmen,
lokalAnmelden,
lokalenBenutzerAnlegen,
lokalerLoginAktiv,
passwortHashen,
rolleSetzen,
} from "@/server/auth/benutzer";
import type { OidcAnspruecke } from "@/server/auth/oidc";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Anmeldung und Benutzerpflege gegen die echte Datenbank (#007).
*
* Hier haengen mehrere Akzeptanzkriterien dran: JIT-Provisioning, dass die Rolle
* bei jeder Anmeldung neu gesetzt wird, dass ein deaktiviertes Konto abgewiesen
* wird und dass Passwoerter nur als Hash in der Datenbank stehen.
*/
const UMGEBUNG = { ...process.env };
function anspruecke(ueberschreiben: Partial<OidcAnspruecke> = {}): OidcAnspruecke {
return {
sub: "anbieter-sub-1",
email: "maria.huber@firma.example",
emailBestaetigt: true,
name: "Maria Huber",
gruppen: [],
ziel: "/",
...ueberschreiben,
};
}
describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
beforeEach(async () => {
await datenbankLeeren();
process.env["AUTH_LOCAL_ENABLED"] = "true";
process.env["OIDC_GROUP_ADMIN"] = "lz-admin";
process.env["OIDC_GROUP_EDITOR"] = "lz-redakteur";
process.env["OIDC_GROUP_TECHNICIAN"] = "lz-techniker";
process.env["OIDC_GROUP_VIEWER"] = "lz-leser";
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("JIT-Provisioning", () => {
it("legt einen unbekannten Benutzer beim ersten Login an", async () => {
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] }));
expect(benutzer.email).toBe("maria.huber@firma.example");
expect(benutzer.name).toBe("Maria Huber");
expect(benutzer.rolle).toBe("TECHNIKER");
expect(benutzer.aktiv).toBe(true);
expect(benutzer.passwortHash).toBeNull();
expect(await testPrisma.benutzer.count()).toBe(1);
});
it("haelt den Vorgang im Protokoll fest", async () => {
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Benutzer", entitaetId: benutzer.id },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.aktion).toContain("angelegt");
});
it("legt beim zweiten Login kein zweites Konto an", async () => {
await benutzerAusOidcUebernehmen(anspruecke());
await benutzerAusOidcUebernehmen(anspruecke());
expect(await testPrisma.benutzer.count()).toBe(1);
});
it("erkennt den Benutzer an der Kennung wieder, auch wenn die Adresse wechselt", async () => {
// Die Adresse eines Kontos kann sich aendern, die Kennung beim Anbieter nicht.
const erst = await benutzerAusOidcUebernehmen(anspruecke());
const dann = await benutzerAusOidcUebernehmen(
anspruecke({ email: "m.huber@firma.example", name: "Maria Huber-Schmitt" }),
);
expect(dann.id).toBe(erst.id);
expect(dann.email).toBe("m.huber@firma.example");
expect(dann.name).toBe("Maria Huber-Schmitt");
expect(await testPrisma.benutzer.count()).toBe(1);
});
it("verknuepft nicht ueber eine unbestaetigte Adresse", async () => {
// Sonst waere ein Anbieter mit frei waehlbarer Adresse der Weg, den
// lokalen Notfall-Administrator zu uebernehmen.
await testPrisma.benutzer.create({
data: {
email: "maria.huber@firma.example",
name: "Maria Huber",
rolle: "ADMIN",
passwortHash: await passwortHashen("ein-langes-passwort"),
},
});
await expect(benutzerAusOidcUebernehmen(anspruecke({ emailBestaetigt: false }))).rejects.toBeInstanceOf(
AnmeldeFehler,
);
// Das bestehende Konto bleibt unangetastet.
const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({
where: { email: "maria.huber@firma.example" },
});
expect(unveraendert.oidcSub).toBeNull();
expect(unveraendert.rolle).toBe("ADMIN");
});
it("uebernimmt ein bestehendes lokales Konto mit derselben Adresse", async () => {
const lokal = await testPrisma.benutzer.create({
data: {
email: "maria.huber@firma.example",
name: "Maria Huber",
rolle: "LESER",
passwortHash: await passwortHashen("ein-langes-passwort"),
},
});
const nachOidc = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
expect(nachOidc.id).toBe(lokal.id);
expect(nachOidc.oidcSub).toBe("anbieter-sub-1");
// Der Notfallzugang bleibt erhalten.
expect(nachOidc.passwortHash).not.toBeNull();
});
});
describe("Rolle aus den Gruppen", () => {
it("setzt die Rolle bei jeder Anmeldung neu", async () => {
const erst = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] }));
expect(erst.rolle).toBe("TECHNIKER");
const befoerdert = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
expect(befoerdert.rolle).toBe("ADMIN");
// Und wieder zurueck: eine entzogene Gruppe entzieht die Rolle.
const entzogen = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }));
expect(entzogen.rolle).toBe("LESER");
});
it("protokolliert jede Rollenaenderung aus den Gruppen", async () => {
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-techniker"] }));
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
const aenderungen = await testPrisma.protokoll.findMany({
where: { entitaetId: benutzer.id, aktion: { contains: "Rolle" } },
});
expect(aenderungen).toHaveLength(1);
expect(aenderungen[0]?.vorher).toEqual({ rolle: "TECHNIKER" });
expect(aenderungen[0]?.nachher).toEqual({ rolle: "ADMIN" });
});
it("weist ein deaktiviertes Konto ab, auch bei gueltiger Anmeldung beim Anbieter", async () => {
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
await testPrisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: false } });
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toBeInstanceOf(AnmeldeFehler);
});
it("schreibt nichts, bevor ein deaktiviertes Konto abgewiesen wird", async () => {
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
await testPrisma.benutzer.update({
where: { id: benutzer.id },
data: { aktiv: false, letzteAnmeldungAm: null },
});
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toBeInstanceOf(AnmeldeFehler);
const danach = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: benutzer.id } });
expect(danach.letzteAnmeldungAm).toBeNull();
expect(danach.rolle).toBe("ADMIN");
});
it("laesst die Rolle unangetastet, wenn keine Gruppen-Zuordnung eingerichtet ist", async () => {
// Sonst waere jede OIDC-Anmeldung eine stille Herabstufung auf LESER – bei
// einem einzigen Administrator koennte danach niemand mehr etwas richten.
const admin = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
expect(admin.rolle).toBe("ADMIN");
for (const name of [
"OIDC_GROUP_ADMIN",
"OIDC_GROUP_EDITOR",
"OIDC_GROUP_TECHNICIAN",
"OIDC_GROUP_VIEWER",
]) {
delete process.env[name];
}
const nochmal = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }));
expect(nochmal.rolle).toBe("ADMIN");
});
});
describe("Kundenbindung", () => {
it("bindet den Benutzer an die Kunden aus seinen Gruppen", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
await testPrisma.kunde.create({ data: { name: "Baeckerei Reiter", kuerzel: "REI" } });
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
const bindungen = await testPrisma.benutzerKunde.findMany({
where: { benutzerId: benutzer.id },
});
expect(bindungen).toHaveLength(1);
expect(bindungen[0]?.kundeId).toBe(mus.id);
});
it("schreibt die Bindung bei jeder Anmeldung neu", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
const rei = await testPrisma.kunde.create({
data: { name: "Baeckerei Reiter", kuerzel: "REI" },
});
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-REI"] }));
const bindungen = await testPrisma.benutzerKunde.findMany({
where: { benutzerId: benutzer.id },
});
expect(bindungen).toHaveLength(1);
expect(bindungen[0]?.kundeId).toBe(rei.id);
});
it("bindet ohne Praefix niemanden ein", async () => {
await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] }));
expect(await testPrisma.benutzerKunde.count({ where: { benutzerId: benutzer.id } })).toBe(0);
});
});
describe("Lokale Anmeldung", () => {
const PASSWORT = "ein-hinreichend-langes-passwort";
async function kontoAnlegen(email = "admin@firma.example") {
return testPrisma.benutzer.create({
data: {
email,
name: "Administrator",
rolle: "ADMIN",
passwortHash: await passwortHashen(PASSWORT),
},
});
}
it("laesst ein richtiges Passwort durch", async () => {
const konto = await kontoAnlegen();
const angemeldet = await lokalAnmelden("admin@firma.example", PASSWORT);
expect(angemeldet.id).toBe(konto.id);
});
it("merkt sich den Zeitpunkt der Anmeldung", async () => {
await kontoAnlegen();
const angemeldet = await lokalAnmelden("admin@firma.example", PASSWORT);
expect(angemeldet.letzteAnmeldungAm).toBeNull();
const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: angemeldet.id } });
expect(frisch.letzteAnmeldungAm).not.toBeNull();
});
it("nimmt die Adresse unabhaengig von Gross- und Kleinschreibung", async () => {
await kontoAnlegen();
await expect(lokalAnmelden(" Admin@Firma.Example ", PASSWORT)).resolves.toBeTruthy();
});
it("weist ein falsches Passwort ab", async () => {
await kontoAnlegen();
await expect(lokalAnmelden("admin@firma.example", "falsch")).rejects.toBeInstanceOf(AnmeldeFehler);
});
it("verraet nicht, ob es die Adresse gibt", async () => {
await kontoAnlegen();
const beiFalschemPasswort = await lokalAnmelden("admin@firma.example", "falsch").catch(
(f: Error) => f.message,
);
const beiUnbekannterAdresse = await lokalAnmelden("gibtsnicht@firma.example", "egal").catch(
(f: Error) => f.message,
);
expect(beiFalschemPasswort).toBe(beiUnbekannterAdresse);
});
it("weist ein Konto ohne Passwort ab, auch mit leerem Passwort", async () => {
await testPrisma.benutzer.create({
data: { email: "nur-oidc@firma.example", name: "Nur OIDC", oidcSub: "sub-x" },
});
await expect(lokalAnmelden("nur-oidc@firma.example", "")).rejects.toBeInstanceOf(AnmeldeFehler);
});
it("weist ein deaktiviertes Konto ab", async () => {
const konto = await kontoAnlegen();
await testPrisma.benutzer.update({ where: { id: konto.id }, data: { aktiv: false } });
await expect(lokalAnmelden("admin@firma.example", PASSWORT)).rejects.toThrow(/deaktiviert/);
});
it("ist abgeschaltet, solange AUTH_LOCAL_ENABLED nicht true ist", async () => {
await kontoAnlegen();
for (const wert of ["false", "", "1", "ja"]) {
process.env["AUTH_LOCAL_ENABLED"] = wert;
expect(lokalerLoginAktiv()).toBe(false);
await expect(lokalAnmelden("admin@firma.example", PASSWORT)).rejects.toThrow(/abgeschaltet/);
}
});
it("speichert das Passwort nur als argon2-Hash", async () => {
const konto = await kontoAnlegen();
expect(konto.passwortHash).toMatch(/^\$argon2/);
expect(konto.passwortHash).not.toContain(PASSWORT);
});
});
describe("Verwaltung", () => {
async function adminAnlegen() {
return testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
});
}
it("legt ein lokales Konto an und protokolliert es", async () => {
const admin = await adminAnlegen();
const neu = await lokalenBenutzerAnlegen(
{
email: " Neu@Firma.Example ",
name: " Neue Kollegin ",
rolle: "TECHNIKER",
passwort: "ein-langes-passwort",
},
admin.id,
);
expect(neu.email).toBe("neu@firma.example");
expect(neu.name).toBe("Neue Kollegin");
expect(neu.passwortHash).toMatch(/^\$argon2/);
const protokoll = await testPrisma.protokoll.findFirst({
where: { entitaetId: neu.id, aktion: "lokales Konto angelegt" },
});
expect(protokoll?.benutzerId).toBe(admin.id);
});
it("weist eine doppelte Adresse ab", async () => {
const admin = await adminAnlegen();
const eingabe = {
email: "doppelt@firma.example",
name: "Doppelt",
rolle: "LESER" as const,
passwort: "ein-langes-passwort",
};
await lokalenBenutzerAnlegen(eingabe, admin.id);
await expect(lokalenBenutzerAnlegen(eingabe, admin.id)).rejects.toBeInstanceOf(AnmeldeFehler);
});
it("protokolliert eine Rollenaenderung mit Vorher und Nachher", async () => {
const admin = await adminAnlegen();
const ziel = await testPrisma.benutzer.create({
data: { email: "ziel@firma.example", name: "Ziel", rolle: "LESER" },
});
await rolleSetzen(ziel.id, "REDAKTEUR", admin.id);
const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } });
expect(frisch.rolle).toBe("REDAKTEUR");
const protokoll = await testPrisma.protokoll.findFirst({
where: { entitaetId: ziel.id, aktion: "Rolle geaendert" },
});
expect(protokoll?.vorher).toEqual({ rolle: "LESER" });
expect(protokoll?.nachher).toEqual({ rolle: "REDAKTEUR" });
});
it("macht beim Deaktivieren zugleich alle laufenden Sitzungen ungueltig", async () => {
// Sonst koennte sich der Betroffene mit einem gueltigen Cookie noch bis zu
// zwoelf Stunden weiterbewegen.
const admin = await adminAnlegen();
const ziel = await testPrisma.benutzer.create({
data: { email: "ziel@firma.example", name: "Ziel", rolle: "TECHNIKER" },
});
await aktivSetzen(ziel.id, false, admin.id);
const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } });
expect(frisch.aktiv).toBe(false);
expect(frisch.sitzungenUngueltigAb).not.toBeNull();
});
it("laesst den letzten aktiven Administrator nicht herabstufen", async () => {
const admin = await adminAnlegen();
await expect(rolleSetzen(admin.id, "LESER", admin.id)).rejects.toThrow(/letzte aktive Administrator/);
const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
expect(unveraendert.rolle).toBe("ADMIN");
});
it("laesst den letzten aktiven Administrator nicht deaktivieren", async () => {
const admin = await adminAnlegen();
await expect(aktivSetzen(admin.id, false, admin.id)).rejects.toThrow(/letzte aktive Administrator/);
});
it("erlaubt die Aenderung, sobald ein zweiter Administrator da ist", async () => {
const admin = await adminAnlegen();
await testPrisma.benutzer.create({
data: { email: "zweiter@firma.example", name: "Zweiter", rolle: "ADMIN" },
});
await rolleSetzen(admin.id, "LESER", admin.id);
const danach = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
expect(danach.rolle).toBe("LESER");
});
it("zaehlt einen deaktivierten Administrator nicht als Rueckweg", async () => {
const admin = await adminAnlegen();
await testPrisma.benutzer.create({
data: { email: "inaktiv@firma.example", name: "Inaktiv", rolle: "ADMIN", aktiv: false },
});
await expect(rolleSetzen(admin.id, "LESER", admin.id)).rejects.toThrow(/letzte aktive Administrator/);
});
it("schreibt nichts, wenn sich nichts aendert", async () => {
const admin = await adminAnlegen();
const ziel = await testPrisma.benutzer.create({
data: { email: "ziel@firma.example", name: "Ziel", rolle: "LESER" },
});
await rolleSetzen(ziel.id, "LESER", admin.id);
await aktivSetzen(ziel.id, true, admin.id);
expect(await testPrisma.protokoll.count({ where: { entitaetId: ziel.id } })).toBe(0);
});
});
});