feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user