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
+222
View File
@@ -0,0 +1,222 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { sitzungBewerten } from "@/server/auth/sitzungspflicht";
import type { Sitzung } from "@/lib/sitzung";
import {
kundenkuerzelAusGruppen,
oidcAktiv,
oidcName,
rolleAusGruppen,
rueckrufAdresse,
} from "@/server/auth/oidc";
/**
* Die Entscheidungen rund um die Anmeldung, die ohne Datenbank und ohne Next
* pruefbar sind (#007).
*/
const UMGEBUNG = { ...process.env };
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("Ruecksprungziel", () => {
it("nimmt einen Pfad innerhalb der Anwendung an", () => {
expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q");
expect(sicheresZiel("/admin/benutzer?seite=2")).toBe("/admin/benutzer?seite=2");
});
it("weist eine Weiterleitung nach draussen ab", () => {
// Ohne diese Pruefung waere ein Anmeldelink verschickbar, der nach
// erfolgreicher Anmeldung auf einer fremden Seite landet.
for (const boesartig of [
"https://fremd.example/phishing",
"//fremd.example",
"/\\fremd.example",
"http://fremd.example",
"javascript:alert(1)",
]) {
expect(sicheresZiel(boesartig)).toBe("/");
}
});
it("faellt ohne Angabe auf die Startseite zurueck", () => {
expect(sicheresZiel(undefined)).toBe("/");
expect(sicheresZiel(null)).toBe("/");
expect(sicheresZiel("")).toBe("/");
});
});
describe("Rollen aus OIDC-Gruppen", () => {
beforeEach(() => {
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";
});
it("bildet jede Gruppe auf ihre Rolle ab", () => {
expect(rolleAusGruppen(["lz-admin"])).toBe("ADMIN");
expect(rolleAusGruppen(["lz-redakteur"])).toBe("REDAKTEUR");
expect(rolleAusGruppen(["lz-techniker"])).toBe("TECHNIKER");
expect(rolleAusGruppen(["lz-leser"])).toBe("LESER");
});
it("gibt bei mehreren Gruppen die staerkste Rolle", () => {
expect(rolleAusGruppen(["lz-leser", "lz-admin", "lz-techniker"])).toBe("ADMIN");
expect(rolleAusGruppen(["lz-techniker", "lz-redakteur"])).toBe("REDAKTEUR");
});
it("gibt ohne Treffer LESER, nie ADMIN und nie einen Fehler", () => {
expect(rolleAusGruppen([])).toBe("LESER");
expect(rolleAusGruppen(["irgendwas", "ganz-anderes"])).toBe("LESER");
});
it("achtet auf Gross- und Kleinschreibung der Gruppennamen", () => {
// Verzeichnisse unterscheiden hier, also darf die Zuordnung es auch.
expect(rolleAusGruppen(["LZ-ADMIN"])).toBe("LESER");
});
it("ordnet nichts zu, wenn die Variablen fehlen", () => {
process.env = { ...UMGEBUNG };
delete process.env["OIDC_GROUP_ADMIN"];
expect(rolleAusGruppen(["lz-admin"])).toBe("LESER");
});
});
describe("Kundenbindung aus OIDC-Gruppen", () => {
it("liefert ohne Praefix nichts, damit keine Einschraenkung entsteht", () => {
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
expect(kundenkuerzelAusGruppen(["lz-kunde-MUS"])).toEqual([]);
});
it("liest die Kuerzel hinter dem Praefix", () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
expect(kundenkuerzelAusGruppen(["lz-kunde-MUS", "lz-kunde-rei", "lz-admin"])).toEqual(["MUS", "REI"]);
});
it("verwirft Kuerzel, die nicht zur Form passen", () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
// Dieselbe Form wie die Bedingung Kunde_kuerzel_form in der Datenbank.
expect(kundenkuerzelAusGruppen(["lz-kunde-X", "lz-kunde-ZULANG7", "lz-kunde-MU_S"])).toEqual([]);
});
});
describe("OIDC-Konfiguration", () => {
it("gilt erst als eingerichtet, wenn alle drei Angaben da sind", () => {
process.env["OIDC_ISSUER"] = "https://authentik.example/application/o/laufzettel/";
process.env["OIDC_CLIENT_ID"] = "kennung";
delete process.env["OIDC_CLIENT_SECRET"];
expect(oidcAktiv()).toBe(false);
process.env["OIDC_CLIENT_SECRET"] = "geheim";
expect(oidcAktiv()).toBe(true);
});
it("leitet die Rueckruf-Adresse aus APP_URL ab, nicht aus der Anfrage", () => {
// Hinter einem Reverse Proxy traegt die Anfrage den internen Host; die beim
// Anbieter hinterlegte Adresse ist aber die oeffentliche.
process.env["APP_URL"] = "https://laufzettel.example.de";
expect(rueckrufAdresse()).toBe("https://laufzettel.example.de/api/auth/oidc/rueckruf");
});
it("wirft mit klarer Meldung, wenn APP_URL fehlt", () => {
delete process.env["APP_URL"];
expect(() => rueckrufAdresse()).toThrow(/APP_URL/);
});
it("nennt den Anbieter beim Namen, sonst neutral", () => {
process.env["OIDC_NAME"] = "authentik";
expect(oidcName()).toBe("authentik");
delete process.env["OIDC_NAME"];
expect(oidcName()).toBe("Single Sign-on");
});
});
describe("Gueltigkeit einer Sitzung", () => {
const jetzt = Math.floor(Date.now() / 1000);
const sitzung: Sitzung = {
benutzerId: "benutzer-1",
email: "techniker@firma.example",
name: "Techniker",
herkunft: "passwort",
ausgestelltAm: jetzt,
};
it("traegt bei aktivem Benutzer", () => {
expect(sitzungBewerten(sitzung, { id: "benutzer-1", aktiv: true, sitzungenUngueltigAb: null })).toEqual({
gueltig: true,
});
});
it("faellt, wenn es den Benutzer nicht mehr gibt", () => {
expect(sitzungBewerten(sitzung, null)).toEqual({ gueltig: false, grund: "unbekannt" });
});
it("faellt, wenn das Cookie auf einen anderen Benutzer zeigt", () => {
expect(sitzungBewerten(sitzung, { id: "benutzer-2", aktiv: true, sitzungenUngueltigAb: null })).toEqual({
gueltig: false,
grund: "unbekannt",
});
});
it("faellt sofort bei einem deaktivierten Konto", () => {
// Genau dafuer wird pro Anfrage aus der Datenbank gelesen: sonst liefe das
// Cookie noch bis zu zwoelf Stunden weiter.
expect(sitzungBewerten(sitzung, { id: "benutzer-1", aktiv: false, sitzungenUngueltigAb: null })).toEqual({
gueltig: false,
grund: "deaktiviert",
});
});
it("faellt, wenn die Sitzung vor dem Widerruf ausgestellt wurde", () => {
expect(
sitzungBewerten(sitzung, {
id: "benutzer-1",
aktiv: true,
sitzungenUngueltigAb: new Date((jetzt + 60) * 1000),
}),
).toEqual({ gueltig: false, grund: "widerrufen" });
});
it("traegt, wenn die Sitzung nach dem Widerruf ausgestellt wurde", () => {
expect(
sitzungBewerten(sitzung, {
id: "benutzer-1",
aktiv: true,
sitzungenUngueltigAb: new Date((jetzt - 60) * 1000),
}),
).toEqual({ gueltig: true });
});
});
describe("Oeffentliche Adressen", () => {
it("baut Umleitungen aus APP_URL, nicht aus der Anfrage", () => {
// Hinter einem Reverse Proxy traegt die Anfrage den internen Host. Eine
// daraus gebaute Umleitung schickt den Benutzer an eine Adresse, die er gar
// nicht erreicht.
process.env["APP_URL"] = "https://laufzettel.example.de";
expect(oeffentlicheAdresse("/anmelden", "http://10.0.0.7:3000").toString()).toBe(
"https://laufzettel.example.de/anmelden",
);
});
it("behaelt Abfrageparameter", () => {
process.env["APP_URL"] = "https://laufzettel.example.de";
expect(oeffentlicheAdresse("/anmelden?fehler=oidc-aus", "http://intern").search).toBe("?fehler=oidc-aus");
});
it("faellt ohne APP_URL auf die Herkunft der Anfrage zurueck", () => {
delete process.env["APP_URL"];
expect(oeffentlicheAdresse("/anmelden", "http://127.0.0.1:3100").toString()).toBe(
"http://127.0.0.1:3100/anmelden",
);
});
it("faellt auch bei unbrauchbarer APP_URL zurueck, statt stehen zu bleiben", () => {
process.env["APP_URL"] = "keine-adresse";
expect(oeffentlicheAdresse("/anmelden", "http://127.0.0.1:3100").host).toBe("127.0.0.1:3100");
});
});
+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);
});
});
});
+43 -3
View File
@@ -1,19 +1,52 @@
import { describe, expect, it } from "vitest";
import { navigationFuer } from "@/lib/navigation";
import { darf } from "@/server/auth/rollen";
import type { Rolle } from "@/generated/prisma/enums";
describe("Seitenleiste", () => {
/** Was die Seitenleiste je Betriebsmodus und Rolle zeigt (#005, #007). */
function pfadeFuer(modus: "INTERN" | "DIENSTLEISTUNG", rolle: Rolle): string[] {
return navigationFuer(modus, (recht) => darf(rolle, recht)).map((e) => e.pfad);
}
describe("Seitenleiste nach Betriebsmodus", () => {
it("zeigt im Modus Intern die Kunden-Ebene nicht", () => {
const pfade = navigationFuer("INTERN").map((e) => e.pfad);
const pfade = pfadeFuer("INTERN", "ADMIN");
expect(pfade).not.toContain("/kunden");
expect(pfade).toContain("/mitarbeiter");
});
it("zeigt im Modus Dienstleistung Kunden statt Mitarbeiter & Geräte", () => {
const pfade = navigationFuer("DIENSTLEISTUNG").map((e) => e.pfad);
const pfade = pfadeFuer("DIENSTLEISTUNG", "ADMIN");
expect(pfade).toContain("/kunden");
expect(pfade).not.toContain("/mitarbeiter");
});
});
describe("Seitenleiste nach Rolle", () => {
it("zeigt Admin nur dem Administrator", () => {
expect(pfadeFuer("INTERN", "ADMIN")).toContain("/admin/benutzer");
for (const rolle of ["REDAKTEUR", "TECHNIKER", "LESER"] as const) {
expect(pfadeFuer("INTERN", rolle), `Rolle ${rolle}`).not.toContain("/admin/benutzer");
}
});
it("zeigt einem Leser die fachlichen Bereiche, aber nichts Verwaltendes", () => {
const pfade = pfadeFuer("INTERN", "LESER");
expect(pfade).toContain("/");
expect(pfade).toContain("/laeufe");
expect(pfade).toContain("/archiv");
expect(pfade).not.toContain("/admin/benutzer");
});
it("blendet ohne bekannte Rolle alles Rechtegebundene aus", () => {
// So sieht die Huelle aus, solange niemand angemeldet ist.
const pfade = navigationFuer("INTERN", () => false).map((e) => e.pfad);
expect(pfade).not.toContain("/admin/benutzer");
});
});
describe("Aufbau der Eintraege", () => {
it("verlinkt keinen Eintrag, dessen Seite es noch nicht gibt", () => {
for (const eintrag of navigationFuer("INTERN")) {
if (!eintrag.verfuegbar) {
@@ -21,4 +54,11 @@ describe("Seitenleiste", () => {
}
}
});
it("hat keine doppelten Pfade je Modus", () => {
for (const modus of ["INTERN", "DIENSTLEISTUNG"] as const) {
const pfade = pfadeFuer(modus, "ADMIN");
expect(new Set(pfade).size).toBe(pfade.length);
}
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { Rolle } from "@/generated/prisma/enums";
import { RECHTE, ROLLEN_RANG, ROLLEN_TEXT, darf, staerksteRolle, type Recht } from "@/server/auth/rollen";
const ALLE_ROLLEN = Object.values(Rolle);
const ALLE_RECHTE = Object.keys(RECHTE) as Recht[];
/**
* Die vollstaendige Rechtematrix, absichtlich ausgeschrieben statt aus `RECHTE` berechnet:
* nur so faellt auf, wenn jemand einem `LESER` versehentlich Schreibrechte gibt (#007). Ein
* Test, der die Tabelle nur gegen sich selbst haelt, wuerde jede Aenderung mitmachen.
*/
const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true },
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false },
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
};
describe("Rechtematrix", () => {
const faelle = ALLE_RECHTE.flatMap((recht) =>
ALLE_ROLLEN.map((rolle) => [recht, rolle, MATRIX[recht][rolle]] as const),
);
it("prueft jede Rolle gegen jedes Recht", () => {
// Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sechs Rechte.
expect(ALLE_ROLLEN).toHaveLength(4);
expect(ALLE_RECHTE).toHaveLength(6);
expect(faelle).toHaveLength(24);
});
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {
expect(darf(rolle, recht)).toBe(erwartet);
});
});
describe("Rollen im Einzelnen", () => {
it("laesst LESER nur lesen", () => {
expect(darf("LESER", "lesen")).toBe(true);
for (const recht of ALLE_RECHTE.filter((r) => r !== "lesen")) {
expect(darf("LESER", recht), `LESER darf ${recht} nicht`).toBe(false);
}
});
it("gibt TECHNIKER Laeufe und Geraete, aber keine Vorlagen und keine Verwaltung", () => {
expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true);
expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true);
expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false);
expect(darf("TECHNIKER", "verwaltung")).toBe(false);
expect(darf("TECHNIKER", "archivVerwalten")).toBe(false);
});
it("gibt REDAKTEUR alles von TECHNIKER plus Vorlagen, aber keine Verwaltung", () => {
for (const recht of ALLE_RECHTE.filter((r) => darf("TECHNIKER", r))) {
expect(darf("REDAKTEUR", recht), `REDAKTEUR darf ${recht} wie ein TECHNIKER`).toBe(true);
}
expect(darf("REDAKTEUR", "vorlagenBearbeiten")).toBe(true);
expect(darf("REDAKTEUR", "verwaltung")).toBe(false);
expect(darf("REDAKTEUR", "archivVerwalten")).toBe(false);
});
it("gibt ADMIN alles", () => {
for (const recht of ALLE_RECHTE) {
expect(darf("ADMIN", recht), `ADMIN darf ${recht}`).toBe(true);
}
});
it("behaelt das Zurueckholen aus dem Archiv beim ADMIN", () => {
// Steht als eigener Test da, weil die Zusage "ein archivierter Lauf aendert sich nicht"
// daran haengt.
expect(RECHTE.archivVerwalten).toStrictEqual(["ADMIN"]);
});
});
describe("Aufbau der Tabelle", () => {
it("listet fuer jedes Recht mindestens eine Rolle", () => {
for (const recht of ALLE_RECHTE) {
expect(RECHTE[recht].length, `${recht} ohne Rolle waere fuer niemanden erreichbar`).toBeGreaterThan(0);
}
});
it("nennt in jedem Recht nur bekannte Rollen, jede hoechstens einmal", () => {
for (const recht of ALLE_RECHTE) {
const rollen = RECHTE[recht] as readonly Rolle[];
expect(new Set(rollen).size, `${recht} nennt eine Rolle doppelt`).toBe(rollen.length);
for (const rolle of rollen) {
expect(ALLE_ROLLEN).toContain(rolle);
}
}
});
it("deckt mit ROLLEN_TEXT genau die Rollen des Enums ab", () => {
expect(Object.keys(ROLLEN_TEXT).sort()).toStrictEqual([...ALLE_ROLLEN].sort());
for (const rolle of ALLE_ROLLEN) {
expect(ROLLEN_TEXT[rolle].length).toBeGreaterThan(0);
}
});
it("deckt mit ROLLEN_RANG genau die Rollen des Enums ab", () => {
expect(Object.keys(ROLLEN_RANG).sort()).toStrictEqual([...ALLE_ROLLEN].sort());
});
it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und LESER unten", () => {
const raenge = ALLE_ROLLEN.map((rolle) => ROLLEN_RANG[rolle]);
expect(new Set(raenge).size).toBe(ALLE_ROLLEN.length);
expect(Math.max(...raenge)).toBe(ROLLEN_RANG.ADMIN);
expect(Math.min(...raenge)).toBe(ROLLEN_RANG.LESER);
expect(ROLLEN_RANG.ADMIN).toBeGreaterThan(ROLLEN_RANG.REDAKTEUR);
expect(ROLLEN_RANG.REDAKTEUR).toBeGreaterThan(ROLLEN_RANG.TECHNIKER);
expect(ROLLEN_RANG.TECHNIKER).toBeGreaterThan(ROLLEN_RANG.LESER);
});
});
describe("staerksteRolle", () => {
it("gibt bei leerer Liste LESER zurueck", () => {
// Wer in keiner abgebildeten Gruppe ist, soll hineinsehen duerfen -- nicht scheitern
// und erst recht nicht ADMIN werden.
expect(staerksteRolle([])).toBe("LESER");
});
it.each(ALLE_ROLLEN)("gibt bei nur %s diese Rolle zurueck", (rolle) => {
expect(staerksteRolle([rolle])).toBe(rolle);
});
it.each([
["LESER und TECHNIKER", ["LESER", "TECHNIKER"], "TECHNIKER"],
["TECHNIKER und REDAKTEUR", ["TECHNIKER", "REDAKTEUR"], "REDAKTEUR"],
["REDAKTEUR und ADMIN", ["REDAKTEUR", "ADMIN"], "ADMIN"],
["alle vier", ["LESER", "TECHNIKER", "REDAKTEUR", "ADMIN"], "ADMIN"],
["ADMIN zuerst genannt", ["ADMIN", "LESER"], "ADMIN"],
["LESER zuerst genannt", ["LESER", "ADMIN"], "ADMIN"],
["dieselbe Rolle mehrfach", ["TECHNIKER", "TECHNIKER"], "TECHNIKER"],
] as const)("waehlt aus %s die Rolle %s", (_fall, rollen, erwartet) => {
expect(staerksteRolle(rollen)).toBe(erwartet);
});
it("ist von der Reihenfolge unabhaengig", () => {
const rollen: Rolle[] = ["TECHNIKER", "ADMIN", "LESER", "REDAKTEUR"];
expect(staerksteRolle(rollen)).toBe(staerksteRolle([...rollen].reverse()));
});
});
+281
View File
@@ -0,0 +1,281 @@
import { SignJWT } from "jose";
import { afterEach, describe, expect, it } from "vitest";
import {
SITZUNG_COOKIE,
SITZUNG_LAUFZEIT_STUNDEN,
SitzungsFehler,
sitzungErzeugen,
sitzungLesen,
type Sitzung,
} from "@/lib/sitzung";
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach
// wieder her, damit die Reihenfolge der Tests nichts aendert.
const APP_SECRET_VORHER = process.env["APP_SECRET"];
function appSecretSetzen(wert: string): void {
process.env["APP_SECRET"] = wert;
}
afterEach(() => {
if (APP_SECRET_VORHER === undefined) {
delete process.env["APP_SECRET"];
} else {
process.env["APP_SECRET"] = APP_SECRET_VORHER;
}
});
const DATEN: Omit<Sitzung, "ausgestelltAm"> = {
benutzerId: "b-0815",
email: "anna.mueller@example.org",
name: "Anna Müller",
herkunft: "passwort",
};
function schluessel(quelle: string): Uint8Array {
return new TextEncoder().encode(quelle);
}
/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */
async function tokenBauen(optionen: {
aussteller?: string;
zielgruppe?: string;
ausgestelltAm?: number;
laeuftAbAm?: number;
geheimnis?: string;
inhalt?: Record<string, unknown>;
}): Promise<string> {
const jetzt = Math.floor(Date.now() / 1000);
return await new SignJWT({
email: DATEN.email,
name: DATEN.name,
herkunft: DATEN.herkunft,
...optionen.inhalt,
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(DATEN.benutzerId)
.setIssuer(optionen.aussteller ?? "laufzettel")
.setAudience(optionen.zielgruppe ?? "laufzettel")
.setIssuedAt(optionen.ausgestelltAm ?? jetzt)
.setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600)
.sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? ""));
}
/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */
function zeichenVerfaelschen(jwt: string, abschnitt: number): string {
const teile = jwt.split(".");
const teil = teile[abschnitt];
if (teil === undefined || teil.length === 0) {
throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`);
}
teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1);
return teile.join(".");
}
describe("Sitzung erzeugen und lesen", () => {
it("ist verlustfrei, auch bei Umlauten im Namen", async () => {
const jwt = await sitzungErzeugen(DATEN);
const sitzung = await sitzungLesen(jwt);
expect(sitzung).toBeDefined();
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
expect(sitzung?.email).toBe(DATEN.email);
expect(sitzung?.name).toBe("Anna Müller");
expect(sitzung?.herkunft).toBe("passwort");
});
it.each([
["Umlaute und Eszett", "Jörg Weiß-Bäcker"],
["franzoesische Akzente", "Zoé Lefèvre"],
["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"],
])("behaelt %s im Namen", async (_fall, name) => {
const jwt = await sitzungErzeugen({ ...DATEN, name });
expect((await sitzungLesen(jwt))?.name).toBe(name);
});
it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => {
const vorher = Math.floor(Date.now() / 1000);
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
const nachher = Math.floor(Date.now() / 1000);
expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher);
expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher);
});
it("uebernimmt beide Herkuenfte", async () => {
for (const herkunft of ["passwort", "oidc"] as const) {
const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft }));
expect(sitzung?.herkunft).toBe(herkunft);
}
});
it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => {
const jwt = await sitzungErzeugen(DATEN);
const [kopfTeil, inhaltTeil] = jwt.split(".");
const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as {
alg: string;
};
const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as {
iat: number;
exp: number;
iss: string;
aud: string;
};
expect(kopf.alg).toBe("HS256");
expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600);
expect(inhalt.iss).toBe("laufzettel");
expect(inhalt.aud).toBe("laufzettel");
});
it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => {
// Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier.
expect(SITZUNG_COOKIE).toBe("lz_sitzung");
expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(12);
});
it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => {
// Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer
// Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert.
const jwt = await sitzungErzeugen(DATEN);
expect(jwt.split(".")).toHaveLength(3);
});
});
describe("Sitzung lesen bei kaputtem Token", () => {
it("gibt bei undefined undefined zurueck", async () => {
await expect(sitzungLesen(undefined)).resolves.toBeUndefined();
});
it.each([
["leerer Wert", ""],
["nur Leerzeichen", " "],
["Klartext ohne Punkte", "keinTokenSondernText"],
["zu wenige Abschnitte", "aaa.bbb"],
["kein base64", "!!!.???.###"],
])("gibt bei %s undefined zurueck", async (_fall, jwt) => {
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
});
it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => {
const jwt = await sitzungErzeugen(DATEN);
// Die Signatur passt danach nicht mehr -- genau das soll auffallen.
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined();
});
it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => {
const jwt = await sitzungErzeugen(DATEN);
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined();
});
it("gibt bei verfaelschtem Kopf undefined zurueck", async () => {
const jwt = await sitzungErzeugen(DATEN);
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined();
});
it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => {
const jwt = await sitzungErzeugen(DATEN);
await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined();
await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined();
});
it("gibt bei einem Token ohne Signatur undefined zurueck", async () => {
// Der klassische alg-none-Versuch: Signatur weglassen und hoffen.
const jwt = await sitzungErzeugen(DATEN);
await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined();
});
it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => {
const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" });
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
});
it("gibt bei einem Token undefined zurueck, dessen APP_SECRET inzwischen rotiert wurde", async () => {
const jwt = await sitzungErzeugen(DATEN);
appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge");
// Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft.
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
});
});
describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => {
it("gibt bei einem abgelaufenen Token undefined zurueck", async () => {
const jetzt = Math.floor(Date.now() / 1000);
const abgelaufen = await tokenBauen({
ausgestelltAm: jetzt - 13 * 3600,
laeuftAbAm: jetzt - 3600,
});
await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined();
});
it("nimmt ein Token an, das gerade noch laeuft", async () => {
const jetzt = Math.floor(Date.now() / 1000);
const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 });
expect(await sitzungLesen(knapp)).toBeDefined();
});
it("gibt bei falschem Aussteller undefined zurueck", async () => {
// Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als
// Sitzung durchrutschen.
const fremd = await tokenBauen({ aussteller: "proxledger" });
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
});
it("gibt bei falscher Zielgruppe undefined zurueck", async () => {
const fremd = await tokenBauen({ zielgruppe: "einladung" });
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
});
it.each([
["fehlende E-Mail", { email: undefined }],
["fehlender Name", { name: undefined }],
["fehlende Herkunft", { herkunft: undefined }],
["unbekannte Herkunft", { herkunft: "ldap" }],
["Name als Zahl", { name: 42 }],
])("gibt bei %s undefined zurueck", async (_fall, inhalt) => {
const luecke = await tokenBauen({ inhalt });
await expect(sitzungLesen(luecke)).resolves.toBeUndefined();
});
});
describe("APP_SECRET pruefen", () => {
it.each([
["fehlendes", ""],
["viel zu kurzes", "kurz"],
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
])("wirft beim Erzeugen bei %s APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => {
appSecretSetzen(wert);
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler);
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/);
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/);
});
it("wirft auch beim Lesen bei zu kurzem APP_SECRET", async () => {
// Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein
// Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen.
const jwt = await sitzungErzeugen(DATEN);
appSecretSetzen("kurz");
await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler);
await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/);
});
it("nimmt genau 32 Zeichen an", async () => {
appSecretSetzen("b".repeat(32));
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
});
it("stellt den Standardwert aus setup-env wieder her", async () => {
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach
// Reihenfolge unterschiedlich ausgehen.
expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER);
expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined();
});
});
+173
View File
@@ -0,0 +1,173 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { SignJWT } from "jose";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { istOeffentlich } from "@/proxy";
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
/**
* Die Zugangswege in die Anwendung (#007).
*
* Diese Datei haelt die Zusicherungen fest, die ein unabhaengiger Review als
* Luecken benannt hat: das Ruecksprungziel gegen Steuerzeichen, die Allowlist des
* Proxys, die Trennung der beiden Token-Arten und dass wirklich jede Server
* Action mit einer Rechtepruefung beginnt.
*/
const UMGEBUNG = { ...process.env };
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("Ruecksprungziel gegen Steuerzeichen", () => {
// Browser entfernen Tabulator, Zeilenumbruch und Wagenruecklauf beim
// Verarbeiten einer URL. Aus "/<TAB>/fremd.example" wird dabei
// "//fremd.example" – also ein fremder Host. Eine Pruefung auf Praefixe
// erkennt das nicht.
const angriffe = [
"/\t/fremd.example",
"/\n//fremd.example",
"/\r/fremd.example",
"/\t\t//fremd.example",
"//fremd.example",
"/\\fremd.example",
"/..//fremd.example",
"/../..//fremd.example",
"https://fremd.example",
"//FREMD.example",
"javascript:alert(1)",
];
it.each(angriffe)("weist %j ab", (angriff) => {
const ergebnis = sicheresZiel(angriff);
expect(ergebnis).toBe("/");
});
it("gibt nie etwas zurueck, das als Adresse einen fremden Host ergaebe", () => {
for (const angriff of angriffe) {
const aufgeloest = new URL(sicheresZiel(angriff), "http://laufzettel.example");
expect(aufgeloest.origin, `bei ${JSON.stringify(angriff)}`).toBe("http://laufzettel.example");
}
});
it("laesst gewoehnliche Ziele unveraendert durch", () => {
expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q");
expect(sicheresZiel("/laeufe?filter=offen#abschnitt-2")).toBe("/laeufe?filter=offen#abschnitt-2");
});
it("laesst prozentkodierte Steuerzeichen als Pfad stehen, ohne den Host zu wechseln", () => {
// %09 wird beim Aufloesen nicht dekodiert, bleibt also ein eigenartiger Pfad
// auf der eigenen Herkunft – harmlos, im Gegensatz zum echten Tabulator.
const ergebnis = sicheresZiel("/%09/fremd.example");
expect(new URL(ergebnis, "http://laufzettel.example").origin).toBe("http://laufzettel.example");
});
});
describe("Allowlist des Proxys", () => {
it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => {
for (const pfad of [
"/anmelden",
"/api/auth/oidc/start",
"/api/auth/oidc/rueckruf",
"/api/auth/abmelden",
"/api/gesundheit",
"/manifest.webmanifest",
"/icon.svg",
]) {
expect(istOeffentlich(pfad), pfad).toBe(true);
}
});
it("laesst sich nicht durch einen angehaengten Namen austricksen", () => {
// Ein reiner startsWith-Vergleich haette /anmeldenX durchgelassen.
for (const pfad of [
"/anmeldenX",
"/anmelden-geheim",
"/api/authX",
"/api/gesundheitlich",
"/admin/benutzer",
"/",
"/laeufe",
"/t/LZ-7K3F9Q",
]) {
expect(istOeffentlich(pfad), pfad).toBe(false);
}
});
});
describe("Trennung von Sitzung und Kurzzeit-Token", () => {
it("laesst ein Kurzzeit-Token nicht als Sitzung durchgehen", async () => {
const kurz = await kurzzeitTokenErzeugen("oidc-fluss", { ziel: "/" }, 60);
expect(await sitzungLesen(kurz)).toBeUndefined();
});
it("laesst eine Sitzung nicht als Kurzzeit-Token durchgehen", async () => {
const sitzung = await sitzungErzeugen({
benutzerId: "b1",
email: "a@b.example",
name: "A",
herkunft: "passwort",
});
expect(await kurzzeitTokenLesen("oidc-fluss", sitzung)).toBeUndefined();
});
it("laesst ein Kurzzeit-Token fuer einen anderen Zweck nicht durchgehen", async () => {
const kurz = await kurzzeitTokenErzeugen("ganz-anderer-zweck", { ziel: "/" }, 60);
expect(await kurzzeitTokenLesen("oidc-fluss", kurz)).toBeUndefined();
});
it("weist ein Token ab, das mit dem rohen APP_SECRET signiert wurde", async () => {
// Das Kurzzeit-Modul leitet seinen Schluessel per scrypt ab. Wer nur das
// Geheimnis kennt, kann damit kein gueltiges Fluss-Token bauen.
const roh = new TextEncoder().encode(process.env["APP_SECRET"] ?? "");
const gefaelscht = await new SignJWT({ ziel: "/" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer("laufzettel")
.setAudience("oidc-fluss")
.setExpirationTime("60s")
.sign(roh);
expect(await kurzzeitTokenLesen("oidc-fluss", gefaelscht)).toBeUndefined();
});
});
describe("Rechtepruefung an den Einstiegspunkten", () => {
const verzeichnis = "src/server/actions";
/** Aktionen, die absichtlich ohne Anmeldung erreichbar sind. */
const OEFFENTLICHE_AKTIONEN = new Set(["anmeldenAktion"]);
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
// wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab.
const befunde: string[] = [];
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
const quelle = readFileSync(join(verzeichnis, name), "utf8");
expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true);
for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) {
const [, funktion, rumpf] = treffer;
if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue;
if (!rumpf?.includes("rechtErforderlich(")) {
befunde.push(`${name}: ${funktion} prueft kein Recht`);
}
}
}
expect(befunde).toEqual([]);
});
it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => {
const aktionen = readdirSync(verzeichnis)
.filter((n) => n.endsWith(".ts"))
.flatMap((n) => [
...readFileSync(join(verzeichnis, n), "utf8").matchAll(/export async function (\w+)/g),
])
.map((t) => t[1]);
expect(aktionen.length).toBeGreaterThanOrEqual(4);
});
});