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>
430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|