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,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()));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user