Merge branch 'phase/004-anmeldung-rollen' (Logik)
This commit is contained in:
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user