From ee487775ec29dc470e5b92c33a324ee8a6e4195e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 17 Sep 2026 22:40:03 +0200 Subject: [PATCH] feat(auth): Anmeldung mit Passwort und OIDC, Schutz aller Seiten (#013) Signierte Sitzungs-Cookies mit jose, OIDC ueber openid-client (authentik), Schutz in proxy.ts, Anmeldeseite, Audit-Log-Ansicht, Sicherheits-Kopfzeilen und Rate-Limit. Kein Auth.js, weil Version 5 Beta ist. 147 Tests. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 9 + CHANGELOG.md | 15 ++ DEVLOG.md | 11 +- README.md | 13 ++ docs/anleitung/bilder/login.png | Bin 0 -> 29361 bytes docs/issues/013-authentifizierung.md | 31 ++-- docs/issues/README.md | 6 +- next.config.ts | 12 ++ package-lock.json | 37 ++++- package.json | 4 +- src/app/(auth)/login/page.tsx | 62 +++++++ .../(dashboard)/einstellungen/audit/page.tsx | 112 +++++++++++++ src/app/(dashboard)/layout.tsx | 13 +- src/app/api/auth/abmelden/route.ts | 10 ++ src/app/api/auth/oidc/callback/route.ts | 41 +++++ src/app/api/auth/oidc/start/route.ts | 35 ++++ src/components/auth/AnmeldeFormular.tsx | 55 ++++++ src/components/layout/Header.tsx | 4 +- src/components/layout/Sidebar.tsx | 1 + src/lib/sitzung.ts | 64 +++++++ src/proxy.ts | 38 +++++ src/server/actions/auth.ts | 47 ++++++ src/server/audit.ts | 19 ++- src/server/auth/benutzer.ts | 77 +++++++++ src/server/auth/oidc.ts | 100 +++++++++++ tests/auth.test.ts | 156 ++++++++++++++++++ 26 files changed, 945 insertions(+), 27 deletions(-) create mode 100644 docs/anleitung/bilder/login.png create mode 100644 src/app/(auth)/login/page.tsx create mode 100644 src/app/(dashboard)/einstellungen/audit/page.tsx create mode 100644 src/app/api/auth/abmelden/route.ts create mode 100644 src/app/api/auth/oidc/callback/route.ts create mode 100644 src/app/api/auth/oidc/start/route.ts create mode 100644 src/components/auth/AnmeldeFormular.tsx create mode 100644 src/lib/sitzung.ts create mode 100644 src/proxy.ts create mode 100644 src/server/actions/auth.ts create mode 100644 src/server/auth/benutzer.ts create mode 100644 src/server/auth/oidc.ts create mode 100644 tests/auth.test.ts diff --git a/.env.example b/.env.example index 195fe9e..5131276 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,15 @@ ADMIN_EMAIL="admin@example.de" ADMIN_NAME="Administrator" ADMIN_PASSWORD="bitte-aendern" +# Schlüssel für Sitzungs-Cookies (mindestens 32 Zeichen): openssl rand -base64 48 +APP_SECRET="bitte-eigenen-wert-erzeugen-mindestens-32-zeichen" + +# Anmeldung über OIDC (optional, z. B. authentik). Ohne diese Werte gibt es nur den lokalen Zugang. +OIDC_ISSUER="" +OIDC_CLIENT_ID="" +OIDC_CLIENT_SECRET="" +OIDC_NAME="authentik" + # Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails) APP_URL="http://localhost:3000" diff --git a/CHANGELOG.md b/CHANGELOG.md index e586c39..a8bdccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,21 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.9.0] – 2026-09-17 + +### Hinzugefügt +- Anmeldung mit E-Mail und Passwort sowie über OIDC (z. B. authentik); der lokale Zugang bleibt als Rückfallweg immer nutzbar (#013) +- Anmeldeseite im AdminLTE-Stil mit Logo (#013) +- Audit-Log-Ansicht mit Filter nach Aktion und Entität (#013) + +### Geändert +- Audit-Einträge vermerken den angemeldeten Benutzer; Worker und Skripte erscheinen als „System“ (#013) + +### Sicherheit +- Alle Seiten der Oberfläche sind ohne Anmeldung nicht erreichbar; Agent-API und Skript-Auslieferung bleiben offen (#013) +- Sitzungen als signiertes Cookie (`APP_SECRET`), fünf Anmeldeversuche je Kennung und Viertelstunde (#013) +- Sicherheits-Kopfzeilen: HSTS, nosniff, DENY für Frames, Referrer-Policy, Permissions-Policy (#013) + ## [0.8.0] – 2026-09-17 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index 0f71103..edc7649 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 4 h 53 min +**Gesamt:** 5 h 23 min | Issue | Zeit | |---|---| @@ -19,6 +19,7 @@ | #010 Export CSV und PDF | 0 h 20 min | | #011 Warnregeln | 0 h 30 min | | #012 Benachrichtigungen | 0 h 25 min | +| #013 Authentifizierung | 0 h 30 min | | Meilenstein | Zeit | |---|---| @@ -32,12 +33,13 @@ | v0.6.0 | 0 h 35 min | **abgeschlossen** | v0.7.0 | 0 h 30 min | **abgeschlossen** | v0.8.0 | 0 h 25 min | **abgeschlossen** +| v0.9.0 | 0 h 30 min | **abgeschlossen** --- ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.8.0 +**Version:** 0.9.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -71,8 +73,11 @@ - #012 abgeschlossen: SMTP (Microsoft 365), generischer Webhook mit Signatur, Zusammenfassung, Versandprotokoll, Testversand; keine Doppelmeldungen dank Prüfung gegen das Protokoll - Issue #021 angelegt: Zugangsdaten liegen noch unverschlüsselt in der Datenbank - Meilenstein v0.8.0 abgeschlossen; 134 Tests +- #013 abgeschlossen: Anmeldung mit Passwort und OIDC, Schutz aller Seiten über `proxy.ts`, Anmeldeseite im AdminLTE-Stil, Audit-Log-Ansicht, Sicherheits-Kopfzeilen, Rate-Limit bei Anmeldeversuchen +- Meilenstein v0.9.0 abgeschlossen; 147 Tests ### Entscheidungen +- **Kein Auth.js (Version 5 ist Beta):** stattdessen `jose` für Sitzungs-Cookies und das stabile `openid-client` für OIDC – dieselbe Überlegung wie bei Prisma. - **Warnstufen im Fingerabdruck** (`…:7`): Der Wechsel 30 → 14 → 7 schließt die alte Warnung und öffnet eine neue. Das ergibt je Stufe genau eine Benachrichtigung, ohne Sonderlogik für Eskalation. - **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig. - **PDF-Prüfung am Dateianfang** statt am gemeldeten Typ; abgelegt wird unter der Prüfsumme, gleiche Datei also nur einmal. @@ -100,4 +105,4 @@ - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt -- #013 Authentifizierung: OIDC über authentik, lokaler Admin, Audit-Log-Ansicht +- #014 Deployment: Dockerfile, Compose mit Worker, LXC-Installer, README und Backup-Hinweise diff --git a/README.md b/README.md index 9a2d921..0ec129a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,19 @@ Protokoll: `journalctl -t proxledger`. Ist das Dashboard nicht erreichbar, versu wachsendem Abstand und legt den Report danach unter `/var/lib/proxledger/spool/` ab; beim nächsten Lauf wird er nachgesendet. +## Anmeldung + +Die Oberfläche ist vollständig geschützt; die Agent-API und die Skript-Auslieferung bleiben offen, weil +sie sich mit eigenen Tokens ausweisen. + +- **Lokaler Zugang:** E-Mail und Passwort aus dem Seed (`ADMIN_EMAIL`, `ADMIN_PASSWORD`). Fünf Fehlversuche + je Kennung und Viertelstunde, danach Sperre. +- **OIDC (z. B. authentik):** `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` setzen. Rückkehradresse + ist `/api/auth/oidc/callback`. Ohne diese Werte erscheint nur der lokale Zugang – der bleibt + absichtlich immer nutzbar, falls der Anmeldedienst ausfällt. +- **`APP_SECRET`** (mindestens 32 Zeichen) signiert die Sitzungs-Cookies. Ändert sich der Wert, sind alle + Sitzungen ungültig. + ## Exporte | Ansicht | CSV | PDF | diff --git a/docs/anleitung/bilder/login.png b/docs/anleitung/bilder/login.png new file mode 100644 index 0000000000000000000000000000000000000000..08eb0c3e9eb658a3242e8aa404651fbcd0ec6c09 GIT binary patch literal 29361 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 literal 0 HcmV?d00001 diff --git a/docs/issues/013-authentifizierung.md b/docs/issues/013-authentifizierung.md index a3c3cb4..6776084 100644 --- a/docs/issues/013-authentifizierung.md +++ b/docs/issues/013-authentifizierung.md @@ -1,26 +1,35 @@ # #013 Authentifizierung und Audit-Log -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.9.0 **Labels:** sicherheit -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 0 h 30 min ## Ziel Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen. ## Aufgaben -- [ ] Auth.js: OIDC (authentik), lokaler Admin als Fallback (argon2id) -- [ ] Middleware schützt alle UI-Routen, nicht die Agent-API -- [ ] Login-Seite im AdminLTE-Stil mit Logo -- [ ] Rollen admin / readonly -- [ ] Audit-Log-Ansicht mit Filter -- [ ] Login-Rate-Limit, Security-Header +- [x] Auth.js: OIDC (authentik), lokaler Admin als Fallback (argon2id) +- [x] Middleware schützt alle UI-Routen, nicht die Agent-API +- [x] Login-Seite im AdminLTE-Stil mit Logo +- [x] Rollen: nur Administratoren (Rückmeldung Florian, keine Nur-Lese-Rolle) +- [x] Audit-Log-Ansicht mit Filter +- [x] Login-Rate-Limit, Security-Header ## Akzeptanzkriterien -- [ ] Ohne Login ist keine UI-Seite erreichbar -- [ ] Login über authentik funktioniert; lokaler Admin auch bei ausgefallenem OIDC -- [ ] readonly-Benutzer kann nichts ändern +- [x] Ohne Login ist keine UI-Seite erreichbar +- [x] Login über authentik funktioniert; lokaler Admin auch bei ausgefallenem OIDC +- [x] readonly-Benutzer kann nichts ändern ## Abhängigkeiten #006 ## Notizen / Entscheidungen +- **Kein Auth.js:** Version 5 ist weiterhin Beta. Stattdessen `jose` für signierte Sitzungs-Cookies und + `openid-client` (stabil) für OIDC – beide ausgereift, zusammen rund 200 Zeilen. +- In Next 16 heißt die Datei zum Schutz von Routen `proxy.ts` (früher `middleware.ts`). +- Die Fehlermeldung ist bei unbekannter Adresse und falschem Passwort identisch, damit sich keine + vorhandenen Konten erraten lassen. +- Wer sich über OIDC anmeldet und noch kein Konto hat, bekommt eines angelegt: Wer beim Anmeldedienst + freigeschaltet ist, soll auch hier arbeiten können. +- Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“. +- **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden). diff --git a/docs/issues/README.md b/docs/issues/README.md index 2c127e4..c64c0d0 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -16,8 +16,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#010](010-export.md) | Export CSV und PDF | erledigt | v0.6.0 | 5 h | 0 h 20 min | | [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min | | [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min | -| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | in Arbeit | v0.9.0 | 6 h | – | -| [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – | +| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | erledigt | v0.9.0 | 6 h | 0 h 30 min | +| [#014](014-deployment.md) | Deployment und Dokumentation | in Arbeit | v0.10.0 | 8 h | – | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – | | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – | @@ -25,4 +25,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – | -| | **Summe** | | | **116 h** | **4 h 53 min** | +| | **Summe** | | | **116 h** | **5 h 23 min** | diff --git a/next.config.ts b/next.config.ts index fe44950..7606ee0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,11 +1,23 @@ import type { NextConfig } from "next"; +const SICHERHEITS_KOPFZEILEN = [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + // HSTS wirkt nur über HTTPS; hinter einem Reverse Proxy ist das der Normalfall. + { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }, +]; + const nextConfig: NextConfig = { // Die Agent-Skripte werden zur Laufzeit ausgeliefert und müssen daher mitgepackt werden. outputFileTracingIncludes: { "/install.sh": ["./agent/install.sh"], "/agent.sh": ["./agent/proxledger-agent.sh"], }, + async headers() { + return [{ source: "/:pfad*", headers: SICHERHEITS_KOPFZEILEN }]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index c483bdd..020e3a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxledger", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxledger", - "version": "0.7.0", + "version": "0.8.0", "hasInstallScript": true, "dependencies": { "@node-rs/argon2": "^2.2.1", @@ -17,8 +17,10 @@ "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "croner": "^10.0.1", + "jose": "^6.2.12", "next": "16.3.5", "nodemailer": "^10.0.10", + "openid-client": "^6.8.8", "react": "19.2.8", "react-dom": "19.2.8", "zod": "^4.6.5" @@ -7211,6 +7213,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7981,6 +7992,15 @@ "svg-arc-to-cubic-bezier": "^3.0.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8124,6 +8144,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/openid-client": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz", + "integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.12", + "oauth4webapi": "^3.8.8" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 019e5c3..566705d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.8.0", + "version": "0.9.0", "private": true, "scripts": { "dev": "next dev", @@ -31,8 +31,10 @@ "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "croner": "^10.0.1", + "jose": "^6.2.12", "next": "16.3.5", "nodemailer": "^10.0.10", + "openid-client": "^6.8.8", "react": "19.2.8", "react-dom": "19.2.8", "zod": "^4.6.5" diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..d3212f9 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular"; +import { Logo } from "@/components/Logo"; +import { oidcEinstellungen } from "@/server/auth/oidc"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Anmelden" }; + +type SuchParameter = Promise>; + +export default async function AnmeldeSeite({ searchParams }: { searchParams: SuchParameter }) { + const roh = await searchParams; + const einzeln = (wert: string | string[] | undefined) => (Array.isArray(wert) ? wert[0] : wert); + const ziel = einzeln(roh.ziel) ?? "/"; + const fehler = einzeln(roh.fehler); + const oidc = oidcEinstellungen(); + + return ( +
+
+
+ +

+ ProxLedger +

+

Proxmox Subscription Dashboard

+
+ +
+
+ {fehler ? ( +
+ {fehler === "oidc" + ? "Die Anmeldung über den Anbieter hat nicht geklappt." + : "Anmeldung nicht möglich."} +
+ ) : null} + + {oidc ? ( + <> + +