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
+1
View File
@@ -74,6 +74,7 @@ describe("Rechte", () => {
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
[
"benutzerVerwalten",
"deploymentKeyVerwalten",
"massenwiderruf",
"privateKeyAnzeigen",
+2 -2
View File
@@ -66,10 +66,10 @@ test("OIDC ohne sshkv-Gruppe wird abgewiesen", async ({ page }) => {
test("Notfall-Admin richtet TOTP ein und bestätigt sich erneut", async ({ page }) => {
test.skip(NOTFALL_PASSWORT.length < 16, "SSHKV_BREAKGLASS_PASSWORD fehlt in .env");
await page.goto("/anmelden");
await page.getByText("Notfallzugang", { exact: true }).click();
await page.getByText("Lokales Konto oder Notfallzugang", { exact: true }).click();
await page.getByLabel("Anmeldename").fill("notfall");
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
await page.getByRole("button", { name: "Mit Notfallzugang anmelden" }).click();
await page.getByRole("button", { name: "Mit Benutzername anmelden" }).click();
await expect(page).toHaveURL(/\/anmelden\/totp/);
await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible();
+76
View File
@@ -0,0 +1,76 @@
import { expect, test } from "@playwright/test";
import { base32Dekodieren, totpCode, totpSchritt } from "../../src/lib/totp";
import { anmelden, oidcBestaetigen } from "./hilfen";
/**
* Lokale Konten (#035) im gebauten Stand: Admin legt ein Konto an, die Person meldet sich zum
* ersten Mal an (eigenes Passwort, TOTP), der Admin sperrt sie – die Sitzung endet sofort.
*/
test.setTimeout(120_000);
test("lokales Konto anlegen, erste Anmeldung, sperren", async ({ page, browser }) => {
const anmeldename = `zz-e2e-${Date.now().toString(36)}`;
const passwort = "ein-eigenes-e2e-passwort";
await anmelden(page);
await page.goto("/einstellungen");
await page.getByRole("link", { name: "Konten verwalten" }).click();
await expect(page.getByRole("heading", { name: "Benutzer", exact: true })).toBeVisible();
await page.getByLabel("Anmeldename").fill(anmeldename);
await page.getByLabel("Name", { exact: true }).fill("E2E Lokal");
await page.getByLabel("Rolle", { exact: true }).selectOption("OPERATOR");
await page.getByRole("button", { name: "Konto anlegen" }).click();
// Ohne frische Bestaetigung lehnt der Server ab; danach klappt es.
await expect(page.getByText(/erneute Bestätigung/)).toBeVisible();
await page.goto("/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer");
await oidcBestaetigen(page);
await expect(page).toHaveURL(/\/einstellungen\/benutzer$/);
await page.getByLabel("Anmeldename").fill(anmeldename);
await page.getByLabel("Name", { exact: true }).fill("E2E Lokal");
await page.getByLabel("Rolle", { exact: true }).selectOption("OPERATOR");
await page.getByRole("button", { name: "Konto anlegen" }).click();
const anzeige = page.getByRole("region", { name: `Einmal-Passwort für ${anmeldename}` });
const einmal = ((await anzeige.locator("code").textContent()) ?? "").trim();
expect(einmal).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
await page.screenshot({ path: "docs/anleitung/bilder/benutzer-desktop.png", fullPage: true });
// Zweiter Browser: die Person meldet sich zum ersten Mal an.
const kontext = await browser.newContext();
const person = await kontext.newPage();
await person.goto("/anmelden");
await person.getByText("Lokales Konto oder Notfallzugang", { exact: true }).click();
await person.getByLabel("Anmeldename").fill(anmeldename);
await person.getByLabel("Passwort").fill(einmal);
await person.getByRole("button", { name: "Mit Benutzername anmelden" }).click();
await expect(person).toHaveURL(/\/anmelden\/passwort/);
await person.getByLabel("Neues Passwort", { exact: true }).fill("zu-kurz");
await person.getByLabel("Neues Passwort wiederholen").fill("zu-kurz");
await person.getByRole("button", { name: "Passwort setzen" }).click();
await expect(person.getByText(/mindestens 12 Zeichen/).last()).toBeVisible();
await person.getByLabel("Neues Passwort", { exact: true }).fill(passwort);
await person.getByLabel("Neues Passwort wiederholen").fill(passwort);
await person.getByRole("button", { name: "Passwort setzen" }).click();
await expect(person).toHaveURL(/\/anmelden\/totp/);
const geheimnis = ((await person.locator("code").first().textContent()) ?? "").trim();
await person
.getByLabel("Code zur Bestätigung")
.fill(totpCode(base32Dekodieren(geheimnis), totpSchritt(Date.now())));
await person.getByRole("button", { name: /TOTP einrichten/ }).click();
await expect(person.getByRole("heading", { name: "Übersicht" })).toBeVisible();
await expect(person.getByText("Operator · über lokales Konto")).toBeVisible();
// Ein Operator verwaltet keine Benutzer.
await person.goto("/einstellungen/benutzer");
await expect(person).toHaveURL(/\/kein-zugriff/);
// Admin sperrt das Konto – die Sitzung der Person endet sofort.
await page.reload();
const zeile = page.getByRole("row").filter({ hasText: anmeldename });
await expect(zeile.getByText("Einrichtung offen")).toHaveCount(0);
await zeile.getByRole("button", { name: /Sperren/ }).click();
await expect(zeile.getByText("gesperrt", { exact: true })).toBeVisible();
await person.goto("/");
await expect(person).toHaveURL(/\/anmelden/);
await kontext.close();
});
+192
View File
@@ -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/);
});
});
+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(
+1
View File
@@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest";
const AUSNAHMEN: Record<string, string> = {
"src/app/anmelden/page.tsx": "Anmeldeseite selbst",
"src/app/anmelden/totp/page.tsx": "nur mit Einrichtungs-Cookie aus richtigem Passwort erreichbar",
"src/app/anmelden/passwort/page.tsx": "nur mit Cookie aus richtigem Einmal-Passwort erreichbar (#035)",
"src/app/api/auth/abmelden/route.ts": "Abmelden braucht keine Pruefung",
"src/app/api/auth/oidc/start/route.ts": "Beginn der Anmeldung",
"src/app/api/auth/oidc/rueckruf/route.ts": "Ende der Anmeldung; prueft state, nonce und PKCE",