Files
sshkeyvault/tests/notfall-anmeldung.test.ts
T
florian.wildeandClaude Opus 5.5 4219701b29 feat(anmeldung): lokale Benutzerkonten mit Pflicht-TOTP (#035)
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>
2026-10-12 22:36:25 +02:00

126 lines
4.7 KiB
TypeScript

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,
kontoAnmelden,
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 kontoAnmelden("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(kontoAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
const ergebnis = await kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
expect(ergebnis.art).toBe("angemeldet");
// Derselbe Code ein zweites Mal: abgelehnt.
await expect(kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
AnmeldeFehler,
);
});
it("verraet nicht, ob Name oder Passwort falsch war", async () => {
await notfallAnlegen();
const a = await kontoAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
const b = await kontoAnmelden("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(kontoAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
}
await expect(kontoAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
});
it("protokolliert Fehlversuche ohne Passwort", async () => {
await notfallAnlegen();
await kontoAnmelden("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" });
});
});