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>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { SignJWT } from "jose";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
SITZUNG_COOKIE,
|
|
SITZUNG_LAUFZEIT_STUNDEN,
|
|
SitzungsFehler,
|
|
sitzungErzeugen,
|
|
sitzungLesen,
|
|
type Sitzung,
|
|
} from "@/lib/sitzung";
|
|
|
|
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach
|
|
// wieder her, damit die Reihenfolge der Tests nichts aendert.
|
|
const APP_SECRET_VORHER = process.env["APP_SECRET"];
|
|
|
|
function appSecretSetzen(wert: string): void {
|
|
process.env["APP_SECRET"] = wert;
|
|
}
|
|
|
|
afterEach(() => {
|
|
if (APP_SECRET_VORHER === undefined) {
|
|
delete process.env["APP_SECRET"];
|
|
} else {
|
|
process.env["APP_SECRET"] = APP_SECRET_VORHER;
|
|
}
|
|
});
|
|
|
|
const DATEN: Omit<Sitzung, "ausgestelltAm"> = {
|
|
benutzerId: "b-0815",
|
|
email: "anna.mueller@example.org",
|
|
name: "Anna Müller",
|
|
herkunft: "passwort",
|
|
};
|
|
|
|
function schluessel(quelle: string): Uint8Array {
|
|
return new TextEncoder().encode(quelle);
|
|
}
|
|
|
|
/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */
|
|
async function tokenBauen(optionen: {
|
|
aussteller?: string;
|
|
zielgruppe?: string;
|
|
ausgestelltAm?: number;
|
|
laeuftAbAm?: number;
|
|
geheimnis?: string;
|
|
inhalt?: Record<string, unknown>;
|
|
}): Promise<string> {
|
|
const jetzt = Math.floor(Date.now() / 1000);
|
|
return await new SignJWT({
|
|
email: DATEN.email,
|
|
name: DATEN.name,
|
|
herkunft: DATEN.herkunft,
|
|
...optionen.inhalt,
|
|
})
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setSubject(DATEN.benutzerId)
|
|
.setIssuer(optionen.aussteller ?? "laufzettel")
|
|
.setAudience(optionen.zielgruppe ?? "laufzettel")
|
|
.setIssuedAt(optionen.ausgestelltAm ?? jetzt)
|
|
.setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600)
|
|
.sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? ""));
|
|
}
|
|
|
|
/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */
|
|
function zeichenVerfaelschen(jwt: string, abschnitt: number): string {
|
|
const teile = jwt.split(".");
|
|
const teil = teile[abschnitt];
|
|
if (teil === undefined || teil.length === 0) {
|
|
throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`);
|
|
}
|
|
teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1);
|
|
return teile.join(".");
|
|
}
|
|
|
|
describe("Sitzung erzeugen und lesen", () => {
|
|
it("ist verlustfrei, auch bei Umlauten im Namen", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
const sitzung = await sitzungLesen(jwt);
|
|
|
|
expect(sitzung).toBeDefined();
|
|
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
|
expect(sitzung?.email).toBe(DATEN.email);
|
|
expect(sitzung?.name).toBe("Anna Müller");
|
|
expect(sitzung?.herkunft).toBe("passwort");
|
|
});
|
|
|
|
it.each([
|
|
["Umlaute und Eszett", "Jörg Weiß-Bäcker"],
|
|
["franzoesische Akzente", "Zoé Lefèvre"],
|
|
["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"],
|
|
])("behaelt %s im Namen", async (_fall, name) => {
|
|
const jwt = await sitzungErzeugen({ ...DATEN, name });
|
|
expect((await sitzungLesen(jwt))?.name).toBe(name);
|
|
});
|
|
|
|
it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => {
|
|
const vorher = Math.floor(Date.now() / 1000);
|
|
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
|
const nachher = Math.floor(Date.now() / 1000);
|
|
|
|
expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher);
|
|
expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher);
|
|
});
|
|
|
|
it("uebernimmt beide Herkuenfte", async () => {
|
|
for (const herkunft of ["passwort", "oidc"] as const) {
|
|
const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft }));
|
|
expect(sitzung?.herkunft).toBe(herkunft);
|
|
}
|
|
});
|
|
|
|
it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
const [kopfTeil, inhaltTeil] = jwt.split(".");
|
|
const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as {
|
|
alg: string;
|
|
};
|
|
const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as {
|
|
iat: number;
|
|
exp: number;
|
|
iss: string;
|
|
aud: string;
|
|
};
|
|
|
|
expect(kopf.alg).toBe("HS256");
|
|
expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600);
|
|
expect(inhalt.iss).toBe("laufzettel");
|
|
expect(inhalt.aud).toBe("laufzettel");
|
|
});
|
|
|
|
it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => {
|
|
// Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier.
|
|
expect(SITZUNG_COOKIE).toBe("lz_sitzung");
|
|
expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(12);
|
|
});
|
|
|
|
it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => {
|
|
// Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer
|
|
// Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert.
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
expect(jwt.split(".")).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("Sitzung lesen bei kaputtem Token", () => {
|
|
it("gibt bei undefined undefined zurueck", async () => {
|
|
await expect(sitzungLesen(undefined)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["leerer Wert", ""],
|
|
["nur Leerzeichen", " "],
|
|
["Klartext ohne Punkte", "keinTokenSondernText"],
|
|
["zu wenige Abschnitte", "aaa.bbb"],
|
|
["kein base64", "!!!.???.###"],
|
|
])("gibt bei %s undefined zurueck", async (_fall, jwt) => {
|
|
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
// Die Signatur passt danach nicht mehr -- genau das soll auffallen.
|
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei verfaelschtem Kopf undefined zurueck", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
|
|
await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined();
|
|
await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem Token ohne Signatur undefined zurueck", async () => {
|
|
// Der klassische alg-none-Versuch: Signatur weglassen und hoffen.
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => {
|
|
const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" });
|
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem Token undefined zurueck, dessen APP_SECRET inzwischen rotiert wurde", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge");
|
|
|
|
// Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft.
|
|
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => {
|
|
it("gibt bei einem abgelaufenen Token undefined zurueck", async () => {
|
|
const jetzt = Math.floor(Date.now() / 1000);
|
|
const abgelaufen = await tokenBauen({
|
|
ausgestelltAm: jetzt - 13 * 3600,
|
|
laeuftAbAm: jetzt - 3600,
|
|
});
|
|
|
|
await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("nimmt ein Token an, das gerade noch laeuft", async () => {
|
|
const jetzt = Math.floor(Date.now() / 1000);
|
|
const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 });
|
|
|
|
expect(await sitzungLesen(knapp)).toBeDefined();
|
|
});
|
|
|
|
it("gibt bei falschem Aussteller undefined zurueck", async () => {
|
|
// Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als
|
|
// Sitzung durchrutschen.
|
|
const fremd = await tokenBauen({ aussteller: "proxledger" });
|
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei falscher Zielgruppe undefined zurueck", async () => {
|
|
const fremd = await tokenBauen({ zielgruppe: "einladung" });
|
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["fehlende E-Mail", { email: undefined }],
|
|
["fehlender Name", { name: undefined }],
|
|
["fehlende Herkunft", { herkunft: undefined }],
|
|
["unbekannte Herkunft", { herkunft: "ldap" }],
|
|
["Name als Zahl", { name: 42 }],
|
|
])("gibt bei %s undefined zurueck", async (_fall, inhalt) => {
|
|
const luecke = await tokenBauen({ inhalt });
|
|
await expect(sitzungLesen(luecke)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("APP_SECRET pruefen", () => {
|
|
it.each([
|
|
["fehlendes", ""],
|
|
["viel zu kurzes", "kurz"],
|
|
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
|
|
])("wirft beim Erzeugen bei %s APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => {
|
|
appSecretSetzen(wert);
|
|
|
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler);
|
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/);
|
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/);
|
|
});
|
|
|
|
it("wirft auch beim Lesen bei zu kurzem APP_SECRET", async () => {
|
|
// Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein
|
|
// Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen.
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
appSecretSetzen("kurz");
|
|
|
|
await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler);
|
|
await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/);
|
|
});
|
|
|
|
it("nimmt genau 32 Zeichen an", async () => {
|
|
appSecretSetzen("b".repeat(32));
|
|
|
|
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
|
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
|
});
|
|
|
|
it("stellt den Standardwert aus setup-env wieder her", async () => {
|
|
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach
|
|
// Reihenfolge unterschiedlich ausgehen.
|
|
expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER);
|
|
expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined();
|
|
});
|
|
});
|