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
+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);
});
});