feat(auth): Anmeldeseiten, TOTP-Einrichtung und Bestaetigung (#004)

Anmeldung, Einrichtung des zweiten Faktors fuer den Notfall-Admin,
erneute Bestaetigung vor kritischen Aktionen und die Seite fuer
fehlende Rechte. Die Huelle zeigt jetzt den echten Benutzer, und jede
Seite prueft die Sitzung selbst; ein struktureller Test haelt das fest.

Die Ablaeufe sind im gebauten Stand gegen den Mock-OIDC geprueft
(pnpm test:e2e): Admin, Konto ohne Gruppe, Notfall-Admin mit TOTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:28:51 +02:00
co-authored by Claude Opus 5.5
parent 4d0044b380
commit d1ba822a4a
37 changed files with 763 additions and 38 deletions
+97
View File
@@ -0,0 +1,97 @@
import { expect, test, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config";
import { base32Dekodieren, totpCode, totpSchritt } from "../../src/lib/totp";
/**
* Anmeldung im gebauten Stand (#004): OIDC ueber den Mock-Anbieter und Notfallzugang mit
* TOTP-Einrichtung. Laeuft gegen die Entwicklungsdatenbank und setzt dort vorher das TOTP
* des Notfall-Admins zurueck – deshalb nie gegen einen Betrieb richten.
*/
const NOTFALL_PASSWORT = process.env["SSHKV_BREAKGLASS_PASSWORD"] ?? "";
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader" | "ohne") {
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator(`button[data-rolle="${rolle}"]`).click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
}
test.beforeAll(async () => {
await datenbank(async (client) => {
await client.query(
`UPDATE "Benutzer" SET "totpGeheimnis" = NULL, "totpEingerichtetAm" = NULL, "totpLetzterSchritt" = NULL WHERE "istNotfall"`,
);
await client.query(`DELETE FROM "Ratenbegrenzung"`);
});
});
test("ohne Sitzung geht es zur Anmeldung, mit Ziel", async ({ page }) => {
await page.goto("/hosts");
await expect(page).toHaveURL(/\/anmelden\?ziel=%2Fhosts/);
await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible();
});
test("OIDC-Admin landet in der Übersicht und kann sich abmelden", async ({ page }) => {
await oidcAnmelden(page, "admin");
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
await expect(page.getByText("Admin (Mock)")).toBeVisible();
await expect(page.getByText("Admin · über Mock-OIDC")).toBeVisible();
await page.getByRole("button", { name: "Abmelden" }).click();
await expect(page.getByText("Sie wurden abgemeldet.")).toBeVisible();
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
});
test("OIDC ohne sshkv-Gruppe wird abgewiesen", async ({ page }) => {
await oidcAnmelden(page, "ohne");
await expect(page).toHaveURL(/fehler=keine-gruppe/);
await expect(page.getByText(/keiner SSHKeyVault-Gruppe zugeordnet/)).toBeVisible();
});
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.getByLabel("Anmeldename").fill("notfall");
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
await page.getByRole("button", { name: "Mit Notfallzugang anmelden" }).click();
await expect(page).toHaveURL(/\/anmelden\/totp/);
await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible();
const geheimnis = ((await page.locator("code").first().textContent()) ?? "").trim();
expect(geheimnis).toMatch(/^[A-Z2-7]{32}$/);
const code = () => totpCode(base32Dekodieren(geheimnis), totpSchritt(Date.now()));
await page.getByLabel("Code zur Bestätigung").fill("000000");
await page.getByRole("button", { name: /TOTP einrichten/ }).click();
await expect(page.getByText(/Der Code passt nicht/)).toBeVisible();
await page.getByLabel("Code zur Bestätigung").fill(code());
await page.getByRole("button", { name: /TOTP einrichten/ }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
await expect(page.getByText("Admin · über Notfallzugang")).toBeVisible();
// Erneute Bestaetigung: derselbe Code gilt nicht noch einmal, also auf den naechsten
// Zeitschritt warten.
await page.goto("/bestaetigen?ziel=%2Fhosts");
const schritt = totpSchritt(Date.now());
await expect
.poll(() => totpSchritt(Date.now()), { timeout: 35_000, intervals: [1000] })
.toBeGreaterThan(schritt);
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
await page.getByLabel("Code aus der Authenticator-App").fill(code());
await page.getByRole("button", { name: "Bestätigen" }).click();
await expect(page).toHaveURL(/\/hosts$/);
});