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>
This commit is contained in:
2026-10-12 22:36:25 +02:00
co-authored by Claude Opus 5.5
parent d9eec3d991
commit 4219701b29
41 changed files with 1295 additions and 109 deletions
+10 -10
View File
@@ -4,7 +4,7 @@ import { passwortHashen } from "@/lib/passwort-hash";
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
import {
AnmeldeFehler,
notfallAnmelden,
kontoAnmelden,
oidcBenutzerUebernehmen,
totpEinrichtungAbschliessen,
} from "@/server/services/anmeldung";
@@ -37,7 +37,7 @@ beforeEach(async () => {
describe("Notfall-Admin", () => {
it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => {
await notfallAnlegen();
const ergebnis = await notfallAnmelden("notfall", PASSWORT, "");
const ergebnis = await kontoAnmelden("notfall", PASSWORT, "");
expect(ergebnis.art).toBe("totpEinrichten");
});
@@ -56,33 +56,33 @@ describe("Notfall-Admin", () => {
expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis);
const t1 = new Date(t0.getTime() + 60_000);
await expect(notfallAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
const ergebnis = await notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
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(notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
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 notfallAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
const b = await notfallAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
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(notfallAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
await expect(kontoAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
}
await expect(notfallAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
await expect(kontoAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
});
it("protokolliert Fehlversuche ohne Passwort", async () => {
await notfallAnlegen();
await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
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(