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