Merge branch 'phase/004-anmeldung-rollen' (Logik)

This commit is contained in:
2026-10-12 09:17:57 +02:00
20 changed files with 2320 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
import { afterEach, describe, expect, it } from "vitest";
import type { Sitzung } from "@/lib/sitzung";
import { base32Dekodieren, base32Kodieren, totpCode, totpPruefen, totpSchritt } from "@/lib/totp";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { rolleAusGruppen } from "@/server/auth/oidc";
import { BESTAETIGUNG_NOETIG, RECHTE, darf } from "@/server/auth/rechte";
import { BESTAETIGUNG_GUELTIG_MS, bestaetigungFrisch, sitzungBewerten } from "@/server/auth/sitzungspflicht";
import { OEFFENTLICH, istOeffentlich, sicherheitsrichtlinie } from "@/proxy";
/** Entscheidungen rund um Anmeldung und Rechte, die ohne Datenbank pruefbar sind (#004). */
const UMGEBUNG = { ...process.env };
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("TOTP nach RFC 6238", () => {
// Testvektoren aus RFC 6238, Anhang B (SHA-1, Geheimnis "12345678901234567890").
const geheimnis = Buffer.from("12345678901234567890");
it.each([
[59, "94287082"],
[1111111109, "07081804"],
[1111111111, "14050471"],
[1234567890, "89005924"],
[2000000000, "69279037"],
])("Zeitpunkt %i ergibt %s", (sekunden, erwartet) => {
expect(totpCode(geheimnis, totpSchritt(sekunden * 1000), 8)).toBe(erwartet);
expect(totpCode(geheimnis, totpSchritt(sekunden * 1000))).toBe(erwartet.slice(2));
});
it("kodiert Base32 verlustfrei", () => {
expect(base32Kodieren(geheimnis)).toBe("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
expect(base32Dekodieren("gezd gnbv gy3t qojq gezd gnbv gy3t qojq")).toEqual(geheimnis);
});
it("nimmt den Code des Nachbarschritts an, aber keinen zweimal", () => {
const base32 = base32Kodieren(geheimnis);
const jetzt = 1_111_111_111_000;
const vorher = totpCode(geheimnis, totpSchritt(jetzt) - 1);
const schritt = totpPruefen(base32, vorher, jetzt);
expect(schritt).toBe(totpSchritt(jetzt) - 1);
// Derselbe Code nach erfolgreicher Anmeldung: abgelehnt (Wiederholung).
expect(totpPruefen(base32, vorher, jetzt, schritt)).toBeUndefined();
});
it("lehnt alte, kaputte und fremde Codes ab", () => {
const base32 = base32Kodieren(geheimnis);
const jetzt = 1_111_111_111_000;
expect(totpPruefen(base32, totpCode(geheimnis, totpSchritt(jetzt) - 3), jetzt)).toBeUndefined();
expect(totpPruefen(base32, "12345", jetzt)).toBeUndefined();
expect(totpPruefen(base32, "abcdef", jetzt)).toBeUndefined();
});
});
describe("Rechte", () => {
it("laesst Private Keys nur Admins ausrollen und anzeigen (Freigabe Frage 3)", () => {
expect(darf("ADMIN", "privateKeyAusrollen")).toBe(true);
expect(darf("OPERATOR", "privateKeyAusrollen")).toBe(false);
expect(darf("OPERATOR", "privateKeyAnzeigen")).toBe(false);
expect(darf("LESER", "privateKeyAnzeigen")).toBe(false);
});
it("laesst Operatoren Public Keys ausrollen, Leser nicht", () => {
expect(darf("OPERATOR", "publicKeyAusrollen")).toBe(true);
expect(darf("LESER", "publicKeyAusrollen")).toBe(false);
});
it("laesst jeden eigene Public Keys hinterlegen (Freigabe Frage 4)", () => {
for (const rolle of ["ADMIN", "OPERATOR", "LESER"] as const) {
expect(darf(rolle, "eigeneSchluesselHinterlegen")).toBe(true);
}
});
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
["deploymentKeyVerwalten", "massenwiderruf", "privateKeyAnzeigen", "zielkontenVerwalten"].sort(),
);
for (const recht of BESTAETIGUNG_NOETIG) {
expect(RECHTE[recht]).toEqual(["ADMIN"]);
}
});
});
describe("Rollen aus OIDC-Gruppen", () => {
it("bildet die Standardgruppen ab und nimmt die staerkste", () => {
expect(rolleAusGruppen(["sshkv-readers"])).toBe("LESER");
expect(rolleAusGruppen(["sshkv-operators"])).toBe("OPERATOR");
expect(rolleAusGruppen(["sshkv-readers", "sshkv-admins"])).toBe("ADMIN");
});
it("gibt ohne passende Gruppe gar keine Rolle – kein stiller Lesezugriff", () => {
expect(rolleAusGruppen([])).toBeUndefined();
expect(rolleAusGruppen(["andere", "SSHKV-ADMINS"])).toBeUndefined();
});
it("folgt umbenannten Gruppen aus der Umgebung", () => {
process.env["SSHKV_OIDC_GROUP_ADMIN"] = "it-root";
expect(rolleAusGruppen(["it-root"])).toBe("ADMIN");
expect(rolleAusGruppen(["sshkv-admins"])).toBeUndefined();
});
});
describe("Sitzung bewerten", () => {
const sitzung: Sitzung = {
benutzerId: "b1",
email: "a@b.de",
name: "A",
herkunft: "oidc",
ausgestelltAm: 1_700_000_000,
};
it("traegt fuer ein aktives Konto", () => {
expect(sitzungBewerten(sitzung, { id: "b1", aktiv: true, sitzungenGueltigAb: new Date(0) })).toEqual({
gueltig: true,
});
});
it("verfaellt fuer fremde, deaktivierte und widerrufene Konten", () => {
expect(sitzungBewerten(sitzung, null).gueltig).toBe(false);
expect(sitzungBewerten(sitzung, { id: "b2", aktiv: true, sitzungenGueltigAb: new Date(0) }).gueltig).toBe(
false,
);
expect(
sitzungBewerten(sitzung, { id: "b1", aktiv: false, sitzungenGueltigAb: new Date(0) }).gueltig,
).toBe(false);
const spaeter = new Date((sitzung.ausgestelltAm + 60) * 1000);
expect(sitzungBewerten(sitzung, { id: "b1", aktiv: true, sitzungenGueltigAb: spaeter })).toEqual({
gueltig: false,
grund: "widerrufen",
});
});
it("haelt eine Bestaetigung genau fuenf Minuten fuer frisch", () => {
const jetzt = new Date("2026-10-12T10:00:00Z");
expect(bestaetigungFrisch(null, jetzt)).toBe(false);
expect(bestaetigungFrisch(new Date(jetzt.getTime() - BESTAETIGUNG_GUELTIG_MS), jetzt)).toBe(true);
expect(bestaetigungFrisch(new Date(jetzt.getTime() - BESTAETIGUNG_GUELTIG_MS - 1), jetzt)).toBe(false);
// Aus der Zukunft zaehlt nicht (Uhr verstellt oder Wert manipuliert).
expect(bestaetigungFrisch(new Date(jetzt.getTime() + 60_000), jetzt)).toBe(false);
});
});
describe("Ruecksprungziel", () => {
it("nimmt Pfade der Anwendung an und weist alles andere ab", () => {
expect(sicheresZiel("/schluessel/abc?tab=2")).toBe("/schluessel/abc?tab=2");
for (const boesartig of [
"https://fremd.example",
"//fremd.example",
"/\\fremd.example",
"javascript:alert(1)",
]) {
expect(sicheresZiel(boesartig)).toBe("/");
}
expect(sicheresZiel(null)).toBe("/");
});
});
describe("Proxy", () => {
it("haelt die oeffentlichen Pfade klein und benannt", () => {
expect([...OEFFENTLICH].sort()).toEqual(
["/anmelden", "/api/auth", "/api/gesundheit", "/brand", "/icons", "/manifest.webmanifest"].sort(),
);
expect(istOeffentlich("/api/auth/oidc/start")).toBe(true);
expect(istOeffentlich("/anmeldenX")).toBe(false);
expect(istOeffentlich("/schluessel")).toBe(false);
});
it("liefert im Betrieb eine strenge CSP ohne unsafe-eval", () => {
const richtlinie = sicherheitsrichtlinie("abc", false);
expect(richtlinie).toContain("script-src 'self' 'nonce-abc' 'strict-dynamic'");
expect(richtlinie).not.toContain("unsafe-eval");
expect(richtlinie).toContain("frame-ancestors 'none'");
});
});
+168
View File
@@ -0,0 +1,168 @@
import { afterEach, describe, expect, it } from "vitest";
import { GeheimnisFehler, entschluesseln, verschluesseln } from "@/lib/geheimnisse";
// `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["SSHKV_APP_SECRET"];
function appSecretSetzen(wert: string): void {
process.env["SSHKV_APP_SECRET"] = wert;
}
afterEach(() => {
if (APP_SECRET_VORHER === undefined) {
delete process.env["SSHKV_APP_SECRET"];
} else {
process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER;
}
});
/** Aendert ein Byte im angegebenen Teil des Geheimtexts, ohne das Format zu verletzen. */
function teilVerfaelschen(geheimtext: string, index: number): string {
const teile = geheimtext.split(".");
const teil = teile[index];
if (teil === undefined || teil.length === 0) {
throw new Error(`Teil ${index} fehlt im Geheimtext.`);
}
// Das erste base64-Zeichen tragt die oberen Bits des ersten Bytes, eine
// Aenderung dort wirkt also sicher -- anders als am gepolsterten Ende.
const ersetzt = teil.startsWith("A") ? "B" : "A";
teile[index] = ersetzt + teil.slice(1);
return teile.join(".");
}
describe("Geheimnisse verschluesseln und entschluesseln", () => {
it("ist verlustfrei fuer einfachen Text", () => {
const klartext = "bind-passwort-123";
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
});
it.each([
["Umlaute und Eszett", "Grueße aus Bad Münstereifel: ÄÖÜäöüß"],
["Emoji", "Schlüssel 🔐 im Tresor 🗄️ mit Häkchen ✅"],
["leerer Text", ""],
["nur Leerzeichen", " "],
["Zeilenumbrueche", "erste Zeile\nzweite Zeile\r\ndritte"],
["Punkte im Klartext", "v1.das.sieht.aus.wie.ein.geheimtext"],
["base64-artiger Text", "YWJjZGVmZ2g="],
])("ist verlustfrei fuer %s", (_fall, klartext) => {
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
});
it("ist verlustfrei fuer lange Texte", () => {
const lang = "Zertifikat-".repeat(10_000);
const geheim = verschluesseln(lang);
expect(geheim.length).toBeGreaterThan(lang.length / 2);
expect(entschluesseln(geheim)).toBe(lang);
});
it("traegt das Versionspraefix und vier durch Punkt getrennte Teile", () => {
const teile = verschluesseln("etwas").split(".");
expect(teile).toHaveLength(4);
expect(teile[0]).toBe("v1");
// 12 Byte IV und 16 Byte Auth-Tag, base64 kodiert.
expect(Buffer.from(teile[1] ?? "", "base64")).toHaveLength(12);
expect(Buffer.from(teile[2] ?? "", "base64")).toHaveLength(16);
});
it("liefert fuer denselben Klartext zwei verschiedene Geheimtexte", () => {
// Jeder Aufruf zieht einen eigenen IV; sonst waere an gleichen Geheimtexten
// ablesbar, welche Konten dasselbe Passwort benutzen.
const klartext = "immer-dasselbe-passwort";
const erster = verschluesseln(klartext);
const zweiter = verschluesseln(klartext);
expect(erster).not.toBe(zweiter);
expect(erster.split(".")[1]).not.toBe(zweiter.split(".")[1]);
expect(entschluesseln(erster)).toBe(klartext);
expect(entschluesseln(zweiter)).toBe(klartext);
});
it("enthaelt den Klartext nicht im Ergebnis", () => {
const klartext = "geheimes-bind-passwort";
expect(verschluesseln(klartext)).not.toContain(klartext);
});
});
describe("Geheimnisse gegen Verfaelschung", () => {
it("wirft, wenn ein Byte im Geheimtext veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 3);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(verfaelscht)).toThrow(/Auth-Tag passt nicht/);
});
it("wirft, wenn das Auth-Tag veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 2);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
});
it("wirft, wenn der IV veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 1);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
});
it("wirft, wenn der Geheimtext abgeschnitten wurde", () => {
const geheim = verschluesseln("bind-passwort-123");
const ohneLetztenTeil = geheim.split(".").slice(0, 3).join(".");
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(/unbekanntes Format/);
});
it.each([
["leerer Wert", ""],
["Klartext ohne Trenner", "einfach-nur-text"],
["zu wenige Teile", "v1.abc"],
["zu viele Teile", "v1.a.b.c.d"],
["unbekannte Version", "v9.AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
["IV zu kurz", "v1.AAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
["Auth-Tag zu kurz", "v1.AAAAAAAAAAAAAAAA.AAAA.AAAA"],
])("wirft mit verstaendlicher Meldung bei %s", (_fall, geheimtext) => {
let gefangen: unknown;
try {
entschluesseln(geheimtext);
} catch (fehler) {
gefangen = fehler;
}
expect(gefangen).toBeInstanceOf(GeheimnisFehler);
expect((gefangen as GeheimnisFehler).message).toMatch(/v1\.<iv>\.<authTag>\.<geheimtext>/);
});
it("wirft, wenn SSHKV_APP_SECRET zwischen Verschluesseln und Entschluesseln wechselt", () => {
const geheim = verschluesseln("bind-passwort-123");
appSecretSetzen("ein-voellig-anderes-geheimnis-mit-genug-zeichen");
expect(() => entschluesseln(geheim)).toThrow(GeheimnisFehler);
});
});
describe("SSHKV_APP_SECRET pruefen", () => {
it.each([
["fehlendes", ""],
["viel zu kurzes", "kurz"],
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
])("wirft bei %s SSHKV_APP_SECRET mit Hinweis auf openssl", (_fall, wert) => {
appSecretSetzen(wert);
expect(() => verschluesseln("etwas")).toThrow(GeheimnisFehler);
expect(() => verschluesseln("etwas")).toThrow(/openssl rand -base64 48/);
expect(() => verschluesseln("etwas")).toThrow(/mindestens 32 Zeichen/);
});
it("nimmt genau 32 Zeichen an", () => {
appSecretSetzen("b".repeat(32));
expect(entschluesseln(verschluesseln("grenzfall"))).toBe("grenzfall");
});
it("stellt den Standardwert aus setup-env wieder her", () => {
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die
// Tests je nach Reihenfolge unterschiedlich ausgehen.
expect(process.env["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER);
expect(entschluesseln(verschluesseln("nach dem Aufraeumen"))).toBe("nach dem Aufraeumen");
});
});
+125
View File
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { passwortHashen } from "@/lib/passwort-hash";
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
import {
AnmeldeFehler,
notfallAnmelden,
oidcBenutzerUebernehmen,
totpEinrichtungAbschliessen,
} from "@/server/services/anmeldung";
import { datenbankLeeren } from "./hilfen/db";
/** Notfall-Admin mit Pflicht-TOTP und OIDC-Kontouebernahme gegen die Datenbank (#004). */
const PASSWORT = "notfall-test-passwort-123";
async function notfallAnlegen() {
return prisma.benutzer.create({
data: {
anmeldename: "notfall",
name: "Notfall-Admin",
rolle: "ADMIN",
istNotfall: true,
passwortHash: await passwortHashen(PASSWORT),
},
});
}
function codeZu(geheimnis: string, zeit: Date, versatz = 0) {
return totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime()) + versatz);
}
beforeEach(async () => {
await datenbankLeeren();
});
describe("Notfall-Admin", () => {
it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => {
await notfallAnlegen();
const ergebnis = await notfallAnmelden("notfall", PASSWORT, "");
expect(ergebnis.art).toBe("totpEinrichten");
});
it("richtet TOTP ein und meldet danach nur mit gueltigem Code an", async () => {
const benutzer = await notfallAnlegen();
const geheimnis = totpGeheimnisErzeugen();
const t0 = new Date("2026-10-12T10:00:00Z");
await expect(totpEinrichtungAbschliessen(benutzer.id, geheimnis, "000000", t0)).rejects.toThrow(
AnmeldeFehler,
);
await totpEinrichtungAbschliessen(benutzer.id, geheimnis, codeZu(geheimnis, t0), t0);
const gespeichert = await prisma.benutzer.findUniqueOrThrow({ where: { id: benutzer.id } });
// Das Geheimnis liegt verschluesselt, nicht im Klartext.
expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis);
const t1 = new Date(t0.getTime() + 60_000);
await expect(notfallAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
const ergebnis = await notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
expect(ergebnis.art).toBe("angemeldet");
// Derselbe Code ein zweites Mal: abgelehnt.
await expect(notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
AnmeldeFehler,
);
});
it("verraet nicht, ob Name oder Passwort falsch war", async () => {
await notfallAnlegen();
const a = await notfallAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
const b = await notfallAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
expect(a).toBe(b);
});
it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => {
await notfallAnlegen();
for (let i = 0; i < 5; i += 1) {
await expect(notfallAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
}
await expect(notfallAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
});
it("protokolliert Fehlversuche ohne Passwort", async () => {
await notfallAnlegen();
await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
const eintraege = await prisma.protokoll.findMany();
expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen");
expect(
JSON.stringify(eintraege, (_, wert: unknown) => (typeof wert === "bigint" ? String(wert) : wert)),
).not.toContain("geheimes-falsches-pw");
});
});
describe("OIDC-Konto", () => {
const basis = {
sub: "sub-1",
email: "florian@example.de",
emailBestaetigt: true,
name: "Florian",
ziel: "/",
erneut: false,
};
it("legt das Konto an und nimmt die Rolle aus den Gruppen", async () => {
const benutzer = await oidcBenutzerUebernehmen({ ...basis, gruppen: ["sshkv-operators"] });
expect(benutzer.rolle).toBe("OPERATOR");
const wieder = await oidcBenutzerUebernehmen({ ...basis, gruppen: ["sshkv-readers"] });
expect(wieder.id).toBe(benutzer.id);
expect(wieder.rolle).toBe("LESER");
});
it("verweigert ohne sshkv-Gruppe", async () => {
await expect(oidcBenutzerUebernehmen({ ...basis, gruppen: [] })).rejects.toMatchObject({
grund: "keine-gruppe",
});
expect(await prisma.benutzer.count()).toBe(0);
});
it("verknuepft nur ueber eine bestaetigte Adresse mit einem bestehenden Konto", async () => {
await prisma.benutzer.create({ data: { email: basis.email, name: "Alt", rolle: "LESER" } });
await expect(
oidcBenutzerUebernehmen({ ...basis, emailBestaetigt: false, gruppen: ["sshkv-admins"] }),
).rejects.toMatchObject({ grund: "konto-konflikt" });
});
});
+281
View File
@@ -0,0 +1,281 @@
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["SSHKV_APP_SECRET"];
function appSecretSetzen(wert: string): void {
process.env["SSHKV_APP_SECRET"] = wert;
}
afterEach(() => {
if (APP_SECRET_VORHER === undefined) {
delete process.env["SSHKV_APP_SECRET"];
} else {
process.env["SSHKV_APP_SECRET"] = APP_SECRET_VORHER;
}
});
const DATEN: Omit<Sitzung, "ausgestelltAm"> = {
benutzerId: "b-0815",
email: "anna.mueller@example.org",
name: "Anna Müller",
herkunft: "notfall",
};
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 ?? "sshkv")
.setAudience(optionen.zielgruppe ?? "sshkv")
.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("notfall");
});
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 ["notfall", "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("sshkv");
expect(inhalt.aud).toBe("sshkv");
});
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("sshkv_sitzung");
expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(8);
});
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 SSHKV_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("SSHKV_APP_SECRET pruefen", () => {
it.each([
["fehlendes", ""],
["viel zu kurzes", "kurz"],
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
])("wirft beim Erzeugen bei %s SSHKV_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 SSHKV_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["SSHKV_APP_SECRET"]).toBe(APP_SECRET_VORHER);
expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined();
});
});