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>
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'");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user