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

Anmeldung über einen generischen OIDC-Anbieter (Ziel authentik) mit Discovery,
PKCE, state und nonce, dazu ein lokaler Login mit argon2 als Notfallzugang. Die
Rechte stehen in einer Tabelle, die Prüfung liegt ausschließlich serverseitig,
und Rolle wie Aktiv-Status werden bei jeder Anfrage aus der Datenbank gelesen —
eine entzogene Rolle greift damit sofort, ein abgeschaltetes Konto kommt nicht
mit gültigem Cookie noch zwölf Stunden weiter.

Kein Auth.js: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und
sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose,
wie in ProxLedger. Die API von openid-client 6 wurde vorher gegen die
Typdefinitionen im Paket geprüft, nicht aus dem Gedächtnis geschrieben.

Ein unabhängiger Review hat einen Blocker und acht weitere Befunde gefunden, alle
behoben. Der wichtigste war eine offene Weiterleitung: die Prüfung des
Rücksprungziels verglich Präfixe, und "/<TAB>/fremd.example" besteht alle drei
Bedingungen. Browser entfernen Tabulator, Zeilenumbruch und Wagenrücklauf beim
Verarbeiten einer URL, übrig bleibt "//fremd.example" und damit ein fremder
Host — genau das Phishing, das die Prüfung abwehren sollte. Sie löst das Ziel
jetzt gegen eine Wegwerf-Basis auf und vergleicht die Herkunft.

Weitere Befunde: jede OIDC-Anmeldung hätte ohne konfigurierte Gruppen die Rolle
still auf LESER gesetzt, bei einem einzigen Administrator ohne Rückweg; die
Verknüpfung über die E-Mail-Adresse geschah ohne email_verified und wäre der Weg
gewesen, den lokalen Notfall-Administrator zu übernehmen; der letzte aktive
Administrator konnte sich selbst herabstufen.

Drei Fehler waren meine eigenen. Der Platzhalter-Hash gegen den Zeitkanal war
erfunden und hätte sofort geworfen statt Zeit zu kosten, die Zusage wäre also
falsch gewesen. nextUrl.origin lieferte im Test localhost statt 127.0.0.1 und
wäre hinter einem Reverse Proxy der interne Host; alle Umleitungen gehen jetzt
über APP_URL. Und overflow-x: hidden auf body hat die eigene Mobil-Prüfung blind
gemacht — auf der Benutzerseite war bei 390 px ein Knopf abgeschnitten, ohne dass
scrollWidth es zeigen konnte.

274 Tests, darunter die vollständige Rechtematrix, die Angriffe auf das
Rücksprungziel, die Trennung von Sitzungs- und Kurzzeit-Token und ein
struktureller Nachweis, dass jede Server Action mit einer Rechteprüfung beginnt.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 03:33:48 +02:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit ffb063b178
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);
});
});
});