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:
@@ -0,0 +1,192 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user