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:
@@ -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$/);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Struktureller Test (#004, Muster aus Laufzettel): jede Seite und jeder Route Handler
|
||||
* prueft selbst, wer zugreift. Die Ausnahmen stehen hier namentlich mit Begruendung – ein
|
||||
* neuer ungepruefter Handler soll jemanden zwingen, sie aufzuschreiben.
|
||||
*/
|
||||
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/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",
|
||||
"src/app/api/gesundheit/route.ts": "Healthcheck, verraet nur die Version",
|
||||
};
|
||||
|
||||
const PRUEFUNGEN = /sitzungErforderlich\(|rechtErforderlich\(|totpEinrichtungLesen\(/;
|
||||
|
||||
function dateien(verzeichnis: string): string[] {
|
||||
return readdirSync(verzeichnis).flatMap((name) => {
|
||||
const pfad = path.join(verzeichnis, name);
|
||||
if (statSync(pfad).isDirectory()) return dateien(pfad);
|
||||
return /^(page\.tsx|route\.ts)$/.test(name) ? [pfad] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("Zugangspruefung", () => {
|
||||
const alle = dateien("src/app").map((p) => p.split(path.sep).join("/"));
|
||||
|
||||
it("findet Seiten und Handler", () => {
|
||||
expect(alle.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it.each(alle)("%s prueft selbst oder steht in den Ausnahmen", (datei) => {
|
||||
if (AUSNAHMEN[datei]) return;
|
||||
const inhalt = readFileSync(datei, "utf8");
|
||||
expect(PRUEFUNGEN.test(inhalt), `${datei} prueft keine Sitzung`).toBe(true);
|
||||
});
|
||||
|
||||
it("listet keine Ausnahme fuer eine Datei, die es nicht mehr gibt", () => {
|
||||
for (const datei of Object.keys(AUSNAHMEN)) expect(alle).toContain(datei);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user