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>
223 lines
7.8 KiB
TypeScript
223 lines
7.8 KiB
TypeScript
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");
|
|
});
|
|
});
|