Files
sshkeyvault/tests/lokale-konten.test.ts
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

193 lines
8.4 KiB
TypeScript
Raw Permalink 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, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import {
anmeldenameSchema,
einmalPasswortErzeugen,
lokaleKontenAktiv,
passwortFehler,
} from "@/lib/lokale-konten";
import { passwortHashen } from "@/lib/passwort-hash";
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
import {
AnmeldeFehler,
eigenesPasswortSetzen,
kontoAnmelden,
totpEinrichtungAbschliessen,
} from "@/server/services/anmeldung";
import {
kontoAktivSetzen,
kontoZuruecksetzen,
lokalesKontoAnlegen,
rolleAendern,
} from "@/server/services/benutzer";
import { FachFehler } from "@/server/services/fachfehler";
import { datenbankLeeren } from "./hilfen/db";
/** Lokale Konten (#035): Regeln, erste Anmeldung, Verwaltung, Abschalten. */
const EIGENES = "ein-eigenes-langes-passwort";
let admin: { id: string; name: string };
beforeEach(async () => {
await datenbankLeeren();
delete process.env["SSHKV_LOKALE_KONTEN"];
admin = await prisma.benutzer.create({
data: { name: "Ada Admin", rolle: "ADMIN", oidcSub: "ada" },
select: { id: true, name: true },
});
});
afterEach(() => {
delete process.env["SSHKV_LOKALE_KONTEN"];
});
const code = (geheimnis: string, zeit: Date) =>
totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime()));
/** Anlegen, Einmal-Passwort ersetzen, TOTP einrichten – wie bei der ersten Anmeldung. */
async function eingerichtetesKonto(anmeldename = "anna") {
const { id, einmalPasswort } = await lokalesKontoAnlegen(
{ anmeldename, name: "Anna", rolle: "OPERATOR" },
admin,
);
expect((await kontoAnmelden(anmeldename, einmalPasswort, "")).art).toBe("passwortSetzen");
await eigenesPasswortSetzen(id, EIGENES);
expect((await kontoAnmelden(anmeldename, EIGENES, "")).art).toBe("totpEinrichten");
const geheimnis = totpGeheimnisErzeugen();
const t0 = new Date("2026-10-12T10:00:00Z");
await totpEinrichtungAbschliessen(id, geheimnis, code(geheimnis, t0), t0);
return { id, geheimnis, einmalPasswort };
}
describe("Regeln", () => {
it("erzeugt gut abtippbare, verschiedene Einmal-Passwoerter", () => {
const pw = einmalPasswortErzeugen();
expect(pw).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
expect(pw).not.toMatch(/[01OlI]/);
expect(new Set(Array.from({ length: 50 }, einmalPasswortErzeugen)).size).toBe(50);
});
it("prueft Anmeldename und Passwort", () => {
expect(anmeldenameSchema.parse(" Anna.Muster ")).toBe("anna.muster");
expect(anmeldenameSchema.safeParse("1anna").success).toBe(false);
expect(anmeldenameSchema.safeParse("a").success).toBe(false);
expect(anmeldenameSchema.safeParse("anna muster").success).toBe(false);
expect(passwortFehler("kurz")).toMatch(/12 Zeichen/);
expect(passwortFehler("xx-anna-xx-geheim", "Anna")).toMatch(/Anmeldenamen/);
expect(passwortFehler(EIGENES, "anna")).toBeNull();
});
it("schaltet lokale Konten nur mit ausdruecklichem Aus ab", () => {
expect(lokaleKontenAktiv({})).toBe(true);
expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: "1" })).toBe(true);
for (const aus of ["0", "false", "nein", "AUS"])
expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: aus })).toBe(false);
});
});
describe("Erste Anmeldung", () => {
it("verlangt erst ein eigenes Passwort, dann TOTP, dann geht es hinein", async () => {
const { id, geheimnis, einmalPasswort } = await eingerichtetesKonto();
const t1 = new Date("2026-10-12T10:01:00Z");
// Das Einmal-Passwort gilt nicht mehr, Gross-/Kleinschreibung des Namens egal.
await expect(kontoAnmelden("anna", einmalPasswort, code(geheimnis, t1), t1)).rejects.toThrow(
AnmeldeFehler,
);
const ergebnis = await kontoAnmelden("Anna", EIGENES, code(geheimnis, t1), t1);
expect(ergebnis).toMatchObject({ art: "angemeldet", benutzer: { id, rolle: "OPERATOR" } });
expect(await prisma.protokoll.count({ where: { aktion: "anmeldung.lokal" } })).toBe(1);
});
it("lehnt schwache und unveraenderte Passwoerter ab und laesst nichts im Protokoll", async () => {
const { id, einmalPasswort } = await lokalesKontoAnlegen(
{ anmeldename: "bert", name: "Bert", rolle: "LESER" },
admin,
);
await expect(eigenesPasswortSetzen(id, "zu-kurz")).rejects.toThrow(/12 Zeichen/);
await expect(eigenesPasswortSetzen(id, einmalPasswort)).rejects.toThrow(/anderes Passwort/);
await eigenesPasswortSetzen(id, EIGENES);
// Kein zweites Mal ohne Zuruecksetzen.
await expect(eigenesPasswortSetzen(id, `${EIGENES}-2`)).rejects.toThrow(/kein Passwortwechsel/);
const protokoll = JSON.stringify(await prisma.protokoll.findMany(), (_k, v: unknown) =>
typeof v === "bigint" ? v.toString() : v,
);
expect(protokoll).not.toContain(einmalPasswort);
expect(protokoll).not.toContain(EIGENES);
});
it("kennt keinen doppelten Anmeldenamen – auch nicht den des Notfall-Admins", async () => {
await prisma.benutzer.create({
data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" },
});
await lokalesKontoAnlegen({ anmeldename: "anna", name: "Anna", rolle: "LESER" }, admin);
for (const name of ["anna", "notfall"]) {
await expect(
lokalesKontoAnlegen({ anmeldename: name, name: "X", rolle: "LESER" }, admin),
).rejects.toThrow(FachFehler);
}
});
});
describe("Verwaltung", () => {
it("aendert Rollen nur bei lokalen Konten und nie am eigenen", async () => {
const { id } = await eingerichtetesKonto();
await rolleAendern(id, "ADMIN", admin);
expect((await prisma.benutzer.findUniqueOrThrow({ where: { id } })).rolle).toBe("ADMIN");
// OIDC-Konto: Rolle kommt aus den Gruppen.
await expect(rolleAendern(admin.id, "LESER", { id: id, name: "Anna" })).rejects.toThrow(/Gruppen/);
await expect(rolleAendern(id, "LESER", { id, name: "Anna" })).rejects.toThrow(/eigene Konto/);
const notfall = await prisma.benutzer.create({
data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" },
});
await expect(rolleAendern(notfall.id, "LESER", admin)).rejects.toThrow(/Notfall-Admin/);
await expect(kontoAktivSetzen(notfall.id, false, admin)).rejects.toThrow(/Notfall-Admin/);
});
it("sperrt sofort: Sitzungen enden, Anmeldung scheitert", async () => {
const { id, geheimnis } = await eingerichtetesKonto();
const vorher = (await prisma.benutzer.findUniqueOrThrow({ where: { id } })).sitzungenGueltigAb;
await kontoAktivSetzen(id, false, admin);
const gesperrt = await prisma.benutzer.findUniqueOrThrow({ where: { id } });
expect(gesperrt.aktiv).toBe(false);
expect(gesperrt.sitzungenGueltigAb.getTime()).toBeGreaterThan(vorher.getTime());
const t = new Date("2026-10-12T10:02:00Z");
await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler);
await kontoAktivSetzen(id, true, admin);
expect((await kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).art).toBe("angemeldet");
});
it("setzt Passwort und TOTP zurueck", async () => {
const { id } = await eingerichtetesKonto();
const { einmalPasswort } = await kontoZuruecksetzen(id, admin);
const konto = await prisma.benutzer.findUniqueOrThrow({ where: { id } });
expect(konto).toMatchObject({
passwortWechselNoetig: true,
totpGeheimnis: null,
totpEingerichtetAm: null,
});
expect((await kontoAnmelden("anna", einmalPasswort, "")).art).toBe("passwortSetzen");
await expect(kontoZuruecksetzen(admin.id, { id, name: "Anna" })).rejects.toThrow(/lokale Konten/);
});
});
describe("Abgeschaltet (SSHKV_LOKALE_KONTEN=0)", () => {
it("laesst lokale Konten nicht mehr hinein, den Notfall-Admin schon", async () => {
const { geheimnis } = await eingerichtetesKonto();
await prisma.benutzer.create({
data: {
anmeldename: "notfall",
name: "Notfall",
rolle: "ADMIN",
istNotfall: true,
passwortHash: await passwortHashen("notfall-test-passwort-123"),
},
});
process.env["SSHKV_LOKALE_KONTEN"] = "0";
const t = new Date("2026-10-12T10:03:00Z");
await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler);
expect((await kontoAnmelden("notfall", "notfall-test-passwort-123", "")).art).toBe("totpEinrichten");
await expect(
lokalesKontoAnlegen({ anmeldename: "carl", name: "Carl", rolle: "LESER" }, admin),
).rejects.toThrow(/abgeschaltet/);
});
});