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>
126 lines
4.7 KiB
TypeScript
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" });
|
|
});
|
|
});
|