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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user