Files
sshkeyvault/tests/anmeldung.test.ts
T
florian.wildeandClaude Opus 5.5 5ef90310e1 feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)
Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf
sshkv angepasst. Abweichungen mit Absicht:

- Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen
  Lesezugriff: schon die Hostliste verraet, wer wohin darf.
- Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges
  Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt
  einmal, fuenf Fehlversuche sperren 15 Minuten.
- Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf
  Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung
  von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis.
- Sitzungen laufen acht statt zwoelf Stunden.

Die Seiten folgen, sobald das Design-System (#002) steht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:17:44 +02:00

175 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'");
});
});