feat(auth): Anmeldung, Rollen und Benutzerverwaltung
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:
co-authored by
Claude Opus 5
parent
7a0f875f55
commit
ffb063b178
@@ -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