diff --git a/CHANGELOG.md b/CHANGELOG.md index fefff13..2a45430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.18.0] – 2026-09-19 + +### Hinzugefügt +- Drei Rollen: Administrator, Techniker und Kunde. Kundenkonten sehen nur die eigenen Hosts und Rechnungen – ohne Einkaufspreise und Marge – und dürfen Notizen schreiben sowie Warnungen quittieren (#027) +- Benutzerverwaltung unter *Einstellungen → Benutzer*: anlegen, Rolle ändern, sperren, Passwort setzen, löschen (#027) +- Anmeldung mit Passkeys (WebAuthn), gleichwertig neben Passwort und OIDC; eigene Seite *Meine Passkeys* (#028) +- Einstellung, welche Rolle automatisch angelegte OIDC-Konten bekommen (#027) + +### Behoben +- Neu über OIDC angelegte Konten hätten die Vorgabe des Datenmodells geerbt und wären Administratoren geworden; Vorgabe ist jetzt *Techniker* (#027) +- Das Screenshot-Skript meldete sich mit einem erfundenen Konto an – seit der Sitzungsprüfung gegen die Datenbank landete es auf der Anmeldeseite (#027) + ## [0.17.0] – 2026-09-19 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index c4b6301..0475c27 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 12 h 18 min +**Gesamt:** 14 h 23 min | Issue | Zeit | |---|---| @@ -32,6 +32,8 @@ | #024 OWASP-Härtung | 0 h 45 min | | #025 Einstellungen | 1 h 10 min | | #026 Auslieferung | 0 h 40 min | +| #027 Rollen und Benutzerverwaltung | 1 h 20 min | +| #028 Passkeys | 0 h 45 min | | Meilenstein | Zeit | |---|---| @@ -54,7 +56,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.17.0 +**Version:** 0.18.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -152,6 +154,10 @@ - Recherche dazu: Das Gerüst der community-scripts liegt inzwischen in einem eigenen Repository, und neue Skripte gehören zuerst nach ProxmoxVED. Eine Einreichung setzt öffentlichen Quelltext voraus – deshalb vorerst intern - Roadmap geschrieben: Bis 1.0.0 fehlt kein Code mehr, sondern Erprobung (PMG, PBS mit Lizenz, ein Monat Dauerbetrieb, einmal eine Sicherung zurückspielen) +- Rollen und Benutzerverwaltung (#027): Die Einschränkung sitzt in den Abfragen selbst, damit keine Seite vergessen kann, sie weiterzugeben. Sechs Tests prüfen, dass ein Kundenkonto weder fremde Hosts noch Einkaufspreise sieht +- Dabei aufgefallen: Neu angelegte OIDC-Konten hätten die Modellvorgabe ADMIN geerbt – behoben, mit eigener Einstellung und Test +- Passkeys (#028): passwortlos, mehrere je Konto, Aufgabe in einem kurzlebigen Cookie. Nur über HTTPS nutzbar – die Seite sagt es, statt still zu scheitern + ### Nächster Schritt - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md index 238eabc..d5b6137 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -18,6 +18,7 @@ auch mit Hosts hinter NAT. 8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten) 9. [Exporte](#9-exporte) 9a. [Einstellungen](#9a-einstellungen) +9b. [Benutzer, Rollen und Passkeys](#9b-benutzer-rollen-und-passkeys) 10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung) 11. [Fehlersuche](#11-fehlersuche) @@ -341,6 +342,46 @@ Zeitzone gilt für PDF, CSV und Benachrichtigungen. --- +## 9b. Benutzer, Rollen und Passkeys + +Unter *Einstellungen → Benutzer* legen Administratoren Konten an und vergeben eine von drei Rollen: + +![Benutzerverwaltung](bilder/benutzer.png) + +| Rolle | Darf | +|---|---| +| **Administrator** | alles, einschließlich Benutzer und Systemeinstellungen | +| **Techniker** | alles Fachliche: Hosts, Kunden, Rechnungen, Tokens, Warnregeln – aber keine Benutzer | +| **Kunde** | nur die Hosts und Rechnungen des eigenen Kunden, **ohne Einkaufspreise und Marge**; darf Notizen schreiben und Warnungen quittieren | + +Ein Kundenkonto ist an genau einen Kunden gebunden – das Feld erscheint, sobald die Rolle „Kunde“ +gewählt ist. Die Einschränkung sitzt in den Abfragen selbst, nicht in der Anzeige: Was ein Kundenkonto +nicht sehen darf, steht auch nicht im ausgelieferten HTML. + +Ein Passwort ist beim Anlegen freiwillig. Ohne Passwort entsteht ein Konto, das sich nur über OIDC oder +einen Passkey anmeldet. **Ein neues Passwort beendet alle laufenden Sitzungen dieses Kontos**, ebenso +das Sperren. + +### Passkeys + +Ein Passkey ersetzt das Passwort: Der Browser bestätigt die Anmeldung mit Fingerabdruck, Gesicht, PIN +oder einem Sicherheitsschlüssel. Jeder Benutzer verwaltet seine eigenen unter *Meine Passkeys*: + +![Passkeys](bilder/passkeys.png) + +Zwei Dinge sind wichtig: + +1. **Passkeys brauchen HTTPS.** Nur `localhost` ist ausgenommen. Wird das Dashboard über `http://` + im LAN aufgerufen, weist die Seite darauf hin und der Knopf bleibt gesperrt. +2. **Der Passkey hängt an der Adresse.** Wer später von `http://server:3000` auf + `https://proxledger.firma.de` wechselt, muss neu registrieren. Deshalb zuerst den Reverse Proxy + einrichten, dann Passkeys anlegen. + +Passwort, OIDC und Passkey stehen gleichberechtigt nebeneinander – der lokale Zugang bleibt als +Rückweg immer nutzbar. + +--- + ## 10. Sicherung und Aktualisierung **Sichern:** diff --git a/docs/anleitung/bilder/audit.png b/docs/anleitung/bilder/audit.png index 206ebae..4d07f1f 100644 Binary files a/docs/anleitung/bilder/audit.png and b/docs/anleitung/bilder/audit.png differ diff --git a/docs/anleitung/bilder/benachrichtigungen.png b/docs/anleitung/bilder/benachrichtigungen.png index b020bd6..20df2c1 100644 Binary files a/docs/anleitung/bilder/benachrichtigungen.png and b/docs/anleitung/bilder/benachrichtigungen.png differ diff --git a/docs/anleitung/bilder/benutzer.png b/docs/anleitung/bilder/benutzer.png new file mode 100644 index 0000000..12fd127 Binary files /dev/null and b/docs/anleitung/bilder/benutzer.png differ diff --git a/docs/anleitung/bilder/dashboard.png b/docs/anleitung/bilder/dashboard.png index 214201f..94b55a1 100644 Binary files a/docs/anleitung/bilder/dashboard.png and b/docs/anleitung/bilder/dashboard.png differ diff --git a/docs/anleitung/bilder/host-detail.png b/docs/anleitung/bilder/host-detail.png index 11d27dc..dc17b75 100644 Binary files a/docs/anleitung/bilder/host-detail.png and b/docs/anleitung/bilder/host-detail.png differ diff --git a/docs/anleitung/bilder/hosts.png b/docs/anleitung/bilder/hosts.png index 866afd8..5061f71 100644 Binary files a/docs/anleitung/bilder/hosts.png and b/docs/anleitung/bilder/hosts.png differ diff --git a/docs/anleitung/bilder/jahresbahn.png b/docs/anleitung/bilder/jahresbahn.png index 3a43f82..d937539 100644 Binary files a/docs/anleitung/bilder/jahresbahn.png and b/docs/anleitung/bilder/jahresbahn.png differ diff --git a/docs/anleitung/bilder/kunden.png b/docs/anleitung/bilder/kunden.png index c8eb81e..1ff119d 100644 Binary files a/docs/anleitung/bilder/kunden.png and b/docs/anleitung/bilder/kunden.png differ diff --git a/docs/anleitung/bilder/login.png b/docs/anleitung/bilder/login.png index c82c561..40f4441 100644 Binary files a/docs/anleitung/bilder/login.png and b/docs/anleitung/bilder/login.png differ diff --git a/docs/anleitung/bilder/nicht-zugeordnet.png b/docs/anleitung/bilder/nicht-zugeordnet.png index 636ffd1..0a54ca8 100644 Binary files a/docs/anleitung/bilder/nicht-zugeordnet.png and b/docs/anleitung/bilder/nicht-zugeordnet.png differ diff --git a/docs/anleitung/bilder/passkeys.png b/docs/anleitung/bilder/passkeys.png new file mode 100644 index 0000000..a147cf6 Binary files /dev/null and b/docs/anleitung/bilder/passkeys.png differ diff --git a/docs/anleitung/bilder/rechnungen.png b/docs/anleitung/bilder/rechnungen.png index b410355..c9b1831 100644 Binary files a/docs/anleitung/bilder/rechnungen.png and b/docs/anleitung/bilder/rechnungen.png differ diff --git a/docs/anleitung/bilder/system.png b/docs/anleitung/bilder/system.png index a30d3ff..cfe41ab 100644 Binary files a/docs/anleitung/bilder/system.png and b/docs/anleitung/bilder/system.png differ diff --git a/docs/anleitung/bilder/tokens.png b/docs/anleitung/bilder/tokens.png index bb0e3f2..301b12a 100644 Binary files a/docs/anleitung/bilder/tokens.png and b/docs/anleitung/bilder/tokens.png differ diff --git a/docs/anleitung/bilder/warnregeln.png b/docs/anleitung/bilder/warnregeln.png index 5314533..b8cb1c1 100644 Binary files a/docs/anleitung/bilder/warnregeln.png and b/docs/anleitung/bilder/warnregeln.png differ diff --git a/docs/anleitung/bilder/warnungen.png b/docs/anleitung/bilder/warnungen.png index 18520c4..6ce2938 100644 Binary files a/docs/anleitung/bilder/warnungen.png and b/docs/anleitung/bilder/warnungen.png differ diff --git a/docs/issues/027-rollen-benutzerverwaltung.md b/docs/issues/027-rollen-benutzerverwaltung.md new file mode 100644 index 0000000..e665571 --- /dev/null +++ b/docs/issues/027-rollen-benutzerverwaltung.md @@ -0,0 +1,36 @@ +# #027 Rollen und Benutzerverwaltung +**Status:** erledigt +**Meilenstein:** v0.18.0 +**Labels:** security, backend, ui +**Geschätzt:** 6 h **Tatsächlich:** 1 h 20 min + +## Ziel +Bisher durfte jeder Angemeldete alles – in `docs/sicherheit.md` als offener Punkt vermerkt. Es soll +drei Rollen geben: Administrator, Techniker und Kunde. Kundenkonten sehen nur die eigenen Daten. + +## Aufgaben +- [x] `Rolle` im Datenmodell, Kundenbindung am Benutzer +- [x] Rechte an **einer** Stelle (`src/server/auth/rollen.ts`), nicht verteilt über Seiten +- [x] `sitzungErforderlich(recht)` – jede der 28 Aktionen nennt ihr Recht +- [x] Sicht einschränken: Hosts, Kunden, Rechnungen, Kennzahlen, Suche, Jahresbahn, Dashboard +- [x] Einkaufspreise und Marge für Kundenkonten entfernen – in der Abfrage, nicht in der Anzeige +- [x] Schreibrechte für Kunden: Notizen am eigenen Host, Warnungen quittieren +- [x] Benutzerverwaltung unter *Einstellungen → Benutzer* +- [x] Menü nach Rolle +- [x] Tests + +## Entscheidungen +- **Rolle und Kundenbindung kommen aus der Datenbank, nicht aus dem Token.** Eine geänderte Rolle + wirkt damit sofort statt erst nach Ablauf der Sitzung. +- **Die Abfragen holen sich die Einschränkung selbst** (`sichtbarerKunde()`), statt sie von der Seite + gereicht zu bekommen. Eine Seite kann so nicht vergessen, sie weiterzugeben – der häufigste Weg, + wie solche Sichten lecken. +- **Ein Kundenkonto ohne Kundenbindung sieht nichts** statt alles. Der Rückfall zeigt bewusst ins + Leere, nicht auf den vollen Bestand. +- **Ausgeblendete Menüpunkte sind keine Zugriffskontrolle.** Sie sind Bequemlichkeit; geprüft wird in + Seite und Aktion. + +## Beim Bauen aufgefallen +Neu angelegte OIDC-Konten hätten die Vorgabe des Datenmodells geerbt – **ADMIN**. Wer sich beim +Identitätsanbieter anmelden darf, wäre damit nebenbei Administrator geworden. Jetzt gibt es dafür eine +eigene Einstellung mit der Vorgabe *Techniker*, samt Test. diff --git a/docs/issues/028-passkeys.md b/docs/issues/028-passkeys.md new file mode 100644 index 0000000..63ffa33 --- /dev/null +++ b/docs/issues/028-passkeys.md @@ -0,0 +1,35 @@ +# #028 Anmeldung mit Passkeys +**Status:** erledigt +**Meilenstein:** v0.18.0 +**Labels:** security, backend, ui +**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min + +## Ziel +Passkeys (WebAuthn) als **gleichwertiger** Anmeldeweg neben Passwort und OIDC – passwortlos, mit +Fingerabdruck, Gesicht, PIN oder Sicherheitsschlüssel. + +## Aufgaben +- [x] `@simplewebauthn/server` und `@simplewebauthn/browser` +- [x] Datenmodell `Passkey` (mehrere je Konto) +- [x] Registrierung und Anmeldung als Endpunkte, Aufgabe („challenge“) in einem kurzlebigen Cookie +- [x] Seite *Meine Passkeys* mit Anlegen und Entfernen +- [x] Knopf „Mit Passkey anmelden“ auf der Anmeldeseite +- [x] Hinweis, wenn die Adresse kein HTTPS hat + +## Entscheidungen +- **Keine Liste erlaubter Schlüssel bei der Anmeldung.** Der Browser bietet selbst an, was zu dieser + Seite passt. Sonst müsste die Seite vorher verraten, welche Passkeys zu einem Konto gehören – das + wäre ein Weg, Konten auszuspähen. +- **Fehlermeldung bei der Anmeldung bewusst unscharf** („Anmeldung mit diesem Passkey nicht + möglich“). Ob es den Schlüssel gibt, geht einen Unbekannten nichts an. +- **Die Aufgabe liegt in einem `SameSite=strict`-Cookie mit fünf Minuten Gültigkeit**, nicht in der + Datenbank. Sie gilt nur für diesen Browser und diesen Versuch. +- **`rpID` und `origin` kommen aus der öffentlichen Adresse** (Einstellung, sonst `APP_URL`, sonst + Proxy-Kopfzeilen). Ein Passkey ist an die Domäne gebunden; steht dort die falsche Adresse, lehnt + der Browser wortlos ab. +- Passwort und OIDC bleiben bestehen. Ein Passkey, der im kaputten Telefon liegt, darf niemanden + aussperren. + +## Grenze +Ein Passkey gilt nur für die Adresse, unter der er angelegt wurde. Beim Umzug von `http://server:3000` +auf `https://proxledger.firma.de` müssen alle Passkeys neu registriert werden – im Handbuch vermerkt. diff --git a/docs/issues/README.md b/docs/issues/README.md index 8915993..49d2316 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -30,4 +30,6 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min | | [#025](025-einstellungen.md) | Einstellungen im Dashboard | erledigt | v0.16.0 | 5 h | 1 h 10 min | | [#026](026-auslieferung.md) | Auslieferung finalisieren | erledigt | v0.17.0 | 3 h | 0 h 40 min | -| | **Summe** | | | **133 h** | **12 h 18 min** | +| [#027](027-rollen-benutzerverwaltung.md) | Rollen und Benutzerverwaltung | erledigt | v0.18.0 | 6 h | 1 h 20 min | +| [#028](028-passkeys.md) | Anmeldung mit Passkeys | erledigt | v0.18.0 | 4 h | 0 h 45 min | +| | **Summe** | | | **143 h** | **14 h 23 min** | diff --git a/docs/sicherheit.md b/docs/sicherheit.md index 4d93fdc..bcc59dd 100644 --- a/docs/sicherheit.md +++ b/docs/sicherheit.md @@ -15,8 +15,13 @@ Die Gliederung folgt den OWASP Top 10 (2021). - Die Anmeldung prüft den `Origin`, damit keine fremde Seite jemanden unbemerkt in ein untergeschobenes Konto anmeldet (Login-CSRF). Für Server Actions erledigt das Next.js selbst. -**Nicht vorhanden:** Rollen. Wer angemeldet ist, darf alles. Für ein Werkzeug, das ein Systemhaus intern -betreibt, ist das vertretbar – für mehrere Mandanten wäre es das nicht. +**Rollen** (#027): Administrator, Techniker und Kunde. Jede Aktion nennt das Recht, das sie verlangt; +die Rechte stehen an einer Stelle (`src/server/auth/rollen.ts`). Kundenkonten sind an einen Kunden +gebunden, und die Einschränkung sitzt in den Abfragen – nicht in der Anzeige. Einkaufspreise und Marge +werden für sie schon beim Abfragen entfernt, stehen also nicht im ausgelieferten HTML. + +**Weiterhin nicht vorhanden:** Mandantenfähigkeit im eigentlichen Sinn. Mehrere Systemhäuser in einer +Installation wären ein Umbau, kein Anbau. ## A02 – Kryptografische Fehler @@ -70,8 +75,13 @@ gespeicherten Zugangsdaten unlesbar. Er gehört in die Sicherung. - Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`). - Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log. -**Nicht vorhanden:** Zwei-Faktor-Anmeldung. Wer sie braucht, stellt OIDC davor und schaltet die -Passwortanmeldung ab. +- **Passkeys** (WebAuthn, #028) als dritter, gleichwertiger Weg. Passwortlos, an die Domäne gebunden, + mehrere je Konto. Setzt HTTPS voraus (außer auf `localhost`). +- Automatisch angelegte OIDC-Konten bekommen die eingestellte Rolle – Vorgabe *Techniker*, nicht + Administrator. + +**Nicht vorhanden:** klassische Zwei-Faktor-Anmeldung mit Einmalkennwort. Ein Passkey ersetzt beides +in einem Schritt; wer einen zweiten Faktor zum Passwort erzwingen will, stellt OIDC davor. ## A08 – Datenintegrität diff --git a/package-lock.json b/package-lock.json index 0c6c373..392247d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "proxledger", - "version": "0.14.1", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxledger", - "version": "0.14.1", + "version": "0.17.0", "hasInstallScript": true, "dependencies": { "@node-rs/argon2": "^2.2.1", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "@react-pdf/renderer": "^4.9.0", + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.2", "admin-lte": "^4.9.1", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", @@ -944,6 +946,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1609,6 +1617,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", @@ -2147,6 +2161,239 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz", + "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.4.tgz", + "integrity": "sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.4.tgz", + "integrity": "sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3031,6 +3278,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz", + "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.2.tgz", + "integrity": "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "@peculiar/x509": "^2.1.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4339,6 +4613,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -8667,6 +8955,24 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -8749,6 +9055,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -9703,6 +10015,24 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index e8083af..4fc44aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.17.0", + "version": "0.18.0", "private": true, "scripts": { "dev": "next dev", @@ -34,6 +34,8 @@ "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "@react-pdf/renderer": "^4.9.0", + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.2", "admin-lte": "^4.9.1", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", diff --git a/prisma/migrations/20260918222635_rollen_und_passkeys/migration.sql b/prisma/migrations/20260918222635_rollen_und_passkeys/migration.sql new file mode 100644 index 0000000..809bf27 --- /dev/null +++ b/prisma/migrations/20260918222635_rollen_und_passkeys/migration.sql @@ -0,0 +1,36 @@ +-- CreateEnum +CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'TECHNIKER', 'KUNDE'); + +-- AlterTable +ALTER TABLE "Benutzer" ADD COLUMN "kundeId" TEXT, +ADD COLUMN "rolle" "Rolle" NOT NULL DEFAULT 'ADMIN'; + +-- CreateTable +CREATE TABLE "Passkey" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "credentialId" TEXT NOT NULL, + "publicKey" BYTEA NOT NULL, + "zaehler" BIGINT NOT NULL DEFAULT 0, + "transports" TEXT[] DEFAULT ARRAY[]::TEXT[], + "bezeichnung" TEXT NOT NULL, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zuletztAm" TIMESTAMP(3), + + CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Passkey_credentialId_key" ON "Passkey"("credentialId"); + +-- CreateIndex +CREATE INDEX "Passkey_benutzerId_idx" ON "Passkey"("benutzerId"); + +-- CreateIndex +CREATE INDEX "Benutzer_kundeId_idx" ON "Benutzer"("kundeId"); + +-- AddForeignKey +ALTER TABLE "Benutzer" ADD CONSTRAINT "Benutzer_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5604a12..f5ebc13 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,6 +72,7 @@ model Kunde { alerts Alert[] @@index([name]) + benutzer Benutzer[] } model Standort { @@ -448,12 +449,23 @@ model Benachrichtigungskanal { // ---------------------------------------------------------------- Benutzer und Protokoll /// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen. +/// Was jemand darf. ADMIN alles, TECHNIKER alles Fachliche, KUNDE nur die eigenen Daten (#027). +enum Rolle { + ADMIN + TECHNIKER + KUNDE +} + model Benutzer { id String @id @default(cuid()) email String @unique name String passwortHash String? oidcSubject String? @unique + /// Bestehende Konten waren vor der Einführung der Rollen uneingeschränkt – daher ADMIN. + rolle Rolle @default(ADMIN) + /// Nur für die Rolle KUNDE: an welchen Kunden das Konto gebunden ist. + kundeId String? aktiv Boolean @default(true) letzterLoginAm DateTime? /// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024). @@ -462,6 +474,28 @@ model Benutzer { geaendertAm DateTime @updatedAt auditEintraege AuditEintrag[] + kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull) + passkeys Passkey[] + + @@index([kundeId]) +} + +/// Ein registrierter Passkey (WebAuthn). Ein Konto darf mehrere haben – Rechner und Telefon (#028). +model Passkey { + id String @id @default(cuid()) + benutzerId String + /// Kennung des Schlüssels, base64url wie vom Browser geliefert. + credentialId String @unique + publicKey Bytes + zaehler BigInt @default(0) + transports String[] @default([]) + bezeichnung String + erstelltAm DateTime @default(now()) + zuletztAm DateTime? + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@index([benutzerId]) } model AuditEintrag { diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs index 9d2d5f5..4f7c15f 100644 --- a/scripts/screenshots.mjs +++ b/scripts/screenshots.mjs @@ -18,6 +18,7 @@ import { homedir } from "node:os"; import path from "node:path"; import { request as httpAnfrage } from "node:http"; import { SignJWT } from "jose"; +import pg from "pg"; import "dotenv/config"; const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000"; @@ -49,6 +50,8 @@ const SEITEN = [ { name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" }, { name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" }, { name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" }, + { name: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" }, + { name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" }, ]; async function sitzungsToken() { @@ -57,14 +60,29 @@ async function sitzungsToken() { throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder."); } + // Seit #027 schlägt die Sitzungsprüfung den Benutzer in der Datenbank nach: Ein erfundenes + // Konto landet wieder auf der Anmeldeseite. Deshalb hier ein echtes Administratorkonto nehmen. + // Kein Prisma-Client hier: Das Skript läuft als reines JavaScript, der erzeugte Client ist TypeScript. + const datenbank = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await datenbank.connect(); + const { rows } = await datenbank.query( + `SELECT id, email, name FROM "Benutzer" WHERE rolle = 'ADMIN' AND aktiv = true ORDER BY "erstelltAm" LIMIT 1`, + ); + await datenbank.end(); + const benutzer = rows[0]; + + if (!benutzer) { + throw new Error("Kein aktiver Administrator in der Datenbank – erst `npm run db:seed` ausführen."); + } + return new SignJWT({ - benutzerId: "screenshots", - email: process.env.ADMIN_EMAIL ?? "admin@example.de", - name: process.env.ADMIN_NAME ?? "Florian", + benutzerId: benutzer.id, + email: benutzer.email, + name: benutzer.name, herkunft: "passwort", }) .setProtectedHeader({ alg: "HS256" }) - .setSubject("screenshots") + .setSubject(benutzer.id) .setIssuedAt() .setIssuer("proxledger") .setExpirationTime("1h") diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index c2c9274..23a1272 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular"; +import { PasskeyAnmeldung } from "@/components/auth/PasskeyAnmeldung"; import { Logo } from "@/components/Logo"; import { oidcEinstellungen } from "@/server/auth/oidc"; @@ -43,10 +44,14 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc