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:
2026-10-12 09:17:44 +02:00
co-authored by Claude Opus 5.5
parent 97e179d068
commit 5ef90310e1
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'");
});
});