Ohne authentik gab es nur den Notfall-Admin – ein geteiltes Konto ohne Rollen und ohne erkennbare Akteure im Audit-Log. Admins legen jetzt lokale Konten an; sshkv erzeugt ein Einmal-Passwort, das bei der ersten Anmeldung ersetzt wird, danach ist TOTP Pflicht. Rollen, Sperren (beendet Sitzungen sofort) und Zurücksetzen unter Einstellungen → Benutzer, nur für Admins mit frischer Bestätigung. SSHKV_LOKALE_KONTEN=0 schaltet lokale Konten ab; der Notfall-Admin bleibt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
7.1 KiB
TypeScript
182 lines
7.1 KiB
TypeScript
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(
|
||
[
|
||
"benutzerVerwalten",
|
||
"deploymentKeyVerwalten",
|
||
"massenwiderruf",
|
||
"privateKeyAnzeigen",
|
||
"privateKeyLoeschen",
|
||
"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'");
|
||
});
|
||
});
|