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