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:
@@ -46,6 +46,8 @@ SSHKV_OIDC_GROUP_READER=sshkv-readers
|
||||
# SSHKV_OIDC_CLIENT_ID=sshkv-dev
|
||||
# SSHKV_OIDC_CLIENT_SECRET=sshkv-dev-geheim
|
||||
# SSHKV_OIDC_NAME=Mock-OIDC
|
||||
# Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen.
|
||||
# SSHKV_OIDC_HTTP_ERLAUBT=1
|
||||
|
||||
# --- Notfall-Admin -------------------------------------------------------------
|
||||
# Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
@@ -1,28 +1,37 @@
|
||||
# #004 Anmeldung, Rollen und erneute Bestätigung
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** sicherheit
|
||||
**Geschätzt:** 5 h **Tatsächlich:** –
|
||||
**Geschätzt:** 5 h **Tatsächlich:** 0 h 45 min
|
||||
|
||||
## Ziel
|
||||
Anmeldung per OIDC (authentik, in der Entwicklung Mock-Anbieter) und lokaler Notfall-Admin mit Passwort + TOTP. Rollen Admin/Operator/Leser, Re-Auth für kritische Aktionen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Sitzungs-Cookie (`jose`), OIDC mit PKCE (`openid-client`)
|
||||
- [ ] Rollen aus authentik-Gruppen, bei jeder Anmeldung neu
|
||||
- [ ] Notfall-Admin: argon2id + TOTP (Pflicht), Einrichtung beim ersten Login
|
||||
- [ ] Re-Auth: OIDC `prompt=login`/`max_age=0` + `auth_time`, Notfall-Admin Passwort + TOTP; Gültigkeit 5 min
|
||||
- [ ] Rechtetabelle `darf()`, jede Seite/Route prüft selbst; struktureller Test
|
||||
- [ ] Rate-Limit für Anmeldung und Re-Auth
|
||||
- [ ] Mock-OIDC-Anbieter in `docker-compose.dev.yml`
|
||||
- [ ] Proxy mit CSP-Nonce und Sicherheits-Kopfzeilen
|
||||
- [x] Sitzungs-Cookie (`jose`), OIDC mit PKCE (`openid-client`)
|
||||
- [x] Rollen aus authentik-Gruppen, bei jeder Anmeldung neu
|
||||
- [x] Notfall-Admin: argon2id + TOTP (Pflicht), Einrichtung beim ersten Login
|
||||
- [x] Re-Auth: OIDC `prompt=login`/`max_age=0` + `auth_time`, Notfall-Admin Passwort + TOTP; Gültigkeit 5 min
|
||||
- [x] Rechtetabelle `darf()`, jede Seite/Route prüft selbst; struktureller Test
|
||||
- [x] Rate-Limit für Anmeldung und Re-Auth
|
||||
- [x] Mock-OIDC-Anbieter in `docker-compose.dev.yml`
|
||||
- [x] Proxy mit CSP-Nonce und Sicherheits-Kopfzeilen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Ohne Anmeldung keine Seite erreichbar
|
||||
- [ ] Operator sieht keine Private-Key-Funktionen
|
||||
- [ ] Re-Auth älter als 5 min wird abgelehnt
|
||||
- [x] Ohne Anmeldung keine Seite erreichbar
|
||||
- [x] Operator sieht keine Private-Key-Funktionen
|
||||
- [x] Re-Auth älter als 5 min wird abgelehnt
|
||||
|
||||
## Abhängigkeiten
|
||||
#003
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Kein Auth.js**, sondern Sitzung (`jose`) und OIDC (`openid-client`) aus Laufzettel übernommen und angepasst.
|
||||
- **Ohne sshkv-Gruppe kein Zugang** – auch kein Lesezugriff (Abweichung von Laufzettel, das LESER vergibt).
|
||||
- **TOTP selbst implementiert** (RFC 6238, Testvektoren aus Anhang B); jeder Code gilt einmal (`totpLetzterSchritt`).
|
||||
- **TOTP-Einrichtung:** Das Geheimnis lebt bis zur Bestätigung nur in einem signierten httpOnly-Cookie (Pfad `/anmelden`, 10 min); gespeichert wird es erst nach passendem Code, verschlüsselt mit `SSHKV_APP_SECRET`.
|
||||
- **Re-Auth bei OIDC:** `prompt=login`, `max_age=0`, Prüfung von `auth_time` gegen den Startzeitpunkt (30 s Toleranz). Gültigkeit 5 min, gespeichert in `Benutzer.bestaetigtAm` – serverseitig, nicht im Cookie.
|
||||
- **Sitzungsdauer 8 h** statt 12 h.
|
||||
- **Mock-OIDC über http** nur mit `SSHKV_OIDC_HTTP_ERLAUBT=1` (openid-client verweigert http zu Recht).
|
||||
- Struktureller Test `tests/zugang.test.ts`: jede `page.tsx`/`route.ts` prüft selbst oder steht namentlich in den Ausnahmen.
|
||||
- E2E (`pnpm test:e2e`, gebauter Stand): OIDC-Admin, OIDC ohne Gruppe, Notfall-Admin mit TOTP-Einrichtung und Re-Auth – 4/4 grün.
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@
|
||||
"icons": "node scripts/icons.mjs",
|
||||
"postinstall": "prisma generate",
|
||||
"prepare": "husky",
|
||||
"build:all": "pnpm build && pnpm build:worker"
|
||||
"build:all": "pnpm build && pnpm build:worker",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,mts,mjs}": [
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Ende-zu-Ende-Ablaeufe gegen den **gebauten** Stand (CLAUDE.md):
|
||||
* pnpm build && pnpm start &
|
||||
* E2E_APP_URL=http://127.0.0.1:3400 pnpm test:e2e
|
||||
* Braucht den Mock-OIDC aus docker-compose.test.yml.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "tests/e2e",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3400",
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
+16
-1
@@ -19,7 +19,9 @@ import { chromium } from "@playwright/test";
|
||||
const BASIS = (process.env["SSHKV_URL"] ?? "http://localhost:3400").replace(/\/$/, "");
|
||||
const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/bilder");
|
||||
|
||||
const SEITEN = [
|
||||
/** `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004). */
|
||||
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean }[] = [
|
||||
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
||||
{ name: "uebersicht", pfad: "/" },
|
||||
{ name: "schluessel-detail", pfad: "/schluessel/beispiel" },
|
||||
];
|
||||
@@ -46,7 +48,20 @@ async function main() {
|
||||
colorScheme: "dark",
|
||||
});
|
||||
const seite = await kontext.newPage();
|
||||
let angemeldet = false;
|
||||
for (const s of seiten) {
|
||||
if (!s.oeffentlich && !angemeldet) {
|
||||
// Anmeldung ueber den Mock-OIDC aus docker-compose.test.yml, Rolle Admin.
|
||||
await seite.goto(`${BASIS}/anmelden`);
|
||||
await seite
|
||||
.getByRole("link", { name: /anmelden/i })
|
||||
.first()
|
||||
.click();
|
||||
await seite.locator('button[data-rolle="admin"]').click();
|
||||
await seite.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await seite.waitForURL(`${BASIS}/`);
|
||||
angemeldet = true;
|
||||
}
|
||||
const antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" });
|
||||
if (!antwort?.ok()) {
|
||||
fehler.push(`${s.pfad}: HTTP ${antwort?.status() ?? "keine Antwort"}`);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default function Einstellungen() {
|
||||
export default async function Einstellungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Einstellungen"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Hostgruppen" };
|
||||
|
||||
export default function Hostgruppen() {
|
||||
export default async function Hostgruppen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Hostgruppen"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Hosts" };
|
||||
|
||||
export default function Hosts() {
|
||||
export default async function Hosts() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Hosts"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { HuellenBenutzer } from "@/components/huelle/typen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
/**
|
||||
* Liefert den Benutzer, den die Seitenleiste anzeigt.
|
||||
*
|
||||
* TODO (#004): durch die echte Sitzung ersetzen (Benutzer + Rolle aus der Datenbank,
|
||||
* Herkunft „authentik“ oder „Notfall-Admin“) und ohne Sitzung zur Anmeldung umleiten.
|
||||
* Bis dahin ein fester Beispielbenutzer, damit der Prototyp ohne Anmeldung läuft.
|
||||
* async schon jetzt, damit sich die Aufrufer beim Umstieg nicht ändern müssen.
|
||||
* Der Benutzer fuer die Seitenleiste – aus der echten Sitzung (#004). Ohne Sitzung leitet
|
||||
* `sitzungErforderlich` zur Anmeldung weiter.
|
||||
*/
|
||||
export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
|
||||
return { name: "Florian Wilde", rolle: "ADMIN", herkunft: "authentik" };
|
||||
const { benutzer, sitzung } = await sitzungErforderlich();
|
||||
return {
|
||||
name: benutzer.name,
|
||||
rolle: benutzer.rolle,
|
||||
herkunft:
|
||||
sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
@@ -33,7 +34,9 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
|
||||
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
|
||||
* folgt in #026; Aufbau und Komponenten bleiben.
|
||||
*/
|
||||
export default function Uebersicht() {
|
||||
export default async function Uebersicht() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Personen" };
|
||||
|
||||
export default function Personen() {
|
||||
export default async function Personen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit-Log" };
|
||||
|
||||
export default function Protokoll() {
|
||||
export default async function Protokoll() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Audit-Log"
|
||||
|
||||
@@ -20,11 +20,14 @@ import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit, tageBis } from "@/lib/format";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
|
||||
|
||||
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
|
||||
export default function SchluesselBeispiel() {
|
||||
export default async function SchluesselBeispiel() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel" };
|
||||
|
||||
export default function Schluessel() {
|
||||
export default async function Schluessel() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Platzhalterseite
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Vorlagen" };
|
||||
|
||||
export default function Vorlagen() {
|
||||
export default async function Vorlagen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Vorlagen"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Zuweisungen" };
|
||||
|
||||
export default function Zuweisungen() {
|
||||
export default async function Zuweisungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Zuweisungen"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { NotfallFormular } from "@/components/anmeldung/NotfallFormular";
|
||||
import { buttonKlassen, Formularfehler } from "@/components/ui";
|
||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
|
||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Anmelden" };
|
||||
|
||||
/** Codes aus /api/auth/oidc/rueckruf – nie der Meldungstext selbst in der Adresse. */
|
||||
const FEHLER: Record<string, string> = {
|
||||
"oidc-aus": "Die Anmeldung über den Identitätsanbieter ist nicht eingerichtet.",
|
||||
"oidc-start": "Der Identitätsanbieter ist nicht erreichbar. Bitte später erneut versuchen.",
|
||||
"oidc-rueckruf": "Die Anmeldung beim Identitätsanbieter ist fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"keine-gruppe":
|
||||
"Ihr Konto ist keiner SSHKeyVault-Gruppe zugeordnet. Bitte beim Administrator Zugriff beantragen.",
|
||||
"konto-konflikt":
|
||||
"Für diese E-Mail-Adresse besteht bereits ein anderes Konto. Bitte den Administrator ansprechen.",
|
||||
deaktiviert: "Dieses Konto ist deaktiviert.",
|
||||
"fremdes-konto": "Die Bestätigung kam von einem anderen Konto als dem angemeldeten.",
|
||||
allgemein: "Die Anmeldung ist fehlgeschlagen.",
|
||||
};
|
||||
|
||||
export default async function AnmeldenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ ziel?: string; fehler?: string; abgemeldet?: string }>;
|
||||
}) {
|
||||
const parameter = await searchParams;
|
||||
const ziel = sicheresZiel(parameter.ziel);
|
||||
if (await angemeldet()) redirect(ziel);
|
||||
|
||||
const oidc = oidcAktiv();
|
||||
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
|
||||
|
||||
return (
|
||||
<AnmeldeRahmen
|
||||
titel="Anmelden"
|
||||
text={parameter.abgemeldet ? "Sie wurden abgemeldet." : "Zentrale Verwaltung von SSH-Schlüsseln."}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Formularfehler text={fehler} />
|
||||
{oidc ? (
|
||||
// Ein echter Link statt next/link: die Route leitet zum Anbieter weiter, ein
|
||||
// Vorladen durch den Router wuerde einen Anmeldefluss anstossen.
|
||||
<a
|
||||
href={`/api/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
|
||||
className={buttonKlassen("primaer", false, "w-full")}
|
||||
>
|
||||
Mit {oidcName()} anmelden
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-muted text-[14px]">
|
||||
Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv.
|
||||
</p>
|
||||
)}
|
||||
<details className="group border-border rounded-box border" open={!oidc}>
|
||||
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
|
||||
Notfallzugang
|
||||
</summary>
|
||||
<div className="border-border border-t p-4">
|
||||
<NotfallFormular ziel={ziel} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import QRCode from "qrcode";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { TotpFormular } from "@/components/anmeldung/TotpFormular";
|
||||
import { MonoKopieren } from "@/components/ui";
|
||||
import { totpAdresse } from "@/lib/totp";
|
||||
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
|
||||
|
||||
export const metadata: Metadata = { title: "TOTP einrichten" };
|
||||
|
||||
/**
|
||||
* Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist
|
||||
* nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat.
|
||||
*/
|
||||
export default async function TotpSeite() {
|
||||
const einrichtung = await totpEinrichtungLesen();
|
||||
if (!einrichtung) redirect("/anmelden");
|
||||
|
||||
const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin");
|
||||
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
|
||||
const svg = await QRCode.toString(adresse, {
|
||||
type: "svg",
|
||||
margin: 1,
|
||||
color: { dark: "#0D1013", light: "#E8ECEF" },
|
||||
});
|
||||
|
||||
return (
|
||||
<AnmeldeRahmen
|
||||
titel="Zweiten Faktor einrichten"
|
||||
text="Code mit einer Authenticator-App scannen und den angezeigten Code eingeben."
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div
|
||||
className="mx-auto w-[200px] overflow-hidden rounded-[10px]"
|
||||
aria-label="QR-Code für die Authenticator-App"
|
||||
role="img"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
<MonoKopieren wert={einrichtung.geheimnis} beschriftung="Geheimnis" umbrechen />
|
||||
<TotpFormular />
|
||||
</div>
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { BestaetigenFormular } from "@/components/anmeldung/BestaetigenFormular";
|
||||
import { buttonKlassen } from "@/components/ui";
|
||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||
import { oidcName } from "@/server/auth/oidc";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestätigen" };
|
||||
|
||||
/** Erneute Bestaetigung vor kritischen Aktionen (Private Key anzeigen, Massenwiderruf …). */
|
||||
export default async function BestaetigenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ ziel?: string }>;
|
||||
}) {
|
||||
const ziel = sicheresZiel((await searchParams).ziel);
|
||||
const { benutzer } = await sitzungErforderlich("lesen", `/bestaetigen?ziel=${encodeURIComponent(ziel)}`);
|
||||
|
||||
return (
|
||||
<AnmeldeRahmen
|
||||
titel="Anmeldung bestätigen"
|
||||
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
|
||||
>
|
||||
{benutzer.istNotfall ? (
|
||||
<BestaetigenFormular ziel={ziel} />
|
||||
) : (
|
||||
<a
|
||||
href={`/api/auth/oidc/start?erneut=1&ziel=${encodeURIComponent(ziel)}`}
|
||||
className={buttonKlassen("primaer", false, "w-full")}
|
||||
>
|
||||
Bei {oidcName()} erneut anmelden
|
||||
</a>
|
||||
)}
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { Button } from "@/components/ui";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Kein Zugriff" };
|
||||
|
||||
export default async function KeinZugriffSeite() {
|
||||
await sitzungErforderlich("lesen");
|
||||
return (
|
||||
<AnmeldeRahmen titel="Kein Zugriff" text="Für diesen Bereich fehlt Ihrer Rolle die Berechtigung.">
|
||||
<Button href="/" variante="sekundaer" className="w-full">
|
||||
Zur Übersicht
|
||||
</Button>
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Marke } from "@/components/marke";
|
||||
|
||||
/** Zentrierte Karte fuer Anmeldung, TOTP und Bestaetigung (#004). */
|
||||
export function AnmeldeRahmen({
|
||||
titel,
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
titel: string;
|
||||
text?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="bg-bg flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<Marke bildGroesse={40} textGroesse={26} />
|
||||
</div>
|
||||
<section className="bg-surface border-border rounded-karte border p-6 sm:p-7">
|
||||
<h1 className="font-display text-text text-[22px] font-semibold">{titel}</h1>
|
||||
{text && <div className="text-muted mt-1.5 text-[14px]">{text}</div>}
|
||||
<div className="mt-6">{children}</div>
|
||||
</section>
|
||||
<p className="text-muted mt-6 text-center text-[12.5px]">
|
||||
Nur im internen Netz. Alle Anmeldungen werden protokolliert.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
|
||||
export function BestaetigenFormular({ ziel }: { ziel: string }) {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallBestaetigenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Feld
|
||||
beschriftung="Passwort"
|
||||
name="passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Code aus der Authenticator-App"
|
||||
name="code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
mono
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Prüfe …" : "Bestätigen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { notfallAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
/** Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code (#004). */
|
||||
export function NotfallFormular({ ziel }: { ziel: string }) {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallAnmeldenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Feld beschriftung="Anmeldename" name="anmeldename" autoComplete="username" required />
|
||||
<Feld
|
||||
beschriftung="Passwort"
|
||||
name="passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Code aus der Authenticator-App"
|
||||
name="code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9 ]*"
|
||||
mono
|
||||
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
|
||||
/>
|
||||
<Button type="submit" variante="sekundaer" disabled={laeuft}>
|
||||
{laeuft ? "Prüfe …" : "Mit Notfallzugang anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { totpEinrichtenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
export function TotpFormular() {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(totpEinrichtenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Feld
|
||||
beschriftung="Code zur Bestätigung"
|
||||
name="code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9 ]*"
|
||||
mono
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Prüfe …" : "TOTP einrichten und anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Bildmarke, Kurzname, Marke, Wortmarke } from "@/components/marke";
|
||||
@@ -86,6 +87,17 @@ function BenutzerKasten({ benutzer }: { benutzer: HuellenBenutzer }) {
|
||||
{ROLLEN_TEXT[benutzer.rolle]} · über {benutzer.herkunft}
|
||||
</p>
|
||||
</div>
|
||||
{/* Abmelden nur per POST, damit ein fremder Link niemanden abmelden kann (#004). */}
|
||||
<form action="/api/auth/abmelden" method="post" className="ml-auto">
|
||||
<button
|
||||
type="submit"
|
||||
title="Abmelden"
|
||||
aria-label="Abmelden"
|
||||
className="text-muted hover:text-text hover:bg-raised rounded-feld flex size-11 items-center justify-center"
|
||||
>
|
||||
<LogOut size={17} aria-hidden />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Eingabefeld mit Beschriftung (#004). Mindesthoehe 44 px wie alle Klickflaechen. */
|
||||
export function Feld({
|
||||
beschriftung,
|
||||
hinweis,
|
||||
mono = false,
|
||||
className,
|
||||
id,
|
||||
name,
|
||||
...rest
|
||||
}: ComponentProps<"input"> & { beschriftung: string; hinweis?: string; mono?: boolean }) {
|
||||
const feldId = id ?? `feld-${name}`;
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-1.5", className)}>
|
||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<input
|
||||
id={feldId}
|
||||
name={name}
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"bg-bg border-border-strong text-text rounded-feld min-h-11 border px-3 text-[15px]",
|
||||
"placeholder:text-muted/70 focus-visible:border-accent",
|
||||
mono && "font-mono tracking-wide",
|
||||
)}
|
||||
/>
|
||||
{hinweis && <p className="text-muted text-[12.5px]">{hinweis}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fehlermeldung eines Formulars – mit role=alert, damit Screenreader sie vorlesen. */
|
||||
export function Formularfehler({ text }: { text?: string }) {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[14px]"
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export { MonoKopieren } from "./MonoKopieren";
|
||||
export { Seitenkopf } from "./Seitenkopf";
|
||||
export { Statuspunkt, type HostStatus } from "./Statuspunkt";
|
||||
export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";
|
||||
export { Feld, Formularfehler } from "./Feld";
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { kurzzeitTokenErzeugen } from "@/lib/kurzzeit-token";
|
||||
import { totpGeheimnisErzeugen } from "@/lib/totp";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung";
|
||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
EINRICHTUNG_ZWECK,
|
||||
TOTP_EINRICHTUNG_COOKIE,
|
||||
totpEinrichtungLesen,
|
||||
} from "@/server/auth/totp-einrichtung";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
notfallAnmelden,
|
||||
notfallBestaetigen,
|
||||
totpEinrichtungAbschliessen,
|
||||
} from "@/server/services/anmeldung";
|
||||
|
||||
/**
|
||||
* Formular-Aktionen rund um die Anmeldung (#004). Duenn: Eingaben pruefen, Service rufen,
|
||||
* Cookie setzen, weiterleiten. Fehler kommen als Text zurueck, nie als Ausnahme – sonst
|
||||
* zeigte Next eine Fehlerseite statt der Meldung im Formular.
|
||||
*/
|
||||
export type FormularZustand = { readonly fehler?: string };
|
||||
|
||||
const notfallSchema = z.object({
|
||||
anmeldename: z.string().trim().min(1).max(64),
|
||||
passwort: z.string().min(1).max(256),
|
||||
code: z.string().trim().max(10).default(""),
|
||||
ziel: z.string().max(512).optional(),
|
||||
});
|
||||
|
||||
export async function notfallAnmeldenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const eingabe = notfallSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
|
||||
const ziel = sicheresZiel(eingabe.data.ziel);
|
||||
|
||||
let ergebnis;
|
||||
try {
|
||||
ergebnis = await notfallAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
if (ergebnis.art === "totpEinrichten") {
|
||||
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
|
||||
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
|
||||
const token = await kurzzeitTokenErzeugen(
|
||||
EINRICHTUNG_ZWECK,
|
||||
{ benutzerId: ergebnis.benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
);
|
||||
(await cookies()).set(TOTP_EINRICHTUNG_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: ueberHttps(),
|
||||
path: "/anmelden",
|
||||
maxAge: EINRICHTUNG_SEKUNDEN,
|
||||
});
|
||||
redirect("/anmelden/totp");
|
||||
}
|
||||
|
||||
await sitzungSetzen(ergebnis.benutzer, "notfall");
|
||||
redirect(ziel);
|
||||
}
|
||||
|
||||
export async function totpEinrichtenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const einrichtung = await totpEinrichtungLesen();
|
||||
if (!einrichtung) return { fehler: "Die Einrichtung ist abgelaufen. Bitte erneut anmelden." };
|
||||
const code = String(formular.get("code") ?? "");
|
||||
let benutzer;
|
||||
try {
|
||||
benutzer = await totpEinrichtungAbschliessen(einrichtung.benutzerId, einrichtung.geheimnis, code);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
const laden = await cookies();
|
||||
laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" });
|
||||
await sitzungSetzen(benutzer, "notfall");
|
||||
redirect(sicheresZiel(einrichtung.ziel));
|
||||
}
|
||||
|
||||
const bestaetigenSchema = z.object({
|
||||
passwort: z.string().min(1).max(256),
|
||||
code: z.string().trim().min(6).max(10),
|
||||
ziel: z.string().max(512).optional(),
|
||||
});
|
||||
|
||||
export async function notfallBestaetigenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const vorhanden = await angemeldet();
|
||||
if (!vorhanden) redirect("/anmelden");
|
||||
const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." };
|
||||
try {
|
||||
await notfallBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
redirect(sicheresZiel(eingabe.data.ziel));
|
||||
}
|
||||
+11
-1
@@ -80,7 +80,17 @@ function konfigurationHolen(): Promise<oidc.Configuration> {
|
||||
entdeckt = {
|
||||
aussteller,
|
||||
kundennummer,
|
||||
konfiguration: oidc.discovery(new URL(aussteller), kundennummer, geheimnis).catch((fehler) => {
|
||||
konfiguration: oidc
|
||||
.discovery(
|
||||
new URL(aussteller),
|
||||
kundennummer,
|
||||
geheimnis,
|
||||
undefined,
|
||||
// Nur fuer den Mock-Anbieter der Entwicklung (#011): openid-client verweigert http zu
|
||||
// Recht. Im Betrieb bleibt das aus; authentik spricht ohnehin https.
|
||||
umgebung("SSHKV_OIDC_HTTP_ERLAUBT") === "1" ? { execute: [oidc.allowInsecureRequests] } : undefined,
|
||||
)
|
||||
.catch((fehler) => {
|
||||
// Beim naechsten Versuch erneut holen, statt den Fehler ewig zu behalten.
|
||||
entdeckt = undefined;
|
||||
throw new OidcFehler(`Discovery bei ${aussteller} fehlgeschlagen.`, { cause: fehler });
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||||
|
||||
/**
|
||||
* Laufende TOTP-Einrichtung des Notfall-Admins (#004). Absichtlich kein "use server"-Modul:
|
||||
* sonst waere das Lesen als Server Action von aussen aufrufbar.
|
||||
*/
|
||||
export const TOTP_EINRICHTUNG_COOKIE = "sshkv_totp_einrichtung";
|
||||
export const EINRICHTUNG_ZWECK = "totp-einrichtung";
|
||||
/** Zehn Minuten fuer QR-Code scannen und ersten Code eintippen. */
|
||||
export const EINRICHTUNG_SEKUNDEN = 600;
|
||||
|
||||
export async function totpEinrichtungLesen(): Promise<
|
||||
{ benutzerId: string; geheimnis: string; ziel: string } | undefined
|
||||
> {
|
||||
const token = (await cookies()).get(TOTP_EINRICHTUNG_COOKIE)?.value;
|
||||
const werte = await kurzzeitTokenLesen(EINRICHTUNG_ZWECK, token);
|
||||
if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined;
|
||||
return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts"],
|
||||
exclude: ["tests/integration/**", "node_modules/**", ".claude/**"],
|
||||
exclude: ["tests/integration/**", "tests/e2e/**", "node_modules/**", ".claude/**"],
|
||||
environment: "node",
|
||||
globalSetup: ["tests/global-setup.ts"],
|
||||
setupFiles: ["tests/setup-env.ts"],
|
||||
|
||||
Reference in New Issue
Block a user