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 0000000..08eb0c3 Binary files /dev/null and b/docs/anleitung/bilder/login.png differ 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 ? ( + <> + +