feat: Anmelden mit Passkey
Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter "Mein Konto". Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets und dessen Entsperrung. Damit das keine Annahme bleibt, wird die Nutzerverifikation angefordert (userVerification: 'required') und serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen. TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet; waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte schliessen. Aufbau: - Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey", nicht in der Server Action davor. Ein "war schon geprueft" ueber die Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen lassen. - Challenges liegen serverseitig und werden per DELETE genau einmal verbraucht - die Datenbank entscheidet, wer sie bekommt. - Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer zweiten Konfigurationsquelle. - Ohne https wird der Knopf gar nicht erst angeboten. - Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die Anmeldung nennt den verwendeten Passkey namentlich. Zwei Anmerkungen zur Migration: - "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe Suche stillgelegt, ohne dass etwas fehlgeschlagen waere. - "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine geloeschte. Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen, Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests, null Typfehler, Produktionsbau steht. Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46 statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man sie nachzaehlt.
This commit is contained in:
+76
-1
@@ -26,6 +26,80 @@ Wiederherstellung*.
|
|||||||
|
|
||||||
_Noch nichts._
|
_Noch nichts._
|
||||||
|
|
||||||
|
## [1.1.0] – 2026-08-22
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Anmelden mit Passkey.** Auf der Anmeldeseite steht jetzt unter dem
|
||||||
|
Formular ein zweiter Weg: *Mit Passkey anmelden*. Ein Fingerabdruck, ein
|
||||||
|
Blick in die Kamera oder die Geräte-PIN — keine E-Mail, kein Passwort,
|
||||||
|
kein abgetippter Code.
|
||||||
|
|
||||||
|
**Der Passkey ersetzt den zweiten Faktor, er kommt nicht zusätzlich.**
|
||||||
|
Das ist kein Nachlassen: Ein Passkey verlangt den Besitz des Geräts
|
||||||
|
*und* dessen Entsperrung, das sind dieselben zwei Faktoren wie vorher.
|
||||||
|
Die Anwendung fordert die Nutzerverifikation ausdrücklich an und weist
|
||||||
|
eine Antwort ohne sie zurück. Ein Gerät, das auf blosses Antippen hin
|
||||||
|
signiert, kommt nicht herein.
|
||||||
|
|
||||||
|
In einem Punkt ist er dem bisherigen Weg sogar überlegen: Ein Passkey
|
||||||
|
ist an die Adresse gebunden, unter der er eingerichtet wurde. Auf einer
|
||||||
|
nachgebauten Anmeldeseite funktioniert er nicht — und Phishing ist der
|
||||||
|
übliche Weg, an einen TOTP-Code zu kommen.
|
||||||
|
|
||||||
|
**Die Authenticator-App muss trotzdem eingerichtet sein**, bevor sich
|
||||||
|
ein Passkey anlegen lässt. Ein Passkey hängt an einem Gerät. Ginge das
|
||||||
|
verloren und wäre er der einzige Weg hinein, käme niemand mehr an die
|
||||||
|
Akte — und darin stehen Unterlagen, die eine betreute Person braucht.
|
||||||
|
Der Passkey ist die bequeme Tür, nicht die einzige.
|
||||||
|
|
||||||
|
Verwaltet werden sie unter **Mein Konto → Passkeys**: einrichten,
|
||||||
|
benennen, entfernen. Die Liste zeigt, wann ein Gerät zuletzt benutzt
|
||||||
|
wurde und ob der Schlüssel gesichert ist (iCloud, Google, Bitwarden)
|
||||||
|
oder nur auf diesem einen Gerät liegt. Einrichten und Entfernen stehen
|
||||||
|
im Protokoll, jede Anmeldung nennt den verwendeten Passkey namentlich.
|
||||||
|
|
||||||
|
- **Bestätigen mit Passkey.** Export, endgültiges Löschen und die
|
||||||
|
Benutzerverwaltung verlangen eine frische Anmeldung. Auch die geht jetzt
|
||||||
|
mit dem Passkey. Geprüft wird zusätzlich, dass er zum angemeldeten
|
||||||
|
Zugang gehört — sonst liesse sich mit dem eigenen Finger eine fremde,
|
||||||
|
offen stehende Sitzung freischalten. Genau der Fall, gegen den diese
|
||||||
|
Zwischenfrage gebaut ist.
|
||||||
|
|
||||||
|
### Zu beachten
|
||||||
|
|
||||||
|
- **Passkeys brauchen `https`.** Der Browser gibt das Verfahren nur in
|
||||||
|
einem sicheren Kontext frei. Läuft die Anwendung ohne TLS, erscheint der
|
||||||
|
Knopf gar nicht erst, statt beim Klick mit einer kryptischen Meldung
|
||||||
|
abzubrechen.
|
||||||
|
|
||||||
|
- **`AUTH_URL` muss stimmen — jetzt noch mehr als vorher.** Aus ihr wird
|
||||||
|
die Kennung abgeleitet, an die der Authenticator den Schlüssel bindet.
|
||||||
|
Steht dort eine andere Adresse als die im Browser, lässt sich zwar ein
|
||||||
|
Passkey einrichten, aber nie wieder verwenden.
|
||||||
|
|
||||||
|
**Ein Wechsel der Domain macht alle Passkeys unbrauchbar.** Das ist
|
||||||
|
keine Panne, sondern der Phishingschutz. Nach einem Umzug richtet man
|
||||||
|
sie neu ein; die Anmeldung mit Passwort und Code bleibt währenddessen
|
||||||
|
offen.
|
||||||
|
|
||||||
|
- **Eine Datenbank-Migration ist nötig** (`20260822160000_passkey_anmeldung`).
|
||||||
|
Sie läuft beim Start des Behälters automatisch mit. Bestehende Zugänge
|
||||||
|
sind nicht betroffen: Ohne eingerichteten Passkey ändert sich nichts.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- 21 neue Prüfungen (`tests/passkey.test.ts`), vor allem für die Herleitung
|
||||||
|
von Kennung und Herkunft aus `AUTH_URL`: Port, Schrägstrich am Ende,
|
||||||
|
Unterdomain, `http` gegen `https`, `localhost`. Das ist die Stelle, an
|
||||||
|
der eine Installation still kaputtginge — dieselbe Fehlerklasse wie
|
||||||
|
seinerzeit bei `APP_BIND` und `ENC_KEY_V2`.
|
||||||
|
- Drei End-to-End-Tests gegen einen echten Browser (`e2e/passkey.spec.ts`),
|
||||||
|
mit Chromiums virtuellem Authenticator: einrichten, abmelden, mit Passkey
|
||||||
|
anmelden, Benutzung vermerkt, entfernen. Dazu die Bestätigung vor dem
|
||||||
|
Vollexport und der Fall, dass ein Gerät ohne Nutzerverifikation nicht
|
||||||
|
hereinkommt.
|
||||||
|
|
||||||
## [1.0.5] – 2026-08-22
|
## [1.0.5] – 2026-08-22
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
@@ -392,7 +466,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.5...HEAD
|
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.0...HEAD
|
||||||
|
[1.1.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.5...v1.1.0
|
||||||
[1.0.5]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.4...v1.0.5
|
[1.0.5]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.4...v1.0.5
|
||||||
[1.0.4]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.3...v1.0.4
|
[1.0.4]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.3...v1.0.4
|
||||||
[1.0.3]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.2...v1.0.3
|
[1.0.3]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.2...v1.0.3
|
||||||
|
|||||||
@@ -293,6 +293,15 @@ entscheiden kann. Was dafür umgesetzt ist:
|
|||||||
- Passwörter mit **Argon2id** (19 MiB, t=2, p=1 — OWASP-Empfehlung).
|
- Passwörter mit **Argon2id** (19 MiB, t=2, p=1 — OWASP-Empfehlung).
|
||||||
- **Zweiter Faktor ist Pflicht** und lässt sich nicht abschalten. TOTP nach
|
- **Zweiter Faktor ist Pflicht** und lässt sich nicht abschalten. TOTP nach
|
||||||
RFC 6238, kompatibel mit Aegis, 2FAS, Bitwarden, 1Password.
|
RFC 6238, kompatibel mit Aegis, 2FAS, Bitwarden, 1Password.
|
||||||
|
- **Passkeys** (WebAuthn/FIDO2) als zweiter Anmeldeweg — auch für die
|
||||||
|
erneute Bestätigung vor sensiblen Aktionen. Sie *ersetzen* den zweiten
|
||||||
|
Faktor, statt zu ihm hinzuzukommen: Nutzerverifikation wird verlangt
|
||||||
|
(`userVerification: 'required'`) und serverseitig nachgeprüft, ein
|
||||||
|
Schlüssel ohne sie wird abgewiesen. Damit sind es dieselben zwei
|
||||||
|
Faktoren — Besitz des Geräts und dessen Entsperrung —, nur an die Domain
|
||||||
|
gebunden und deshalb nicht abphishbar. TOTP muss trotzdem eingerichtet
|
||||||
|
sein, bevor sich ein Passkey anlegen lässt: Er hängt an einem Gerät und
|
||||||
|
darf nicht der einzige Weg hinein sein.
|
||||||
- **Rate-Limiting** am Login, nach E-Mail *und* nach Anschluss. Die Sperre
|
- **Rate-Limiting** am Login, nach E-Mail *und* nach Anschluss. Die Sperre
|
||||||
wird geprüft, bevor Argon2 überhaupt rechnet.
|
wird geprüft, bevor Argon2 überhaupt rechnet.
|
||||||
- Die Antwortzeit ist auf eine Mindestdauer gestreckt, damit sie nicht
|
- Die Antwortzeit ist auf eine Mindestdauer gestreckt, damit sie nicht
|
||||||
@@ -559,14 +568,17 @@ Fehler, die während des Baus aufgefallen sind.
|
|||||||
|
|
||||||
## Stand
|
## Stand
|
||||||
|
|
||||||
| | |
|
| | | nachzuzählen mit |
|
||||||
|---|---:|
|
|---|---:|---|
|
||||||
| Zeilen TypeScript/TSX | 71.051 |
|
| Zeilen TypeScript/TSX | 72.080 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
|
||||||
| Prisma-Modelle | 24 |
|
| Prisma-Modelle | 33 | `grep -c '^model ' prisma/schema.prisma` |
|
||||||
| Seiten | 46 |
|
| Seiten | 79 | `find src/app -name page.tsx \| wc -l` |
|
||||||
| Unit-Tests (Vitest) | 458 |
|
| Unit-Tests (Vitest) | 496 | `npm test` |
|
||||||
| End-to-End-Tests (Playwright) | 34 |
|
| End-to-End-Tests (Playwright) | 37 | `npm run test:e2e` |
|
||||||
| TypeScript-Fehler | 0 |
|
| TypeScript-Fehler | 0 | `npm run typecheck` |
|
||||||
|
|
||||||
|
Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es
|
||||||
|
jemandem auffällt — bei den Modellen und Seiten war genau das passiert.
|
||||||
|
|
||||||
Alle acht Etappen sind umgesetzt. Der Produktionsbuild ist gegen den
|
Alle acht Etappen sind umgesetzt. Der Produktionsbuild ist gegen den
|
||||||
fertigen Standalone-Server geprüft: strenge CSP aktiv, keine
|
fertigen Standalone-Server geprüft: strenge CSP aktiv, keine
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ Anschreiben wieder auf.
|
|||||||
**Zum Schluss:** Geh auf **Fristen** und klicke einmal auf
|
**Zum Schluss:** Geh auf **Fristen** und klicke einmal auf
|
||||||
*Pflichtfristen ableiten*. Danach stehen deine gesetzlichen Termine drin.
|
*Pflichtfristen ableiten*. Danach stehen deine gesetzlichen Termine drin.
|
||||||
|
|
||||||
|
Und wenn du magst: Richte unter **Mein Konto → Passkeys** einen Passkey
|
||||||
|
ein. Dann meldest du dich künftig mit dem Finger an, statt drei Felder
|
||||||
|
auszufüllen. Wie das geht, steht gleich im nächsten Abschnitt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Der Morgen: die Seite „Heute"
|
## Der Morgen: die Seite „Heute"
|
||||||
@@ -328,6 +332,86 @@ Anwendung jederzeit verlassen. Verlangt eine erneute Anmeldung.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Anmelden mit dem Finger: Passkeys
|
||||||
|
|
||||||
|
Drei Felder auszufüllen, nur um kurz eine Frist nachzusehen, ist lästig —
|
||||||
|
und lästige Sicherheit wird umgangen. Ein Passkey macht daraus einen
|
||||||
|
Handgriff.
|
||||||
|
|
||||||
|
### Einrichten
|
||||||
|
|
||||||
|
**Mein Konto → Passkeys.** Gib dem Gerät einen Namen, an dem du es später
|
||||||
|
wiedererkennst („iPhone", „Laptop Büro"), und klicke auf *Passkey
|
||||||
|
einrichten*. Dein Gerät fragt nach Fingerabdruck, Gesicht oder PIN —
|
||||||
|
fertig.
|
||||||
|
|
||||||
|
Mach das auf **jedem Gerät**, mit dem du arbeitest. Sie stören sich nicht
|
||||||
|
gegenseitig.
|
||||||
|
|
||||||
|
### Anmelden
|
||||||
|
|
||||||
|
Auf der Anmeldeseite steht unter dem Formular *Mit Passkey anmelden*. Ein
|
||||||
|
Klick, ein Finger, drin. Keine E-Mail, kein Passwort, kein Code.
|
||||||
|
|
||||||
|
### Warum das nicht unsicherer ist
|
||||||
|
|
||||||
|
Es sieht nach weniger aus, ist aber dasselbe — zweimal:
|
||||||
|
|
||||||
|
| | Bisher | Mit Passkey |
|
||||||
|
|---|---|---|
|
||||||
|
| Erster Faktor | Passwort (Wissen) | Das Gerät selbst (Besitz) |
|
||||||
|
| Zweiter Faktor | Code aus der App (Besitz) | Fingerabdruck, Gesicht oder PIN |
|
||||||
|
|
||||||
|
Der entscheidende Zusatz: **Ein Passkey ist an die Adresse gebunden**,
|
||||||
|
unter der du ihn eingerichtet hast. Landest du auf einer nachgebauten
|
||||||
|
Anmeldeseite, funktioniert er dort schlicht nicht — er lässt sich nicht
|
||||||
|
abtippen und nicht weitergeben. Genau das ist der übliche Weg, an einen
|
||||||
|
Code aus der Authenticator-App zu kommen.
|
||||||
|
|
||||||
|
Ein Gerät, das den Passkey ohne Fingerabdruck oder PIN freigibt, wird
|
||||||
|
abgewiesen. Das wäre nur ein Faktor, und dafür ist hier zu viel zu
|
||||||
|
verlieren.
|
||||||
|
|
||||||
|
### Die Authenticator-App bleibt
|
||||||
|
|
||||||
|
Du kannst sie nicht abschaffen, und das ist Absicht. **Ein Passkey hängt
|
||||||
|
an einem Gerät.** Geht das Handy in der Donau baden und wäre der Passkey
|
||||||
|
der einzige Weg hinein, käme niemand mehr an die Akte — auch der Bezirk
|
||||||
|
nicht, auch das Gericht nicht.
|
||||||
|
|
||||||
|
Deshalb: erst Authenticator-App und Wiederherstellungscodes, dann Passkeys.
|
||||||
|
Die Anmeldung mit E-Mail, Passwort und Code bleibt daneben immer offen.
|
||||||
|
|
||||||
|
### Was in der Liste steht
|
||||||
|
|
||||||
|
Unter **Mein Konto → Passkeys** siehst du zu jedem Gerät, wann es
|
||||||
|
eingerichtet und wann es zuletzt benutzt wurde. **Sieh da gelegentlich
|
||||||
|
hinein.** Ein Eintrag, den du nicht zuordnen kannst, gehört sofort
|
||||||
|
entfernt — und danach das Passwort gewechselt.
|
||||||
|
|
||||||
|
Steht bei einem Eintrag *gesichert*, wird der Schlüssel vom Anbieter
|
||||||
|
mitgesichert (iCloud-Schlüsselbund, Google, Bitwarden) und taucht auf
|
||||||
|
einem neuen Gerät von selbst wieder auf. Ohne diesen Vermerk liegt er nur
|
||||||
|
auf diesem einen Gerät.
|
||||||
|
|
||||||
|
### Wenn ein Gerät verloren geht
|
||||||
|
|
||||||
|
1. Auf einem anderen Gerät mit E-Mail, Passwort und Code anmelden.
|
||||||
|
2. **Mein Konto → Passkeys**, den Eintrag des verlorenen Geräts entfernen.
|
||||||
|
3. Fertig — der Schlüssel auf dem Gerät ist damit wertlos.
|
||||||
|
|
||||||
|
Ein Passkey allein reicht ohnehin niemandem, der das Gerät findet: Ohne
|
||||||
|
Fingerabdruck oder PIN gibt das Gerät ihn nicht heraus.
|
||||||
|
|
||||||
|
### Wenn der Knopf gar nicht erscheint
|
||||||
|
|
||||||
|
Dann bietet der Server Passkeys nicht an — das Verfahren verlangt eine
|
||||||
|
verschlüsselte Verbindung (`https`). Oder der Browser ist zu alt. Melde
|
||||||
|
dich mit E-Mail, Passwort und Code an; alles andere funktioniert
|
||||||
|
unverändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Am Handy
|
## Am Handy
|
||||||
|
|
||||||
Die Anwendung ist am Telefon vollwertig bedienbar — sie ist dafür gebaut,
|
Die Anwendung ist am Telefon vollwertig bedienbar — sie ist dafür gebaut,
|
||||||
|
|||||||
+51
-5
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
|
|||||||
|
|
||||||
# Auf die letzte veröffentlichte Fassung wechseln,
|
# Auf die letzte veröffentlichte Fassung wechseln,
|
||||||
# nicht auf den laufenden Entwicklungsstand
|
# nicht auf den laufenden Entwicklungsstand
|
||||||
git checkout v1.0.0
|
git checkout v1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Kontrolle
|
### Kontrolle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls docker-compose.yml Dockerfile .env.example # alle drei da
|
ls docker-compose.yml Dockerfile .env.example # alle drei da
|
||||||
git describe --tags # v1.0.0
|
git describe --tags # v1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -220,6 +220,12 @@ APP_PORT=3000
|
|||||||
> Steht dort etwas anderes, wird das Sitzungs-Cookie für die falsche
|
> Steht dort etwas anderes, wird das Sitzungs-Cookie für die falsche
|
||||||
> Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular,
|
> Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular,
|
||||||
> ohne Fehlermeldung. Das ist der Fehler, den man am längsten sucht.
|
> ohne Fehlermeldung. Das ist der Fehler, den man am längsten sucht.
|
||||||
|
>
|
||||||
|
> Seit 1.1.0 hängt noch mehr daran: Aus dieser Adresse wird die Kennung
|
||||||
|
> abgeleitet, an die Passkeys gebunden werden. Steht sie falsch, lässt
|
||||||
|
> sich zwar ein Passkey einrichten, aber nie wieder verwenden. Und ein
|
||||||
|
> späterer Wechsel der Domain macht alle vorhandenen Passkeys
|
||||||
|
> unbrauchbar — das ist kein Fehler, sondern der Phishingschutz.
|
||||||
|
|
||||||
**Läuft auf dem Server schon etwas auf Port 3000?** Dann weich aus:
|
**Läuft auf dem Server schon etwas auf Port 3000?** Dann weich aus:
|
||||||
|
|
||||||
@@ -293,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
|
|||||||
```bash
|
```bash
|
||||||
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
||||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||||
# {"status":"bereit","version":"1.0.0","commit":"..."}
|
# {"status":"bereit","version":"1.1.0","commit":"..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
Kommt hier nichts, sieh in die Logs:
|
Kommt hier nichts, sieh in die Logs:
|
||||||
@@ -643,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://10.0.0.5:3000/api/gesundheit
|
curl -s http://10.0.0.5:3000/api/gesundheit
|
||||||
# {"status":"bereit","version":"1.0.0",...}
|
# {"status":"bereit","version":"1.1.0",...}
|
||||||
```
|
```
|
||||||
|
|
||||||
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
|
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
|
||||||
@@ -694,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
|
|||||||
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
|
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
|
||||||
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
|
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
|
||||||
- Unter **Protokoll** siehst du deine eigene Anmeldung.
|
- Unter **Protokoll** siehst du deine eigene Anmeldung.
|
||||||
- Unten links in der Seitenleiste steht `Version 1.0.0 · <commit>`.
|
- Unten links in der Seitenleiste steht `Version 1.1.0 · <commit>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1024,6 +1030,46 @@ timedatectl
|
|||||||
docker compose exec app date
|
docker compose exec app date
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Der Knopf „Mit Passkey anmelden" erscheint nicht
|
||||||
|
|
||||||
|
Passkeys brauchen einen sicheren Kontext. Die Anwendung bietet sie nur an,
|
||||||
|
wenn `AUTH_URL` auf `https://` steht (oder auf `localhost`) — sonst
|
||||||
|
erscheint der Knopf bewusst gar nicht erst, statt beim Klick mit einer
|
||||||
|
kryptischen Browsermeldung abzubrechen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app printenv AUTH_URL # muss mit https:// beginnen
|
||||||
|
```
|
||||||
|
|
||||||
|
Steht dort `http://`, korrigiere die `.env` und starte neu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Erscheint der Knopf weiterhin nicht, kann der Browser kein WebAuthn — das
|
||||||
|
betrifft praktisch nur sehr alte Fassungen.
|
||||||
|
|
||||||
|
### Ein Passkey lässt sich einrichten, aber nicht zum Anmelden benutzen
|
||||||
|
|
||||||
|
Fast immer stimmt die Adresse nicht überein. Der Schlüssel ist an den
|
||||||
|
Hostnamen aus `AUTH_URL` gebunden; der Browser vergleicht ihn mit dem, was
|
||||||
|
in der Adresszeile steht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app printenv AUTH_URL
|
||||||
|
# muss exakt die Adresse sein, die du im Browser eintippst
|
||||||
|
```
|
||||||
|
|
||||||
|
Typische Ursachen: `AUTH_URL` zeigt auf `cockpit.example.de`, aufgerufen
|
||||||
|
wird aber `www.cockpit.example.de` — oder umgekehrt. Ein Passkey für die
|
||||||
|
eine Adresse gilt auf der anderen nicht.
|
||||||
|
|
||||||
|
**Nach einem Wechsel der Domain** müssen alle Passkeys neu eingerichtet
|
||||||
|
werden. Die alten Einträge lassen sich unter *Mein Konto → Passkeys*
|
||||||
|
entfernen. Die Anmeldung mit E-Mail, Passwort und Code funktioniert in der
|
||||||
|
Zwischenzeit unverändert — man sperrt sich damit also nicht aus.
|
||||||
|
|
||||||
### Die Texterkennung findet nichts
|
### Die Texterkennung findet nichts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+34
-1
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
|
|||||||
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
|
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
|
||||||
als wichtiger herausstellt, rutscht nach oben.
|
als wichtiger herausstellt, rutscht nach oben.
|
||||||
|
|
||||||
Stand: 22.08.2026 · Aktuelle Fassung: **1.0.0** ([CHANGELOG](../CHANGELOG.md))
|
Stand: 22.08.2026 · Aktuelle Fassung: **1.1.0** ([CHANGELOG](../CHANGELOG.md))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -136,6 +136,31 @@ Jahresbericht und bei einer Höherstufung gebraucht.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.2b — Passkeys abrunden
|
||||||
|
|
||||||
|
Der Grundstock steht seit 1.1.0. Was noch fehlt, ist Bequemlichkeit, nicht
|
||||||
|
Sicherheit.
|
||||||
|
|
||||||
|
### Passkey direkt im Anmeldefeld anbieten
|
||||||
|
|
||||||
|
Browser können einen Passkey im Ausfüllvorschlag des E-Mail-Feldes
|
||||||
|
anbieten (*conditional UI*), statt hinter einem eigenen Knopf. Ein Schritt
|
||||||
|
weniger. Braucht einen zweiten Challenge-Weg und sorgfältiges Abbrechen,
|
||||||
|
wenn stattdessen doch das Passwort getippt wird.
|
||||||
|
|
||||||
|
### Passkey umbenennen
|
||||||
|
|
||||||
|
Heute geht nur anlegen und entfernen. Wer sein Gerät „Handy" genannt hat
|
||||||
|
und inzwischen zwei hat, muss den Passkey neu einrichten.
|
||||||
|
|
||||||
|
### Warnen, wenn der letzte Passkey nur auf einem Gerät liegt
|
||||||
|
|
||||||
|
Die Liste sagt bereits, ob ein Schlüssel gesichert ist. Wer ausschließlich
|
||||||
|
ungesicherte Passkeys hat und dazu keine Wiederherstellungscodes mehr,
|
||||||
|
sollte das deutlicher zu sehen bekommen als in einer Zeile Kleingedrucktem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1.3 — Belastbarkeit
|
## 1.3 — Belastbarkeit
|
||||||
|
|
||||||
**Ziel:** Die Stellen absichern, die bisher nur unter Laborbedingungen
|
**Ziel:** Die Stellen absichern, die bisher nur unter Laborbedingungen
|
||||||
@@ -164,6 +189,14 @@ Monat automatisch durchspielt — Sicherung zurückspielen, Diagnosen im
|
|||||||
Klartext prüfen, Testdatenbank verwerfen — wäre der Beweis statt der
|
Klartext prüfen, Testdatenbank verwerfen — wäre der Beweis statt der
|
||||||
Absichtserklärung.
|
Absichtserklärung.
|
||||||
|
|
||||||
|
### Passkey-Ablehnung ohne Browser nachstellen
|
||||||
|
|
||||||
|
Die Prüfung „Passkey ohne Nutzerverifikation wird abgewiesen" ist im Code
|
||||||
|
doppelt abgesichert und im Browsertest belegt — dort weist allerdings
|
||||||
|
schon der Browser ab, der Server kommt gar nicht dran. Ein Test, der die
|
||||||
|
Antwort von Hand baut und den Browser umgeht, würde den serverseitigen
|
||||||
|
Rückhalt tatsächlich beweisen.
|
||||||
|
|
||||||
### Barrierefreiheits-Durchgang mit einem Screenreader
|
### Barrierefreiheits-Durchgang mit einem Screenreader
|
||||||
|
|
||||||
Semantik, Fokusreihenfolge und Kontraste sind beim Bauen beachtet und in
|
Semantik, Fokusreihenfolge und Kontraste sind beim Bauen beachtet und in
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import type { BrowserContext, CDPSession, Page } from '@playwright/test'
|
||||||
|
import { anmelden, fehlversucheLoeschen } from './hilfen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkeys gegen einen echten Browser.
|
||||||
|
*
|
||||||
|
* WebAuthn laesst sich nicht sinnvoll mit Einheitstests pruefen: Was zaehlt,
|
||||||
|
* passiert zwischen Browser und Authenticator. Chromium bringt dafuer einen
|
||||||
|
* virtuellen Authenticator mit, der ueber das Debugprotokoll gesteuert wird -
|
||||||
|
* er signiert echt, nur ohne Fingerabdrucksensor.
|
||||||
|
*
|
||||||
|
* Damit laesst sich der Ablauf vollstaendig durchspielen: einrichten,
|
||||||
|
* abmelden, mit Passkey anmelden, entfernen. Und, wichtiger, der Fall, den
|
||||||
|
* die Zusage dieser Anwendung traegt - ein Geraet OHNE Nutzerverifikation
|
||||||
|
* darf nicht hereinkommen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type VirtuellerAuthenticator = {
|
||||||
|
cdp: CDPSession
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticatorAnlegen(
|
||||||
|
kontext: BrowserContext,
|
||||||
|
seite: Page,
|
||||||
|
verifiziert = true,
|
||||||
|
): Promise<VirtuellerAuthenticator> {
|
||||||
|
const cdp = await kontext.newCDPSession(seite)
|
||||||
|
await cdp.send('WebAuthn.enable')
|
||||||
|
|
||||||
|
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
|
||||||
|
options: {
|
||||||
|
protocol: 'ctap2',
|
||||||
|
// "internal" = im Geraet verbaut, wie Face ID oder Windows Hello.
|
||||||
|
transport: 'internal',
|
||||||
|
// Auffindbarer Schluessel - Voraussetzung fuer die Anmeldung ohne
|
||||||
|
// vorherige Eingabe der E-Mail-Adresse.
|
||||||
|
hasResidentKey: true,
|
||||||
|
hasUserVerification: true,
|
||||||
|
isUserVerified: verifiziert,
|
||||||
|
automaticPresenceSimulation: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { cdp, id: authenticatorId }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function passkeyEinrichten(seite: Page, name: string): Promise<void> {
|
||||||
|
await seite.goto('/konto')
|
||||||
|
await seite.getByLabel('Neues Gerät').fill(name)
|
||||||
|
await seite.getByRole('button', { name: 'Passkey einrichten' }).click()
|
||||||
|
await expect(seite.getByText(`„${name}“ ist eingerichtet.`)).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Passkey', () => {
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await fehlversucheLoeschen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('richtet einen Passkey ein und meldet sich anschliessend damit an', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const authenticator = await authenticatorAnlegen(context, page)
|
||||||
|
|
||||||
|
// --- 1. Auf dem gewoehnlichen Weg herein --------------------------------
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
// --- 2. Passkey einrichten ---------------------------------------------
|
||||||
|
await passkeyEinrichten(page, 'Testgerät')
|
||||||
|
|
||||||
|
// Er steht in der Liste, mit Datum und dem Hinweis "noch nicht benutzt".
|
||||||
|
const eintrag = page.locator('li', { hasText: 'Testgerät' })
|
||||||
|
await expect(eintrag).toBeVisible()
|
||||||
|
await expect(eintrag).toContainText('noch nicht benutzt')
|
||||||
|
|
||||||
|
// --- 3. Abmelden --------------------------------------------------------
|
||||||
|
await context.clearCookies()
|
||||||
|
await page.goto('/anmelden')
|
||||||
|
await expect(page.getByRole('button', { name: 'Mit Passkey anmelden' })).toBeVisible()
|
||||||
|
|
||||||
|
// --- 4. Mit Passkey anmelden -------------------------------------------
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }),
|
||||||
|
page.getByRole('button', { name: 'Mit Passkey anmelden' }).click(),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Kein zweiter Schritt: Der Passkey IST der zweite Faktor. Wer hier auf
|
||||||
|
// der Einrichtungsseite fuer TOTP landet, hat einen Fehler im Ablauf.
|
||||||
|
expect(new URL(page.url()).pathname).not.toContain('/zwei-faktor')
|
||||||
|
|
||||||
|
// --- 5. Die Benutzung ist vermerkt -------------------------------------
|
||||||
|
await page.goto('/konto')
|
||||||
|
await expect(page.locator('li', { hasText: 'Testgerät' })).toContainText('zuletzt benutzt')
|
||||||
|
|
||||||
|
// --- 6. Wieder entfernen, damit der naechste Lauf sauber startet -------
|
||||||
|
await page.locator('li', { hasText: 'Testgerät' }).getByRole('button').click()
|
||||||
|
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
|
||||||
|
await expect(page.getByText('„Testgerät“ wurde entfernt.')).toBeVisible()
|
||||||
|
|
||||||
|
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
|
||||||
|
authenticatorId: authenticator.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('laesst ein Geraet ohne Nutzerverifikation nicht herein', async ({ page, context }) => {
|
||||||
|
// Das ist die Zusage, auf der alles steht: Ein Passkey darf den zweiten
|
||||||
|
// Faktor nur ersetzen, wenn das Geraet den Benutzer wirklich verifiziert
|
||||||
|
// hat - Fingerabdruck, Gesicht oder PIN. Ein Schluessel, der auf blosses
|
||||||
|
// Antippen hin signiert, ist ein Faktor, kein zweiter.
|
||||||
|
//
|
||||||
|
// Was dieser Test belegt: Die Anforderung erreicht das Geraet, und ein
|
||||||
|
// Geraet ohne Verifikation kommt nicht herein. Abgewiesen wird dabei
|
||||||
|
// schon vom Browser - im Protokoll steht deshalb kein fehlgeschlagener
|
||||||
|
// Versuch. Die Pruefung auf dem Server (requireUserVerification und die
|
||||||
|
// ausdrueckliche Abfrage von userVerified in anmeldungPruefen) ist der
|
||||||
|
// Rueckhalt fuer den Fall, dass jemand die Antwort von Hand baut und den
|
||||||
|
// Browser umgeht. Der laesst sich hier nicht nachstellen.
|
||||||
|
const authenticator = await authenticatorAnlegen(context, page, true)
|
||||||
|
|
||||||
|
await anmelden(page)
|
||||||
|
await passkeyEinrichten(page, 'Ungesichertes Gerät')
|
||||||
|
|
||||||
|
// Jetzt die Verifikation abschalten - so verhaelt sich ein Geraet ohne
|
||||||
|
// eingerichtete Bildschirmsperre.
|
||||||
|
await authenticator.cdp.send('WebAuthn.setUserVerified', {
|
||||||
|
authenticatorId: authenticator.id,
|
||||||
|
isUserVerified: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await context.clearCookies()
|
||||||
|
await page.goto('/anmelden')
|
||||||
|
await page.getByRole('button', { name: 'Mit Passkey anmelden' }).click()
|
||||||
|
|
||||||
|
// Entscheidend ist nicht der Wortlaut, sondern: Es geht nicht herein.
|
||||||
|
await expect(page.getByRole('alert')).toBeVisible({ timeout: 30_000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
expect(new URL(page.url()).pathname).toBe('/anmelden')
|
||||||
|
|
||||||
|
// Aufraeumen: wieder verifizieren, anmelden, Passkey entfernen.
|
||||||
|
await authenticator.cdp.send('WebAuthn.setUserVerified', {
|
||||||
|
authenticatorId: authenticator.id,
|
||||||
|
isUserVerified: true,
|
||||||
|
})
|
||||||
|
await fehlversucheLoeschen()
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/konto')
|
||||||
|
await page.locator('li', { hasText: 'Ungesichertes Gerät' }).getByRole('button').click()
|
||||||
|
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
|
||||||
|
await expect(page.getByText('„Ungesichertes Gerät“ wurde entfernt.')).toBeVisible()
|
||||||
|
|
||||||
|
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
|
||||||
|
authenticatorId: authenticator.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bestaetigt eine sensible Aktion mit dem Passkey statt mit Passwort und Code', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const authenticator = await authenticatorAnlegen(context, page)
|
||||||
|
|
||||||
|
await anmelden(page)
|
||||||
|
await passkeyEinrichten(page, 'Bestätigungsgerät')
|
||||||
|
|
||||||
|
// Der Vollexport verlangt eine frische Anmeldung.
|
||||||
|
await page.goto('/berichte/export')
|
||||||
|
await page.waitForURL(/\/(bestaetigen|berichte)/)
|
||||||
|
|
||||||
|
if (new URL(page.url()).pathname === '/bestaetigen') {
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => url.pathname !== '/bestaetigen', { timeout: 45_000 }),
|
||||||
|
page.getByRole('button', { name: 'Mit Passkey bestätigen' }).click(),
|
||||||
|
])
|
||||||
|
expect(new URL(page.url()).pathname).toContain('/berichte/export')
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto('/konto')
|
||||||
|
await page.locator('li', { hasText: 'Bestätigungsgerät' }).getByRole('button').click()
|
||||||
|
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
|
||||||
|
await expect(page.getByText('„Bestätigungsgerät“ wurde entfernt.')).toBeVisible()
|
||||||
|
|
||||||
|
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
|
||||||
|
authenticatorId: authenticator.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Generated
+298
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "betreuungs-cockpit",
|
"name": "betreuungs-cockpit",
|
||||||
"version": "0.1.0",
|
"version": "1.0.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "betreuungs-cockpit",
|
"name": "betreuungs-cockpit",
|
||||||
"version": "0.1.0",
|
"version": "1.0.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.735.0",
|
"@aws-sdk/client-s3": "3.735.0",
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
"@radix-ui/react-label": "2.1.1",
|
"@radix-ui/react-label": "2.1.1",
|
||||||
"@radix-ui/react-slot": "1.1.1",
|
"@radix-ui/react-slot": "1.1.1",
|
||||||
"@react-pdf/renderer": "4.1.6",
|
"@react-pdf/renderer": "4.1.6",
|
||||||
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
|
"@simplewebauthn/server": "13.3.2",
|
||||||
"archiver": "7.0.1",
|
"archiver": "7.0.1",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -1573,6 +1575,12 @@
|
|||||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -2161,6 +2169,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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/lzma-linux-x64-gnu": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
@@ -2706,6 +2720,207 @@
|
|||||||
"pako": "^1.0.10"
|
"pako": "^1.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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-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/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": "1.14.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||||
|
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.6.0",
|
||||||
|
"@peculiar/asn1-csr": "^2.6.0",
|
||||||
|
"@peculiar/asn1-ecc": "^2.6.0",
|
||||||
|
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||||
|
"@peculiar/asn1-rsa": "^2.6.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.6.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.6.0",
|
||||||
|
"pvtsutils": "^1.3.6",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"tsyringe": "^4.10.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -4033,6 +4248,31 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@simplewebauthn/browser": {
|
||||||
|
"version": "13.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||||
|
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@simplewebauthn/server": {
|
||||||
|
"version": "13.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
|
||||||
|
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
|
||||||
|
"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/x509": "^1.14.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@smithy/config-resolver": {
|
"node_modules/@smithy/config-resolver": {
|
||||||
"version": "4.7.2",
|
"version": "4.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.7.2.tgz",
|
||||||
@@ -5855,6 +6095,20 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -10192,6 +10446,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/qrcode": {
|
"node_modules/qrcode": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
@@ -10398,6 +10670,12 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -11687,6 +11965,24 @@
|
|||||||
"fsevents": "~2.3.3"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betreuungs-cockpit",
|
"name": "betreuungs-cockpit",
|
||||||
"version": "1.0.5",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -44,6 +44,8 @@
|
|||||||
"@radix-ui/react-label": "2.1.1",
|
"@radix-ui/react-label": "2.1.1",
|
||||||
"@radix-ui/react-slot": "1.1.1",
|
"@radix-ui/react-slot": "1.1.1",
|
||||||
"@react-pdf/renderer": "4.1.6",
|
"@react-pdf/renderer": "4.1.6",
|
||||||
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
|
"@simplewebauthn/server": "13.3.2",
|
||||||
"archiver": "7.0.1",
|
"archiver": "7.0.1",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -90,5 +92,11 @@
|
|||||||
"prisma@6.3.1": true,
|
"prisma@6.3.1": true,
|
||||||
"sharp@0.33.5": true,
|
"sharp@0.33.5": true,
|
||||||
"unrs-resolver@1.12.2": true
|
"unrs-resolver@1.12.2": true
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"next-auth": {
|
||||||
|
"@simplewebauthn/server": "$@simplewebauthn/server",
|
||||||
|
"@simplewebauthn/browser": "$@simplewebauthn/browser"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg.
|
||||||
|
--
|
||||||
|
-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte
|
||||||
|
-- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und
|
||||||
|
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und
|
||||||
|
-- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt,
|
||||||
|
-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus
|
||||||
|
-- jeder erzeugten Migration entfernt.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "AuditAktion" ADD VALUE 'PASSKEY_REGISTRIERT';
|
||||||
|
ALTER TYPE "AuditAktion" ADD VALUE 'PASSKEY_ENTFERNT';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "passkey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"credentialId" TEXT NOT NULL,
|
||||||
|
"publicKey" BYTEA NOT NULL,
|
||||||
|
"counter" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"geraeteArt" TEXT,
|
||||||
|
"gesichert" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"zuletztBenutztAm" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "passkey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "passkey_anfrage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"challenge" TEXT NOT NULL,
|
||||||
|
"art" "PasskeyAnfrageArt" NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"ablaufAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "passkey_anfrage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "passkey_credentialId_key" ON "passkey"("credentialId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "passkey_userId_idx" ON "passkey"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "passkey_anfrage_challenge_key" ON "passkey_anfrage"("challenge");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "passkey_anfrage_ablaufAm_idx" ON "passkey_anfrage"("ablaufAm");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
+143
-55
@@ -89,6 +89,7 @@ model User {
|
|||||||
geloeschtAm DateTime?
|
geloeschtAm DateTime?
|
||||||
|
|
||||||
zugaenge BetreuungsZugang[]
|
zugaenge BetreuungsZugang[]
|
||||||
|
passkeys Passkey[]
|
||||||
auditEintraege AuditLog[]
|
auditEintraege AuditLog[]
|
||||||
angelegteVorgaenge Vorgang[] @relation("VorgangAngelegtVon")
|
angelegteVorgaenge Vorgang[] @relation("VorgangAngelegtVon")
|
||||||
berichte Bericht[]
|
berichte Bericht[]
|
||||||
@@ -135,6 +136,91 @@ model LoginVersuch {
|
|||||||
@@map("login_versuch")
|
@@map("login_versuch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein registrierter Passkey (WebAuthn/FIDO2).
|
||||||
|
///
|
||||||
|
/// Der private Schluessel verlaesst das Geraet nie - hier liegt nur der
|
||||||
|
/// oeffentliche Teil. Ein gestohlener Datenbankabzug erlaubt deshalb keine
|
||||||
|
/// Anmeldung, anders als bei einem Passworthash, den man offline angreifen
|
||||||
|
/// kann.
|
||||||
|
///
|
||||||
|
/// Ein Passkey zaehlt bei uns als ZWEI Faktoren: Besitz des Geraets plus
|
||||||
|
/// die Nutzerverifikation darauf (Fingerabdruck, Gesicht oder Geraete-PIN).
|
||||||
|
/// Deshalb wird bei der Anmeldung "userVerified" erzwungen - siehe
|
||||||
|
/// src/lib/auth/passkey.ts. Ohne diese Pruefung waere es nur ein Faktor.
|
||||||
|
model Passkey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Kennung des Authenticators, base64url. Eindeutig ueber alle Benutzer -
|
||||||
|
/// derselbe Schluessel darf nicht an zwei Zugaengen haengen.
|
||||||
|
credentialId String @unique
|
||||||
|
|
||||||
|
/// Oeffentlicher Schluessel im COSE-Format. Kein Geheimnis.
|
||||||
|
publicKey Bytes
|
||||||
|
|
||||||
|
/// Signaturzaehler des Authenticators. Zaehlt er nicht hoch, obwohl er
|
||||||
|
/// es vorher tat, ist der Schluessel womoeglich geklont worden.
|
||||||
|
/// BigInt, weil der Zaehler bis 2^32-1 geht.
|
||||||
|
counter BigInt @default(0)
|
||||||
|
|
||||||
|
/// "usb", "nfc", "ble", "internal", "hybrid" - hilft dem Browser beim
|
||||||
|
/// naechsten Mal, das richtige Geraet vorzuschlagen.
|
||||||
|
transports String[] @default([])
|
||||||
|
|
||||||
|
/// Vom Benutzer vergeben ("iPhone von Florian"). Bei mehreren Geraeten
|
||||||
|
/// ist das die einzige Moeglichkeit, sie auseinanderzuhalten.
|
||||||
|
name String
|
||||||
|
|
||||||
|
/// "singleDevice" = liegt nur auf diesem Geraet, "multiDevice" = wird vom
|
||||||
|
/// Schluesselbund synchronisiert. Steht in der Uebersicht, weil es den
|
||||||
|
/// Unterschied macht, ob ein verlorenes Geraet den Zugang mitnimmt.
|
||||||
|
geraeteArt String?
|
||||||
|
|
||||||
|
/// Ob der Anbieter den Schluessel gesichert hat (iCloud, Google, Bitwarden).
|
||||||
|
gesichert Boolean @default(false)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
zuletztBenutztAm DateTime?
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("passkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PasskeyAnfrageArt {
|
||||||
|
REGISTRIERUNG
|
||||||
|
ANMELDUNG
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurzlebiger Speicher fuer die Challenge einer laufenden Passkey-Aktion.
|
||||||
|
///
|
||||||
|
/// Die Challenge muss serverseitig liegen und genau einmal gelten, sonst
|
||||||
|
/// liesse sich eine mitgeschnittene Antwort erneut einspielen. Verbraucht
|
||||||
|
/// wird sie durch DELETE - dass genau eine Zeile geloescht wurde, ist der
|
||||||
|
/// Beweis, dass sie noch nicht benutzt war.
|
||||||
|
///
|
||||||
|
/// Bewusst ohne Fremdschluessel auf User: die Zeile lebt fuenf Minuten und
|
||||||
|
/// ist kein Fachdatum. Der Wartungslauf raeumt Liegengebliebenes weg.
|
||||||
|
model PasskeyAnfrage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
/// base64url, zufaellig. Eindeutig, damit das Verbrauchen atomar ist.
|
||||||
|
challenge String @unique
|
||||||
|
|
||||||
|
art PasskeyAnfrageArt
|
||||||
|
|
||||||
|
/// Bei der Registrierung bekannt. Bei der Anmeldung nicht - dort weiss
|
||||||
|
/// der Server erst nach der Antwort, wer sich anmeldet.
|
||||||
|
userId String?
|
||||||
|
|
||||||
|
ablaufAm DateTime
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([ablaufAm])
|
||||||
|
@@map("passkey_anfrage")
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Betreute Person
|
// Betreute Person
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -191,8 +277,8 @@ model Betreute {
|
|||||||
/// Seit der Reform 2023 Leitschnur des Betreuerhandelns.
|
/// Seit der Reform 2023 Leitschnur des Betreuerhandelns.
|
||||||
ermittelterWilleVerschluesselt String?
|
ermittelterWilleVerschluesselt String?
|
||||||
|
|
||||||
patientenverfuegungVorhanden Boolean @default(false)
|
patientenverfuegungVorhanden Boolean @default(false)
|
||||||
vorsorgevollmachtVorhanden Boolean @default(false)
|
vorsorgevollmachtVorhanden Boolean @default(false)
|
||||||
|
|
||||||
// --- Betreuungsgericht ---------------------------------------------------
|
// --- Betreuungsgericht ---------------------------------------------------
|
||||||
betreuungsgericht String?
|
betreuungsgericht String?
|
||||||
@@ -315,8 +401,8 @@ model Vorgang {
|
|||||||
kontaktprotokolle Kontaktprotokoll[]
|
kontaktprotokolle Kontaktprotokoll[]
|
||||||
aufgaben Aufgabe[]
|
aufgaben Aufgabe[]
|
||||||
verknuepfungen DokumentVerknuepfung[]
|
verknuepfungen DokumentVerknuepfung[]
|
||||||
tags Tag[] @relation("VorgangTags")
|
tags Tag[] @relation("VorgangTags")
|
||||||
kontakte Kontakt[] @relation("VorgangKontakte")
|
kontakte Kontakt[] @relation("VorgangKontakte")
|
||||||
|
|
||||||
@@index([betreuteId, status])
|
@@index([betreuteId, status])
|
||||||
@@index([betreuteId, kategorie])
|
@@index([betreuteId, kategorie])
|
||||||
@@ -754,21 +840,21 @@ model Buchung {
|
|||||||
vorgangId String?
|
vorgangId String?
|
||||||
vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull)
|
vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
datum DateTime @db.Date
|
datum DateTime @db.Date
|
||||||
wertstellung DateTime? @db.Date
|
wertstellung DateTime? @db.Date
|
||||||
betragCent BigInt
|
betragCent BigInt
|
||||||
richtung BuchungRichtung
|
richtung BuchungRichtung
|
||||||
kategorie BuchungKategorie @default(SONSTIGE_AUSGABE)
|
kategorie BuchungKategorie @default(SONSTIGE_AUSGABE)
|
||||||
gegenstelle String?
|
gegenstelle String?
|
||||||
verwendungszweckVerschluesselt String?
|
verwendungszweckVerschluesselt String?
|
||||||
|
|
||||||
belegId String?
|
belegId String?
|
||||||
beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull)
|
beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull)
|
||||||
/// Buchung ohne Beleg wird in der Liste markiert - Belegzwang.
|
/// Buchung ohne Beleg wird in der Liste markiert - Belegzwang.
|
||||||
belegFehltBegruendung String?
|
belegFehltBegruendung String?
|
||||||
|
|
||||||
/// false = Auslage des Betreuers, die erstattet werden muss.
|
/// false = Auslage des Betreuers, die erstattet werden muss.
|
||||||
betrifftBetreute Boolean @default(true)
|
betrifftBetreute Boolean @default(true)
|
||||||
/// Verschiebt das Vermoegen (z.B. Verkaufserloes), nicht nur laufende
|
/// Verschiebt das Vermoegen (z.B. Verkaufserloes), nicht nur laufende
|
||||||
/// Einnahme/Ausgabe. Relevant fuer die Vermoegensuebersicht.
|
/// Einnahme/Ausgabe. Relevant fuer die Vermoegensuebersicht.
|
||||||
vermoegenswirksam Boolean @default(false)
|
vermoegenswirksam Boolean @default(false)
|
||||||
@@ -827,7 +913,7 @@ model Vermoegensposten {
|
|||||||
|
|
||||||
/// Passiva werden als eigener Posten mit Art VERBINDLICHKEIT gefuehrt;
|
/// Passiva werden als eigener Posten mit Art VERBINDLICHKEIT gefuehrt;
|
||||||
/// der Wert bleibt positiv, das Vorzeichen ergibt sich aus der Art.
|
/// der Wert bleibt positiv, das Vorzeichen ergibt sich aus der Art.
|
||||||
bewertungSozialhilfe SozialhilfeBewertung @default(UNGEPRUEFT)
|
bewertungSozialhilfe SozialhilfeBewertung @default(UNGEPRUEFT)
|
||||||
/// Warum halte ich den Posten fuer geschuetzt? Verweis auf § 90 SGB XII,
|
/// Warum halte ich den Posten fuer geschuetzt? Verweis auf § 90 SGB XII,
|
||||||
/// Schonvermoegen, Bestattungsvorsorge, selbst bewohntes Hausgrundstueck.
|
/// Schonvermoegen, Bestattungsvorsorge, selbst bewohntes Hausgrundstueck.
|
||||||
bewertungBegruendungVerschluesselt String?
|
bewertungBegruendungVerschluesselt String?
|
||||||
@@ -852,8 +938,8 @@ model Wertstand {
|
|||||||
vermoegenspostenId String
|
vermoegenspostenId String
|
||||||
vermoegensposten Vermoegensposten @relation(fields: [vermoegenspostenId], references: [id], onDelete: Cascade)
|
vermoegensposten Vermoegensposten @relation(fields: [vermoegenspostenId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
stichtag DateTime @db.Date
|
stichtag DateTime @db.Date
|
||||||
wertCent BigInt
|
wertCent BigInt
|
||||||
/// Woher stammt der Wert? "Rueckkaufswertmitteilung vom 12.03.2026",
|
/// Woher stammt der Wert? "Rueckkaufswertmitteilung vom 12.03.2026",
|
||||||
/// "Maklergutachten", "Bodenrichtwert", "eigene Schaetzung".
|
/// "Maklergutachten", "Bodenrichtwert", "eigene Schaetzung".
|
||||||
wertquelle String
|
wertquelle String
|
||||||
@@ -905,7 +991,7 @@ model Immobilie {
|
|||||||
bewohntStatus String? // "leerstehend", "vermietet", "selbst bewohnt"
|
bewohntStatus String? // "leerstehend", "vermietet", "selbst bewohnt"
|
||||||
leerstandSeit DateTime? @db.Date
|
leerstandSeit DateTime? @db.Date
|
||||||
|
|
||||||
statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT)
|
statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT)
|
||||||
statusBegruendungVerschluesselt String?
|
statusBegruendungVerschluesselt String?
|
||||||
|
|
||||||
notizenVerschluesselt String?
|
notizenVerschluesselt String?
|
||||||
@@ -951,14 +1037,14 @@ model ImmobilienCheckpunkt {
|
|||||||
immobilieId String
|
immobilieId String
|
||||||
immobilie Immobilie @relation(fields: [immobilieId], references: [id], onDelete: Cascade)
|
immobilie Immobilie @relation(fields: [immobilieId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
titel String
|
titel String
|
||||||
beschreibung String?
|
beschreibung String?
|
||||||
erledigt Boolean @default(false)
|
erledigt Boolean @default(false)
|
||||||
erledigtAm DateTime? @db.Date
|
erledigtAm DateTime? @db.Date
|
||||||
faelligAm DateTime? @db.Date
|
faelligAm DateTime? @db.Date
|
||||||
/// Wiederkehrend (z.B. Kontrollgang, Zaehlerstand) als RRULE.
|
/// Wiederkehrend (z.B. Kontrollgang, Zaehlerstand) als RRULE.
|
||||||
wiederholungRrule String?
|
wiederholungRrule String?
|
||||||
reihenfolge Int @default(0)
|
reihenfolge Int @default(0)
|
||||||
|
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
|
|
||||||
@@ -1024,8 +1110,8 @@ model Vertrag {
|
|||||||
|
|
||||||
vertragsnummerVerschluesselt String?
|
vertragsnummerVerschluesselt String?
|
||||||
|
|
||||||
beginnAm DateTime? @db.Date
|
beginnAm DateTime? @db.Date
|
||||||
endeAm DateTime? @db.Date
|
endeAm DateTime? @db.Date
|
||||||
/// Kuendigungsfrist in Monaten zum Ende der Laufzeit.
|
/// Kuendigungsfrist in Monaten zum Ende der Laufzeit.
|
||||||
kuendigungsfristMonate Int?
|
kuendigungsfristMonate Int?
|
||||||
kuendigungZum DateTime? @db.Date
|
kuendigungZum DateTime? @db.Date
|
||||||
@@ -1095,13 +1181,13 @@ model Kontakt {
|
|||||||
organisation String?
|
organisation String?
|
||||||
rolle KontaktRolle @default(SONSTIGES)
|
rolle KontaktRolle @default(SONSTIGES)
|
||||||
|
|
||||||
telefon String?
|
telefon String?
|
||||||
mobil String?
|
mobil String?
|
||||||
fax String?
|
fax String?
|
||||||
email String?
|
email String?
|
||||||
strasse String?
|
strasse String?
|
||||||
plz String?
|
plz String?
|
||||||
ort String?
|
ort String?
|
||||||
/// Aktenzeichen, unter dem dieser Kontakt den Vorgang fuehrt.
|
/// Aktenzeichen, unter dem dieser Kontakt den Vorgang fuehrt.
|
||||||
aktenzeichen String?
|
aktenzeichen String?
|
||||||
|
|
||||||
@@ -1112,7 +1198,7 @@ model Kontakt {
|
|||||||
geloeschtAm DateTime?
|
geloeschtAm DateTime?
|
||||||
|
|
||||||
protokolle Kontaktprotokoll[]
|
protokolle Kontaktprotokoll[]
|
||||||
vorgaenge Vorgang[] @relation("VorgangKontakte")
|
vorgaenge Vorgang[] @relation("VorgangKontakte")
|
||||||
verknuepfungen DokumentVerknuepfung[]
|
verknuepfungen DokumentVerknuepfung[]
|
||||||
|
|
||||||
@@index([betreuteId, rolle])
|
@@index([betreuteId, rolle])
|
||||||
@@ -1149,7 +1235,7 @@ model Kontaktprotokoll {
|
|||||||
/// true = persoenlicher Kontakt zur betreuten Person selbst.
|
/// true = persoenlicher Kontakt zur betreuten Person selbst.
|
||||||
mitBetreuter Boolean @default(false)
|
mitBetreuter Boolean @default(false)
|
||||||
|
|
||||||
inhaltVerschluesselt String?
|
inhaltVerschluesselt String?
|
||||||
/// Geaeusserte Wuensche - fuer § 1821 BGB und den Jahresbericht.
|
/// Geaeusserte Wuensche - fuer § 1821 BGB und den Jahresbericht.
|
||||||
wuenscheVerschluesselt String?
|
wuenscheVerschluesselt String?
|
||||||
folgeaufgabenVerschluesselt String?
|
folgeaufgabenVerschluesselt String?
|
||||||
@@ -1224,10 +1310,10 @@ model Heimvertrag {
|
|||||||
plz String?
|
plz String?
|
||||||
ort String?
|
ort String?
|
||||||
|
|
||||||
vertragAm DateTime? @db.Date
|
vertragAm DateTime? @db.Date
|
||||||
einzugAm DateTime? @db.Date
|
einzugAm DateTime? @db.Date
|
||||||
auszugAm DateTime? @db.Date
|
auszugAm DateTime? @db.Date
|
||||||
kuendigungsfristTage Int? @default(14)
|
kuendigungsfristTage Int? @default(14)
|
||||||
|
|
||||||
// Aktuelle Saetze - historische Werte stehen in Heimkostenmonat.
|
// Aktuelle Saetze - historische Werte stehen in Heimkostenmonat.
|
||||||
pflegeEigenanteilCent BigInt? // einrichtungseinheitlicher Eigenanteil
|
pflegeEigenanteilCent BigInt? // einrichtungseinheitlicher Eigenanteil
|
||||||
@@ -1261,7 +1347,7 @@ model Heimkostenmonat {
|
|||||||
zusatzleistungenCent BigInt @default(0)
|
zusatzleistungenCent BigInt @default(0)
|
||||||
|
|
||||||
// Deckungsseite
|
// Deckungsseite
|
||||||
pflegekasseCent BigInt @default(0) // § 43 SGB XI
|
pflegekasseCent BigInt @default(0) // § 43 SGB XI
|
||||||
/// Leistungszuschlag nach § 43c SGB XI, abhaengig von der Verweildauer.
|
/// Leistungszuschlag nach § 43c SGB XI, abhaengig von der Verweildauer.
|
||||||
leistungszuschlagProzent Int @default(0)
|
leistungszuschlagProzent Int @default(0)
|
||||||
leistungszuschlagCent BigInt @default(0)
|
leistungszuschlagCent BigInt @default(0)
|
||||||
@@ -1360,14 +1446,14 @@ model Nachforderung {
|
|||||||
erlaeuterung String?
|
erlaeuterung String?
|
||||||
status NachforderungStatus @default(OFFEN)
|
status NachforderungStatus @default(OFFEN)
|
||||||
|
|
||||||
angefordertAm DateTime? @db.Date
|
angefordertAm DateTime? @db.Date
|
||||||
fristBis DateTime? @db.Date
|
fristBis DateTime? @db.Date
|
||||||
uebersandtAm DateTime? @db.Date
|
uebersandtAm DateTime? @db.Date
|
||||||
/// Wenn die Unterlage nicht existiert: warum nicht? Geht als Erklaerung
|
/// Wenn die Unterlage nicht existiert: warum nicht? Geht als Erklaerung
|
||||||
/// mit ins Unterlagenpaket.
|
/// mit ins Unterlagenpaket.
|
||||||
begruendungNichtVorhanden String?
|
begruendungNichtVorhanden String?
|
||||||
|
|
||||||
reihenfolge Int @default(0)
|
reihenfolge Int @default(0)
|
||||||
/// Vorbefuellte Standardunterlage aus der Checkliste.
|
/// Vorbefuellte Standardunterlage aus der Checkliste.
|
||||||
ausCheckliste Boolean @default(false)
|
ausCheckliste Boolean @default(false)
|
||||||
|
|
||||||
@@ -1387,12 +1473,12 @@ model Bescheid {
|
|||||||
antragId String
|
antragId String
|
||||||
antrag SozialhilfeAntrag @relation(fields: [antragId], references: [id], onDelete: Cascade)
|
antrag SozialhilfeAntrag @relation(fields: [antragId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
art BescheidArt
|
art BescheidArt
|
||||||
bescheidVom DateTime @db.Date
|
bescheidVom DateTime @db.Date
|
||||||
/// Massgeblich fuer den Fristbeginn - nicht das Datum auf dem Bescheid.
|
/// Massgeblich fuer den Fristbeginn - nicht das Datum auf dem Bescheid.
|
||||||
zugegangenAm DateTime? @db.Date
|
zugegangenAm DateTime? @db.Date
|
||||||
zeitraumVon DateTime? @db.Date
|
zeitraumVon DateTime? @db.Date
|
||||||
zeitraumBis DateTime? @db.Date
|
zeitraumBis DateTime? @db.Date
|
||||||
betragCent BigInt?
|
betragCent BigInt?
|
||||||
|
|
||||||
/// In der Regel ein Monat ab Bekanntgabe (§ 84 SGG). Bei fehlender oder
|
/// In der Regel ein Monat ab Bekanntgabe (§ 84 SGG). Bei fehlender oder
|
||||||
@@ -1482,11 +1568,11 @@ model Bericht {
|
|||||||
betreuteId String
|
betreuteId String
|
||||||
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
|
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
art BerichtArt
|
art BerichtArt
|
||||||
titel String
|
titel String
|
||||||
zeitraumVon DateTime? @db.Date
|
zeitraumVon DateTime? @db.Date
|
||||||
zeitraumBis DateTime? @db.Date
|
zeitraumBis DateTime? @db.Date
|
||||||
stichtag DateTime? @db.Date
|
stichtag DateTime? @db.Date
|
||||||
|
|
||||||
/// Vom Nutzer ueberarbeiteter Freitext (Jahresbericht) - verschluesselt.
|
/// Vom Nutzer ueberarbeiteter Freitext (Jahresbericht) - verschluesselt.
|
||||||
entwurfVerschluesselt String?
|
entwurfVerschluesselt String?
|
||||||
@@ -1527,6 +1613,8 @@ enum AuditAktion {
|
|||||||
ANMELDUNG_FEHLGESCHLAGEN
|
ANMELDUNG_FEHLGESCHLAGEN
|
||||||
ABMELDUNG
|
ABMELDUNG
|
||||||
ZWEITER_FAKTOR_EINGERICHTET
|
ZWEITER_FAKTOR_EINGERICHTET
|
||||||
|
PASSKEY_REGISTRIERT
|
||||||
|
PASSKEY_ENTFERNT
|
||||||
PASSWORT_GEAENDERT
|
PASSWORT_GEAENDERT
|
||||||
ROLLE_GEAENDERT
|
ROLLE_GEAENDERT
|
||||||
FREIGABE_GEAENDERT
|
FREIGABE_GEAENDERT
|
||||||
@@ -1539,8 +1627,8 @@ model AuditLog {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
zeitpunkt DateTime @default(now())
|
zeitpunkt DateTime @default(now())
|
||||||
|
|
||||||
userId String?
|
userId String?
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
/// Momentaufnahme - bleibt lesbar, auch wenn der Benutzer geloescht wird.
|
/// Momentaufnahme - bleibt lesbar, auch wenn der Benutzer geloescht wird.
|
||||||
userEmail String?
|
userEmail String?
|
||||||
userName String?
|
userName String?
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useActionState } from 'react'
|
import { useActionState, useEffect, useState } from 'react'
|
||||||
import { useFormStatus } from 'react-dom'
|
import { useFormStatus } from 'react-dom'
|
||||||
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { AlertCircle, Fingerprint, Loader2, ShieldCheck } from 'lucide-react'
|
||||||
import { anmeldungBestaetigen, type BestaetigenZustand } from '@/server/actions/bestaetigen'
|
import { anmeldungBestaetigen, type BestaetigenZustand } from '@/server/actions/bestaetigen'
|
||||||
|
import { passkeyBestaetigen, passkeyBestaetigungStarten } from '@/server/actions/passkey'
|
||||||
|
import {
|
||||||
|
browserSupportsWebAuthn,
|
||||||
|
passkeyBrowserfehler,
|
||||||
|
startAuthentication,
|
||||||
|
} from '@/lib/passkey-browser'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -19,36 +26,133 @@ function Absenden() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BestaetigenFormular({ weiter }: { weiter: string }) {
|
export function BestaetigenFormular({
|
||||||
|
weiter,
|
||||||
|
passkeysMoeglich,
|
||||||
|
}: {
|
||||||
|
weiter: string
|
||||||
|
passkeysMoeglich: boolean
|
||||||
|
}) {
|
||||||
const [zustand, aktion] = useActionState<BestaetigenZustand, FormData>(anmeldungBestaetigen, {})
|
const [zustand, aktion] = useActionState<BestaetigenZustand, FormData>(anmeldungBestaetigen, {})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={aktion} className="space-y-4" noValidate>
|
<div className="space-y-5">
|
||||||
<input type="hidden" name="weiter" value={weiter} />
|
<form action={aktion} className="space-y-4" noValidate>
|
||||||
|
<input type="hidden" name="weiter" value={weiter} />
|
||||||
|
|
||||||
{zustand.fehler && (
|
{zustand.fehler && (
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Feld id="passwort" beschriftung="Passwort" pflicht>
|
||||||
|
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
|
||||||
|
<Input
|
||||||
|
name="code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="123456"
|
||||||
|
className="text-center font-mono text-lg tracking-[0.4em]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Absenden />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{passkeysMoeglich && <PasskeyBestaetigung weiter={weiter} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestaetigung mit Passkey statt mit Passwort und Code.
|
||||||
|
*
|
||||||
|
* Geprueft wird zusaetzlich, dass der Passkey zum angemeldeten Benutzer
|
||||||
|
* gehoert - sonst koennte jemand mit dem eigenen Finger eine fremde,
|
||||||
|
* offen stehende Sitzung freischalten. Genau der Fall, gegen den diese
|
||||||
|
* Seite gebaut ist.
|
||||||
|
*/
|
||||||
|
function PasskeyBestaetigung({ weiter }: { weiter: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [browserKann, setBrowserKann] = useState(false)
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBrowserKann(browserSupportsWebAuthn())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function bestaetigen() {
|
||||||
|
setFehler(null)
|
||||||
|
setLaeuft(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = await passkeyBestaetigungStarten()
|
||||||
|
if (!start.ok) {
|
||||||
|
setFehler(start.fehler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = await startAuthentication({ optionsJSON: start.optionen })
|
||||||
|
const ergebnis = await passkeyBestaetigen(JSON.stringify(antwort))
|
||||||
|
|
||||||
|
if (!ergebnis.ok) {
|
||||||
|
setFehler(ergebnis.fehler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(weiter)
|
||||||
|
router.refresh()
|
||||||
|
} catch (ausnahme) {
|
||||||
|
setFehler(passkeyBrowserfehler(ausnahme))
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!browserKann) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">oder</span>
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
<Alert variant="fehler">
|
<Alert variant="fehler">
|
||||||
<AlertCircle aria-hidden="true" />
|
<AlertCircle aria-hidden="true" />
|
||||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
<AlertDescription>{fehler}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Feld id="passwort" beschriftung="Passwort" pflicht>
|
<Button
|
||||||
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
|
type="button"
|
||||||
</Feld>
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
|
className="w-full"
|
||||||
<Input
|
onClick={() => void bestaetigen()}
|
||||||
name="code"
|
disabled={laeuft}
|
||||||
inputMode="numeric"
|
>
|
||||||
autoComplete="one-time-code"
|
{laeuft ? (
|
||||||
placeholder="123456"
|
<>
|
||||||
className="text-center font-mono text-lg tracking-[0.4em]"
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
required
|
Warte auf das Gerät …
|
||||||
/>
|
</>
|
||||||
</Feld>
|
) : (
|
||||||
|
<>
|
||||||
<Absenden />
|
<Fingerprint aria-hidden="true" />
|
||||||
</form>
|
Mit Passkey bestätigen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ShieldAlert } from 'lucide-react'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
||||||
|
import { passkeysMoeglich } from '@/lib/auth/passkey'
|
||||||
import { BestaetigenFormular } from './formular'
|
import { BestaetigenFormular } from './formular'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Bestätigen' }
|
export const metadata: Metadata = { title: 'Bestätigen' }
|
||||||
@@ -26,7 +27,7 @@ export default async function BestaetigenSeite({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<BestaetigenFormular weiter={weiter} />
|
<BestaetigenFormular weiter={weiter} passkeysMoeglich={passkeysMoeglich()} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { KeyRound, ShieldCheck } from 'lucide-react'
|
import { Fingerprint, KeyRound, ShieldCheck } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
||||||
|
import { passkeysDesBenutzers, passkeysMoeglich } from '@/lib/auth/passkey'
|
||||||
|
import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung'
|
||||||
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
|
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
|
||||||
import { formatDatumZeit } from '@/lib/utils'
|
import { formatDatumZeit } from '@/lib/utils'
|
||||||
import { PasswortFormular } from './passwort-formular'
|
import { PasswortFormular } from './passwort-formular'
|
||||||
@@ -16,10 +18,11 @@ export const dynamic = 'force-dynamic'
|
|||||||
export default async function KontoSeite() {
|
export default async function KontoSeite() {
|
||||||
const benutzer = await erfordereBenutzer()
|
const benutzer = await erfordereBenutzer()
|
||||||
const codesUebrig = benutzer.wiederherstellungscodes.length
|
const codesUebrig = benutzer.wiederherstellungscodes.length
|
||||||
|
const passkeys = await passkeysDesBenutzers(benutzer.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten und zweiter Faktor." />
|
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten, zweiter Faktor und Passkeys." />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -89,6 +92,28 @@ export default async function KontoSeite() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Fingerprint className="size-5" aria-hidden="true" />
|
||||||
|
Passkeys
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Anmelden mit Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail, Passwort und Code.
|
||||||
|
Ein Passkey gilt als zweiter Faktor, weil er Besitz des Geräts und dessen Entsperrung
|
||||||
|
verlangt. Er ist an diese Adresse gebunden: Auf einer nachgebauten Anmeldeseite
|
||||||
|
funktioniert er nicht.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PasskeyVerwaltung
|
||||||
|
passkeys={passkeys}
|
||||||
|
zweiterFaktorAktiv={Boolean(benutzer.totpAktiviertAm)}
|
||||||
|
moeglich={passkeysMoeglich()}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
|
|||||||
ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen',
|
ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen',
|
||||||
ABMELDUNG: 'abgemeldet',
|
ABMELDUNG: 'abgemeldet',
|
||||||
ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet',
|
ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet',
|
||||||
|
PASSKEY_REGISTRIERT: 'Passkey eingerichtet',
|
||||||
|
PASSKEY_ENTFERNT: 'Passkey entfernt',
|
||||||
PASSWORT_GEAENDERT: 'Passwort geändert',
|
PASSWORT_GEAENDERT: 'Passwort geändert',
|
||||||
ROLLE_GEAENDERT: 'Rolle geändert',
|
ROLLE_GEAENDERT: 'Rolle geändert',
|
||||||
FREIGABE_GEAENDERT: 'Freigabe geändert',
|
FREIGABE_GEAENDERT: 'Freigabe geändert',
|
||||||
@@ -41,7 +43,8 @@ const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
|
|||||||
|
|
||||||
function variante(aktion: AuditAktion) {
|
function variante(aktion: AuditAktion) {
|
||||||
if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot'
|
if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot'
|
||||||
if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT') return 'orange'
|
if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT' || aktion === 'PASSKEY_ENTFERNT')
|
||||||
|
return 'orange'
|
||||||
if (aktion === 'ERSTELLT') return 'gruen'
|
if (aktion === 'ERSTELLT') return 'gruen'
|
||||||
return 'secondary'
|
return 'secondary'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ShieldCheck } from 'lucide-react'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
|
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
|
import { passkeysMoeglich } from '@/lib/auth/passkey'
|
||||||
|
import { PasskeyAnmeldung } from '@/components/passkey-anmeldung'
|
||||||
import { AnmeldeFormular } from './formular'
|
import { AnmeldeFormular } from './formular'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Anmelden' }
|
export const metadata: Metadata = { title: 'Anmelden' }
|
||||||
@@ -32,10 +34,14 @@ export default async function AnmeldenSeite({
|
|||||||
<CardTitle>Anmelden</CardTitle>
|
<CardTitle>Anmelden</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
|
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
|
||||||
|
{passkeysMoeglich() && ' Oder in einem Schritt mit deinem Passkey.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-5">
|
||||||
<AnmeldeFormular weiter={weiter} />
|
<AnmeldeFormular weiter={weiter} />
|
||||||
|
{/* Nur wenn der Server ueber https laeuft - sonst gibt der
|
||||||
|
Browser WebAuthn gar nicht frei. */}
|
||||||
|
{passkeysMoeglich() && <PasskeyAnmeldung weiter={weiter} />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -43,7 +49,9 @@ export default async function AnmeldenSeite({
|
|||||||
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||||
<p>
|
<p>
|
||||||
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
|
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
|
||||||
deshalb Pflicht und lässt sich nicht abschalten.
|
deshalb Pflicht und lässt sich nicht abschalten. Ein Passkey erfüllt ihn ebenfalls: Er
|
||||||
|
verlangt Fingerabdruck, Gesicht oder Geräte-PIN — und lässt sich, anders als ein
|
||||||
|
abgetippter Code, nicht auf einer nachgebauten Seite abgreifen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+112
@@ -12,6 +12,8 @@ import {
|
|||||||
versuchFesthalten,
|
versuchFesthalten,
|
||||||
} from '@/lib/auth/ratelimit'
|
} from '@/lib/auth/ratelimit'
|
||||||
import { protokollieren } from '@/lib/audit'
|
import { protokollieren } from '@/lib/audit'
|
||||||
|
import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey'
|
||||||
|
import type { AuthenticationResponseJSON } from '@simplewebauthn/server'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt.
|
* Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt.
|
||||||
@@ -30,6 +32,8 @@ export type AnmeldeGrund =
|
|||||||
| 'gesperrt'
|
| 'gesperrt'
|
||||||
| 'konto-inaktiv'
|
| 'konto-inaktiv'
|
||||||
| 'code-falsch'
|
| 'code-falsch'
|
||||||
|
/** Passkey-Fehler; der genaue Grund haengt mit Doppelpunkt daran. */
|
||||||
|
| 'passkey'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
|
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
|
||||||
@@ -45,6 +49,19 @@ export class AnmeldeFehler extends CredentialsSignin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie AnmeldeFehler, nur fuer Passkeys: der Grund wird als "passkey:<grund>"
|
||||||
|
* kodiert, damit die Anmeldeseite die passende Erklaerung zeigen kann.
|
||||||
|
*/
|
||||||
|
export class PasskeyAnmeldeFehler extends CredentialsSignin {
|
||||||
|
code: string
|
||||||
|
|
||||||
|
constructor(grund: string) {
|
||||||
|
super(grund)
|
||||||
|
this.code = `passkey:${grund}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ipAusAnfrage(anfrage: Request | undefined): string | null {
|
function ipAusAnfrage(anfrage: Request | undefined): string | null {
|
||||||
if (!anfrage) return null
|
if (!anfrage) return null
|
||||||
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||||
@@ -203,6 +220,101 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung mit Passkey - ein Schritt, ohne E-Mail und ohne Passwort.
|
||||||
|
*
|
||||||
|
* Die vollstaendige Pruefung laeuft hier drin und nicht in der Server
|
||||||
|
* Action davor. Das ist Absicht: Wuerde die Action pruefen und das
|
||||||
|
* Ergebnis anschliessend an signIn() weiterreichen, gaebe es einen
|
||||||
|
* Zwischenzustand, dem authorize() glauben muesste. Genau solche
|
||||||
|
* "war schon geprueft"-Uebergaben sind die Stelle, an der sich
|
||||||
|
* Anmeldungen faelschen lassen. Hier gibt es sie nicht.
|
||||||
|
*/
|
||||||
|
Credentials({
|
||||||
|
id: 'passkey',
|
||||||
|
name: 'Passkey',
|
||||||
|
credentials: {
|
||||||
|
antwort: { label: 'Antwort des Authenticators', type: 'text' },
|
||||||
|
},
|
||||||
|
|
||||||
|
async authorize(daten, anfrage) {
|
||||||
|
const ip = ipAusAnfrage(anfrage)
|
||||||
|
|
||||||
|
let antwort: AuthenticationResponseJSON
|
||||||
|
try {
|
||||||
|
antwort = JSON.parse(String(daten?.antwort ?? '')) as AuthenticationResponseJSON
|
||||||
|
} catch {
|
||||||
|
throw new PasskeyAnmeldeFehler('ungueltig')
|
||||||
|
}
|
||||||
|
|
||||||
|
let geprueft
|
||||||
|
try {
|
||||||
|
geprueft = await anmeldungPruefen(antwort)
|
||||||
|
} catch (fehler) {
|
||||||
|
// Fehlversuche zaehlen mit, auch wenn der Zugang unbekannt ist -
|
||||||
|
// sonst waere der Passkey-Weg ein Schlupfloch an der Sperre vorbei.
|
||||||
|
await versuchFesthalten('(passkey)', ip, false, 'passkey-ungueltig')
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
||||||
|
entitaet: 'User',
|
||||||
|
userEmail: '(passkey)',
|
||||||
|
bezeichnung:
|
||||||
|
fehler instanceof PasskeyFehler ? `Passkey: ${fehler.grund}` : 'Passkey abgewiesen',
|
||||||
|
})
|
||||||
|
throw new PasskeyAnmeldeFehler(
|
||||||
|
fehler instanceof PasskeyFehler ? fehler.grund : 'ungueltig',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { benutzer, passkey } = geprueft
|
||||||
|
|
||||||
|
// Die Sperre gilt fuer den Zugang, nicht fuer das Verfahren. Sie
|
||||||
|
// wird erst hier geprueft, weil vorher niemand weiss, wer sich
|
||||||
|
// anmeldet - erraten laesst sich ein Passkey ohnehin nicht.
|
||||||
|
const sperre = await anmeldungErlaubt(benutzer.email, ip)
|
||||||
|
if (sperre.gesperrt) {
|
||||||
|
await versuchFesthalten(benutzer.email, ip, false, 'gesperrt')
|
||||||
|
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!benutzer.aktiv || benutzer.geloeschtAm) {
|
||||||
|
await versuchFesthalten(benutzer.email, ip, false, 'konto-inaktiv')
|
||||||
|
throw new AnmeldeFehler('konto-inaktiv')
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
versuchFesthalten(benutzer.email, ip, true, 'passkey'),
|
||||||
|
fehlversucheZuruecksetzen(benutzer.email),
|
||||||
|
db.user.update({
|
||||||
|
where: { id: benutzer.id },
|
||||||
|
data: { letzteAnmeldung: new Date() },
|
||||||
|
}),
|
||||||
|
protokollieren({
|
||||||
|
aktion: 'ANMELDUNG',
|
||||||
|
entitaet: 'User',
|
||||||
|
entitaetId: benutzer.id,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
bezeichnung: `Mit Passkey „${passkey.name}“`,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: benutzer.id,
|
||||||
|
email: benutzer.email,
|
||||||
|
name: benutzer.name,
|
||||||
|
rolle: benutzer.rolle,
|
||||||
|
sitzungsGeneration: benutzer.sitzungsGeneration,
|
||||||
|
// Ein Passkey mit Nutzerverifikation IST der zweite Faktor. Ohne
|
||||||
|
// eingerichtetes TOTP laesst er sich gar nicht erst anlegen, das
|
||||||
|
// Flag ist hier also immer wahr - es wird trotzdem aus dem
|
||||||
|
// Benutzer gelesen und nicht fest gesetzt.
|
||||||
|
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
callbacks: {
|
callbacks: {
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Fingerprint,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Smartphone,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
passkeyEinrichtungAbschliessen,
|
||||||
|
passkeyEinrichtungStarten,
|
||||||
|
passkeyEntfernen,
|
||||||
|
} from '@/server/actions/passkey'
|
||||||
|
import {
|
||||||
|
browserSupportsWebAuthn,
|
||||||
|
passkeyBrowserfehler,
|
||||||
|
startRegistration,
|
||||||
|
} from '@/lib/passkey-browser'
|
||||||
|
import type { PasskeyUebersicht } from '@/lib/auth/passkey'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Feld } from '@/components/feld'
|
||||||
|
import { formatDatum, formatDatumZeit } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltung der eigenen Passkeys unter "Mein Konto".
|
||||||
|
*
|
||||||
|
* Die Liste kommt vom Server; nach jeder Aenderung wird die Seite neu
|
||||||
|
* geladen (router.refresh) statt den Zustand hier mitzufuehren. Bei einer
|
||||||
|
* Handvoll Eintraegen ist das die ehrlichere Loesung: Was auf dem Bildschirm
|
||||||
|
* steht, steht auch so in der Datenbank.
|
||||||
|
*/
|
||||||
|
export function PasskeyVerwaltung({
|
||||||
|
passkeys,
|
||||||
|
zweiterFaktorAktiv,
|
||||||
|
moeglich,
|
||||||
|
}: {
|
||||||
|
passkeys: PasskeyUebersicht[]
|
||||||
|
zweiterFaktorAktiv: boolean
|
||||||
|
/** Ob der Server Passkeys ueberhaupt anbieten kann (https). */
|
||||||
|
moeglich: boolean
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [browserKann, setBrowserKann] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [entfernt, setEntfernt] = useState<string | null>(null)
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
const [erfolg, setErfolg] = useState<string | null>(null)
|
||||||
|
const [nachfrage, setNachfrage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBrowserKann(browserSupportsWebAuthn())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function einrichten() {
|
||||||
|
setFehler(null)
|
||||||
|
setErfolg(null)
|
||||||
|
|
||||||
|
if (name.trim() === '') {
|
||||||
|
setFehler('Bitte gib dem Gerät zuerst einen Namen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLaeuft(true)
|
||||||
|
try {
|
||||||
|
const start = await passkeyEinrichtungStarten()
|
||||||
|
if (!start.ok) {
|
||||||
|
setFehler(start.fehler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = await startRegistration({ optionsJSON: start.optionen })
|
||||||
|
const ergebnis = await passkeyEinrichtungAbschliessen(name, JSON.stringify(antwort))
|
||||||
|
|
||||||
|
if (!ergebnis.ok) {
|
||||||
|
setFehler(ergebnis.fehler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setName('')
|
||||||
|
setErfolg(ergebnis.meldung ?? 'Passkey eingerichtet.')
|
||||||
|
router.refresh()
|
||||||
|
} catch (ausnahme) {
|
||||||
|
setFehler(passkeyBrowserfehler(ausnahme, true))
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entfernen(id: string) {
|
||||||
|
setFehler(null)
|
||||||
|
setErfolg(null)
|
||||||
|
setNachfrage(null)
|
||||||
|
setEntfernt(id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ergebnis = await passkeyEntfernen(id)
|
||||||
|
if (!ergebnis.ok) {
|
||||||
|
setFehler(ergebnis.fehler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setErfolg(ergebnis.meldung ?? 'Passkey entfernt.')
|
||||||
|
router.refresh()
|
||||||
|
} finally {
|
||||||
|
setEntfernt(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moeglich) {
|
||||||
|
return (
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation ist nicht über
|
||||||
|
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">https</code>
|
||||||
|
erreichbar — sobald sie es ist, erscheint hier die Verwaltung.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zweiterFaktorAktiv) {
|
||||||
|
return (
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ginge das
|
||||||
|
verloren und wäre er der einzige Weg hinein, käme niemand mehr an die Akte. Der Passkey
|
||||||
|
ist die bequeme Tür, nicht die einzige.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fehler && (
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>{fehler}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{erfolg && (
|
||||||
|
<Alert variant="erfolg">
|
||||||
|
<CheckCircle2 aria-hidden="true" />
|
||||||
|
<AlertDescription>{erfolg}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{passkeys.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Noch kein Passkey eingerichtet. Du meldest dich weiterhin mit E-Mail, Passwort und Code an
|
||||||
|
— das bleibt auch danach möglich.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{passkeys.map((passkey) => (
|
||||||
|
<li key={passkey.id} className="flex flex-wrap items-start gap-3 p-3">
|
||||||
|
<Smartphone
|
||||||
|
className="mt-0.5 size-5 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">{passkey.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Eingerichtet am {formatDatum(passkey.erstelltAm)}
|
||||||
|
{passkey.zuletztBenutztAm
|
||||||
|
? ` · zuletzt benutzt ${formatDatumZeit(passkey.zuletztBenutztAm)}`
|
||||||
|
: ' · noch nicht benutzt'}
|
||||||
|
</p>
|
||||||
|
{passkey.geraeteArt === 'singleDevice' && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Liegt nur auf diesem Gerät. Geht es verloren, ist auch der Passkey weg.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passkey.gesichert && (
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
gesichert
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nachfrage === passkey.id ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void entfernen(passkey.id)}
|
||||||
|
disabled={entfernt === passkey.id}
|
||||||
|
>
|
||||||
|
{entfernt === passkey.id ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
Wirklich entfernen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setNachfrage(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => setNachfrage(passkey.id)}
|
||||||
|
aria-label={`Passkey „${passkey.name}“ entfernen`}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" />
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{browserKann ? (
|
||||||
|
<div className="space-y-3 rounded-md border border-dashed p-3">
|
||||||
|
<Feld
|
||||||
|
id="passkey-name"
|
||||||
|
beschriftung="Neues Gerät"
|
||||||
|
erklaerung="Ein Name, an dem du es später wiedererkennst — etwa „iPhone“ oder „Laptop Büro“."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="passkey-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(ereignis) => setName(ereignis.target.value)}
|
||||||
|
maxLength={60}
|
||||||
|
placeholder="iPhone"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Button type="button" onClick={() => void einrichten()} disabled={laeuft}>
|
||||||
|
{laeuft ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
|
Warte auf das Gerät …
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
Passkey einrichten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<Fingerprint aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
Dieser Browser kann keine Passkeys. Auf einem anderen Gerät klappt es vermutlich — die
|
||||||
|
bereits eingerichteten bleiben davon unberührt.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { AlertCircle, Fingerprint, Loader2 } from 'lucide-react'
|
||||||
|
import { passkeyAnmeldungAbschliessen, passkeyAnmeldungStarten } from '@/server/actions/passkey'
|
||||||
|
import {
|
||||||
|
browserSupportsWebAuthn,
|
||||||
|
passkeyBrowserfehler,
|
||||||
|
startAuthentication,
|
||||||
|
} from '@/lib/passkey-browser'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knopf "Mit Passkey anmelden" auf der Anmeldeseite.
|
||||||
|
*
|
||||||
|
* Der Ablauf ist dreiteilig: Optionen vom Server holen, das Geraet fragen,
|
||||||
|
* die Antwort zurueckschicken. Gelingt der letzte Schritt, leitet die Server
|
||||||
|
* Action weiter - dieser Baustein sieht das Ergebnis dann gar nicht mehr.
|
||||||
|
*
|
||||||
|
* Sichtbar ist der Knopf nur, wenn der Server ihn anbietet (https) UND der
|
||||||
|
* Browser WebAuthn kann. Die zweite Pruefung laeuft erst nach dem ersten
|
||||||
|
* Rendern: Auf dem Server gibt es kein navigator-Objekt, und ein Knopf, der
|
||||||
|
* beim Laden verschwindet, ist unangenehmer als einer, der kurz spaeter
|
||||||
|
* erscheint.
|
||||||
|
*/
|
||||||
|
export function PasskeyAnmeldung({ weiter }: { weiter: string }) {
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
const [browserKann, setBrowserKann] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBrowserKann(browserSupportsWebAuthn())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function anmelden() {
|
||||||
|
setFehler(null)
|
||||||
|
setLaeuft(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = await passkeyAnmeldungStarten()
|
||||||
|
if (!start.ok) {
|
||||||
|
setFehler(start.fehler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = await startAuthentication({
|
||||||
|
optionsJSON: start.optionen,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ergebnis = await passkeyAnmeldungAbschliessen(JSON.stringify(antwort), weiter)
|
||||||
|
// Bei Erfolg wird gar nichts zurueckgegeben - die Action leitet weiter.
|
||||||
|
if (!ergebnis.ok) setFehler(ergebnis.fehler)
|
||||||
|
} catch (ausnahme) {
|
||||||
|
setFehler(passkeyBrowserfehler(ausnahme))
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!browserKann) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">oder</span>
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>{fehler}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={anmelden}
|
||||||
|
disabled={laeuft}
|
||||||
|
>
|
||||||
|
{laeuft ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
|
Warte auf das Gerät …
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Fingerprint aria-hidden="true" />
|
||||||
|
Mit Passkey anmelden
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail und Passwort.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
} from '@simplewebauthn/server'
|
||||||
|
import type {
|
||||||
|
AuthenticationResponseJSON,
|
||||||
|
AuthenticatorTransportFuture,
|
||||||
|
PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
RegistrationResponseJSON,
|
||||||
|
} from '@simplewebauthn/server'
|
||||||
|
import type { Passkey, PasskeyAnfrageArt, User } from '@prisma/client'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { env } from '@/lib/env'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Warum ein Passkey hier den zweiten Faktor ERSETZEN darf
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* Die Zusage dieser Anwendung lautet: zwei Faktoren, nicht abschaltbar. Ein
|
||||||
|
* Passkey sieht auf den ersten Blick nach einem einzigen Klick aus - er ist
|
||||||
|
* aber zwei Faktoren, sofern der Authenticator die Nutzerverifikation
|
||||||
|
* durchfuehrt:
|
||||||
|
*
|
||||||
|
* 1. Besitz - der private Schluessel liegt im Sicherheitsbaustein des
|
||||||
|
* Geraets und laesst sich nicht auslesen.
|
||||||
|
* 2. Wissen - Geraete-PIN
|
||||||
|
* oder Sein - Fingerabdruck, Gesicht.
|
||||||
|
*
|
||||||
|
* Diese Bedingung wird nicht gehofft, sondern erzwungen: bei der Anmeldung
|
||||||
|
* ebenso wie beim Einrichten steht userVerification auf "required", und die
|
||||||
|
* Pruefung laeuft mit requireUserVerification. Ein Authenticator, der nur
|
||||||
|
* Anwesenheit meldet (blosses Antippen), wird abgewiesen.
|
||||||
|
*
|
||||||
|
* Zusaetzlich ist ein Passkey dem Zahlenkomma aus Passwort + TOTP in einem
|
||||||
|
* Punkt ueberlegen: Er ist an die Domain gebunden. Eine nachgebaute
|
||||||
|
* Anmeldeseite bekommt keine Signatur - Phishing, der haeufigste Weg an
|
||||||
|
* einen TOTP-Code zu kommen, laeuft ins Leere.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Warum TOTP trotzdem zuerst eingerichtet sein muss
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* Ein Passkey haengt am Geraet. Geht das Handy verloren und ist es der
|
||||||
|
* einzige Zugangsweg, ist die Akte zu - und in ihr stehen Unterlagen, die
|
||||||
|
* eine betreute Person braucht. Deshalb laesst sich ein Passkey erst
|
||||||
|
* anlegen, wenn der zweite Faktor samt Wiederherstellungscodes steht. Der
|
||||||
|
* Passkey ist die bequeme Tuer, nicht die einzige.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie lange eine begonnene Passkey-Aktion abgeschlossen werden kann.
|
||||||
|
*
|
||||||
|
* Fuenf Minuten sind reichlich fuer "Handy suchen, Finger auflegen" und
|
||||||
|
* kurz genug, dass eine abgefangene Challenge nichts mehr wert ist.
|
||||||
|
*/
|
||||||
|
export const ANFRAGE_GUELTIG_SEKUNDEN = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obergrenze je Benutzer. Nicht aus technischen Gruenden - die Liste soll
|
||||||
|
* uebersichtlich bleiben, damit ein fremder Eintrag auffaellt.
|
||||||
|
*/
|
||||||
|
export const MAX_PASSKEYS_JE_BENUTZER = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obergrenze fuer gleichzeitig offene Anmeldeversuche.
|
||||||
|
*
|
||||||
|
* Eine Anmeldung darf jeder beginnen, auch ohne angemeldet zu sein - anders
|
||||||
|
* ginge es nicht. Damit daraus kein Weg wird, die Tabelle vollzuschreiben,
|
||||||
|
* gilt eine Decke. Fuer eine Handvoll Benutzer ist sie unerreichbar; fuer
|
||||||
|
* ein Skript ist sie schnell erreicht, und dann ist Schluss.
|
||||||
|
*/
|
||||||
|
export const MAX_OFFENE_ANMELDUNGEN = 200
|
||||||
|
|
||||||
|
export type PasskeyGrund =
|
||||||
|
| 'nicht-verfuegbar'
|
||||||
|
| 'anfrage-abgelaufen'
|
||||||
|
| 'unbekannt'
|
||||||
|
| 'nicht-verifiziert'
|
||||||
|
| 'geklont'
|
||||||
|
| 'bereits-vorhanden'
|
||||||
|
| 'zu-viele'
|
||||||
|
| 'zweiter-faktor-fehlt'
|
||||||
|
| 'zu-viele-anfragen'
|
||||||
|
| 'ungueltig'
|
||||||
|
|
||||||
|
/** Traegt einen Grund, damit die Oberflaeche einen brauchbaren Satz daraus macht. */
|
||||||
|
export class PasskeyFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly grund: PasskeyGrund,
|
||||||
|
nachricht?: string,
|
||||||
|
) {
|
||||||
|
super(nachricht ?? grund)
|
||||||
|
this.name = 'PasskeyFehler'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PASSKEY_MELDUNG: Record<PasskeyGrund, string> = {
|
||||||
|
'nicht-verfuegbar':
|
||||||
|
'Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation läuft nicht über https — ' +
|
||||||
|
'melde dich mit Passwort und Code an.',
|
||||||
|
'anfrage-abgelaufen':
|
||||||
|
'Das hat zu lange gedauert. Bitte klicke noch einmal auf „Mit Passkey anmelden“.',
|
||||||
|
unbekannt:
|
||||||
|
'Dieser Passkey ist hier nicht hinterlegt. Vielleicht gehört er zu einer anderen Adresse ' +
|
||||||
|
'oder wurde inzwischen entfernt.',
|
||||||
|
'nicht-verifiziert':
|
||||||
|
'Dein Gerät hat den Passkey ohne Fingerabdruck, Gesichtserkennung oder PIN freigegeben. ' +
|
||||||
|
'Das wäre nur ein Faktor — und hier liegen Gesundheitsdaten. Bitte richte die Gerätesperre ein.',
|
||||||
|
geklont:
|
||||||
|
'Der Zähler dieses Passkeys ist nicht weitergelaufen. Das kann auf eine Kopie hindeuten. ' +
|
||||||
|
'Der Passkey wurde nicht akzeptiert — bitte entferne ihn und richte einen neuen ein.',
|
||||||
|
'bereits-vorhanden': 'Dieser Passkey ist bereits hinterlegt.',
|
||||||
|
'zu-viele': `Es sind höchstens ${MAX_PASSKEYS_JE_BENUTZER} Passkeys möglich. Entferne zuerst einen alten.`,
|
||||||
|
'zweiter-faktor-fehlt':
|
||||||
|
'Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ohne einen ' +
|
||||||
|
'zweiten Weg wäre der Zugang bei Verlust zu.',
|
||||||
|
'zu-viele-anfragen':
|
||||||
|
'Gerade laufen ungewöhnlich viele Anmeldeversuche. Bitte versuche es in ein paar Minuten ' +
|
||||||
|
'noch einmal oder melde dich mit Passwort und Code an.',
|
||||||
|
ungueltig: 'Der Passkey konnte nicht geprüft werden. Bitte versuche es noch einmal.',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopiert Bytes in ein Uint8Array mit eigenem, nicht geteiltem Puffer.
|
||||||
|
*
|
||||||
|
* Prisma liefert einen Buffer, TextEncoder ein Uint8Array<ArrayBufferLike> -
|
||||||
|
* die Bibliothek verlangt ausdruecklich Uint8Array<ArrayBuffer>. Der
|
||||||
|
* Unterschied ist nicht kosmetisch: ArrayBufferLike schliesst
|
||||||
|
* SharedArrayBuffer ein, und auf geteiltem Speicher darf keine
|
||||||
|
* Signaturpruefung arbeiten, weil sich der Inhalt waehrenddessen aendern
|
||||||
|
* koennte.
|
||||||
|
*/
|
||||||
|
function alsUint8(daten: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
const kopie = new Uint8Array(new ArrayBuffer(daten.byteLength))
|
||||||
|
kopie.set(daten)
|
||||||
|
return kopie
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt die Challenge aus der Antwort des Browsers.
|
||||||
|
*
|
||||||
|
* Sie steckt in den clientData, die der Authenticator mitsigniert hat. Wir
|
||||||
|
* lesen sie hier nur, um die passende Zeile zu finden - geprueft wird sie
|
||||||
|
* anschliessend von der Bibliothek gegen genau diesen Wert.
|
||||||
|
*
|
||||||
|
* Exportiert, damit sie sich ohne Datenbank pruefen laesst.
|
||||||
|
*/
|
||||||
|
export function challengeAusAntwort(clientDataJSON: string): string {
|
||||||
|
try {
|
||||||
|
const roh = Buffer.from(clientDataJSON, 'base64url').toString('utf8')
|
||||||
|
const gelesen: unknown = JSON.parse(roh)
|
||||||
|
const challenge =
|
||||||
|
typeof gelesen === 'object' && gelesen !== null && 'challenge' in gelesen
|
||||||
|
? (gelesen as { challenge: unknown }).challenge
|
||||||
|
: null
|
||||||
|
if (typeof challenge !== 'string' || challenge.length === 0) {
|
||||||
|
throw new PasskeyFehler('ungueltig')
|
||||||
|
}
|
||||||
|
return challenge
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof PasskeyFehler) throw fehler
|
||||||
|
throw new PasskeyFehler('ungueltig')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt eine Challenge ab. Nur der Server kennt sie, bis der Browser antwortet. */
|
||||||
|
async function anfrageMerken(
|
||||||
|
challenge: string,
|
||||||
|
art: PasskeyAnfrageArt,
|
||||||
|
userId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await db.passkeyAnfrage.create({
|
||||||
|
data: {
|
||||||
|
challenge,
|
||||||
|
art,
|
||||||
|
userId,
|
||||||
|
ablaufAm: new Date(Date.now() + ANFRAGE_GUELTIG_SEKUNDEN * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbraucht eine Challenge - und zwar genau einmal.
|
||||||
|
*
|
||||||
|
* Das DELETE ist der eigentliche Schutz gegen das erneute Einspielen einer
|
||||||
|
* mitgeschnittenen Antwort: Die Datenbank entscheidet, wer die Zeile
|
||||||
|
* bekommt. Wurde keine Zeile geloescht, war sie schon verbraucht oder
|
||||||
|
* abgelaufen - beides fuehrt zum Abbruch.
|
||||||
|
*/
|
||||||
|
async function anfrageVerbrauchen(
|
||||||
|
challenge: string,
|
||||||
|
art: PasskeyAnfrageArt,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { count } = await db.passkeyAnfrage.deleteMany({
|
||||||
|
where: {
|
||||||
|
challenge,
|
||||||
|
art,
|
||||||
|
ablaufAm: { gt: new Date() },
|
||||||
|
...(userId ? { userId } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (count !== 1) throw new PasskeyFehler('anfrage-abgelaufen')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wirft, wenn die Installation gar nicht ueber https erreichbar ist. */
|
||||||
|
function verfuegbarkeitPruefen(): void {
|
||||||
|
if (!env.passkey.sicherAufrufbar) throw new PasskeyFehler('nicht-verfuegbar')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob Passkeys unter der aktuellen Adresse ueberhaupt angeboten werden koennen. */
|
||||||
|
export function passkeysMoeglich(): boolean {
|
||||||
|
return env.passkey.sicherAufrufbar
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Einrichten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function registrierungVorbereiten(
|
||||||
|
benutzer: User,
|
||||||
|
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||||
|
verfuegbarkeitPruefen()
|
||||||
|
|
||||||
|
// Ohne Authenticator-App kein Passkey - siehe Kopfkommentar.
|
||||||
|
if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt')
|
||||||
|
|
||||||
|
const vorhandene = await db.passkey.findMany({
|
||||||
|
where: { userId: benutzer.id },
|
||||||
|
select: { credentialId: true, transports: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (vorhandene.length >= MAX_PASSKEYS_JE_BENUTZER) throw new PasskeyFehler('zu-viele')
|
||||||
|
|
||||||
|
const optionen = await generateRegistrationOptions({
|
||||||
|
rpName: env.passkey.rpName,
|
||||||
|
rpID: env.passkey.rpId,
|
||||||
|
// Stabil aus der Benutzer-ID. Damit erkennt ein Geraet beim zweiten
|
||||||
|
// Einrichten, dass es derselbe Zugang ist, und ersetzt den Eintrag,
|
||||||
|
// statt einen zweiten anzulegen.
|
||||||
|
userID: alsUint8(new TextEncoder().encode(benutzer.id)),
|
||||||
|
userName: benutzer.email,
|
||||||
|
userDisplayName: benutzer.name,
|
||||||
|
// Keine Attestierung anfordern: Wir wollen nicht wissen, welches
|
||||||
|
// Fabrikat der Benutzer benutzt. Das ist ein Datum, das uns nichts
|
||||||
|
// angeht, und es macht den Ablauf auf manchen Geraeten umstaendlicher.
|
||||||
|
attestationType: 'none',
|
||||||
|
// Schon hinterlegte Schluessel ausschliessen, sonst legt dasselbe
|
||||||
|
// Geraet stillschweigend einen zweiten an.
|
||||||
|
excludeCredentials: vorhandene.map((eintrag) => ({
|
||||||
|
id: eintrag.credentialId,
|
||||||
|
transports: eintrag.transports as AuthenticatorTransportFuture[],
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
// "required": der Schluessel wird auf dem Geraet auffindbar
|
||||||
|
// gespeichert. Nur dann geht die Anmeldung ohne vorherige Eingabe
|
||||||
|
// der E-Mail-Adresse.
|
||||||
|
residentKey: 'required',
|
||||||
|
requireResidentKey: true,
|
||||||
|
// Der Punkt, an dem aus einem Faktor zwei werden.
|
||||||
|
userVerification: 'required',
|
||||||
|
},
|
||||||
|
timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await anfrageMerken(optionen.challenge, 'REGISTRIERUNG', benutzer.id)
|
||||||
|
return optionen
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registrierungPruefen(
|
||||||
|
benutzer: User,
|
||||||
|
name: string,
|
||||||
|
antwort: RegistrationResponseJSON,
|
||||||
|
): Promise<Passkey> {
|
||||||
|
verfuegbarkeitPruefen()
|
||||||
|
if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt')
|
||||||
|
|
||||||
|
const challenge = challengeAusAntwort(antwort.response.clientDataJSON)
|
||||||
|
await anfrageVerbrauchen(challenge, 'REGISTRIERUNG', benutzer.id)
|
||||||
|
|
||||||
|
let ergebnis
|
||||||
|
try {
|
||||||
|
ergebnis = await verifyRegistrationResponse({
|
||||||
|
response: antwort,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: env.passkey.herkunft,
|
||||||
|
expectedRPID: env.passkey.rpId,
|
||||||
|
requireUserVerification: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new PasskeyFehler('ungueltig')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ergebnis.verified) throw new PasskeyFehler('ungueltig')
|
||||||
|
|
||||||
|
const { credential, credentialDeviceType, credentialBackedUp, userVerified } =
|
||||||
|
ergebnis.registrationInfo
|
||||||
|
|
||||||
|
// Doppelte Absicherung: die Bibliothek prueft es bereits, aber diese
|
||||||
|
// Zusage ist zu wichtig, um sie an einer Voreinstellung haengen zu lassen.
|
||||||
|
if (!userVerified) throw new PasskeyFehler('nicht-verifiziert')
|
||||||
|
|
||||||
|
const schonDa = await db.passkey.findUnique({
|
||||||
|
where: { credentialId: credential.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (schonDa) throw new PasskeyFehler('bereits-vorhanden')
|
||||||
|
|
||||||
|
return db.passkey.create({
|
||||||
|
data: {
|
||||||
|
userId: benutzer.id,
|
||||||
|
credentialId: credential.id,
|
||||||
|
publicKey: Buffer.from(credential.publicKey),
|
||||||
|
counter: BigInt(credential.counter),
|
||||||
|
transports: credential.transports ?? [],
|
||||||
|
name,
|
||||||
|
geraeteArt: credentialDeviceType,
|
||||||
|
gesichert: credentialBackedUp,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Anmelden
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function anmeldungVorbereiten(): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||||
|
verfuegbarkeitPruefen()
|
||||||
|
|
||||||
|
// Erst aufraeumen, dann die Decke pruefen: Abgelaufenes soll nicht als
|
||||||
|
// laufender Versuch zaehlen.
|
||||||
|
await abgelaufeneAnfragenEntfernen()
|
||||||
|
const offen = await db.passkeyAnfrage.count({ where: { art: 'ANMELDUNG' } })
|
||||||
|
if (offen >= MAX_OFFENE_ANMELDUNGEN) throw new PasskeyFehler('zu-viele-anfragen')
|
||||||
|
|
||||||
|
const optionen = await generateAuthenticationOptions({
|
||||||
|
rpID: env.passkey.rpId,
|
||||||
|
// Bewusst ohne allowCredentials: Der Server soll vor der Anmeldung
|
||||||
|
// nicht verraten, welche Schluessel es zu einer Adresse gibt - und der
|
||||||
|
// Benutzer soll seine E-Mail gar nicht erst eintippen muessen. Das
|
||||||
|
// Geraet weiss selbst, welchen Schluessel es fuer diese Domain hat.
|
||||||
|
userVerification: 'required',
|
||||||
|
timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await anfrageMerken(optionen.challenge, 'ANMELDUNG', null)
|
||||||
|
return optionen
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PasskeyAnmeldung = {
|
||||||
|
benutzer: User
|
||||||
|
passkey: Passkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft eine Anmeldeantwort vollstaendig und meldet, wem sie gehoert.
|
||||||
|
*
|
||||||
|
* Alles, was hier durchgeht, ist kryptografisch belegt: die Signatur passt
|
||||||
|
* zum hinterlegten oeffentlichen Schluessel, die Challenge war frisch und
|
||||||
|
* genau einmal gueltig, die Domain stimmt, und das Geraet hat den Benutzer
|
||||||
|
* verifiziert.
|
||||||
|
*
|
||||||
|
* Rate-Limiting und Audit-Log passieren NICHT hier, sondern beim Aufrufer -
|
||||||
|
* dort ist bekannt, ob es eine Anmeldung oder eine erneute Bestaetigung ist.
|
||||||
|
*/
|
||||||
|
export async function anmeldungPruefen(
|
||||||
|
antwort: AuthenticationResponseJSON,
|
||||||
|
): Promise<PasskeyAnmeldung> {
|
||||||
|
verfuegbarkeitPruefen()
|
||||||
|
|
||||||
|
const challenge = challengeAusAntwort(antwort.response.clientDataJSON)
|
||||||
|
await anfrageVerbrauchen(challenge, 'ANMELDUNG')
|
||||||
|
|
||||||
|
const passkey = await db.passkey.findUnique({
|
||||||
|
where: { credentialId: antwort.id },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!passkey) throw new PasskeyFehler('unbekannt')
|
||||||
|
|
||||||
|
let ergebnis
|
||||||
|
try {
|
||||||
|
ergebnis = await verifyAuthenticationResponse({
|
||||||
|
response: antwort,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: env.passkey.herkunft,
|
||||||
|
expectedRPID: env.passkey.rpId,
|
||||||
|
requireUserVerification: true,
|
||||||
|
credential: {
|
||||||
|
id: passkey.credentialId,
|
||||||
|
publicKey: alsUint8(passkey.publicKey),
|
||||||
|
counter: Number(passkey.counter),
|
||||||
|
transports: passkey.transports as AuthenticatorTransportFuture[],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (fehler) {
|
||||||
|
// Die Bibliothek meldet einen nicht weitergelaufenen Zaehler als
|
||||||
|
// gewoehnlichen Fehler. Der verdient eine eigene Meldung: er ist der
|
||||||
|
// einzige Hinweis auf einen kopierten Schluessel, den wir bekommen.
|
||||||
|
const text = fehler instanceof Error ? fehler.message : ''
|
||||||
|
if (text.includes('counter value')) throw new PasskeyFehler('geklont')
|
||||||
|
throw new PasskeyFehler('ungueltig')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ergebnis.verified) throw new PasskeyFehler('ungueltig')
|
||||||
|
if (!ergebnis.authenticationInfo.userVerified) throw new PasskeyFehler('nicht-verifiziert')
|
||||||
|
|
||||||
|
await db.passkey.update({
|
||||||
|
where: { id: passkey.id },
|
||||||
|
data: {
|
||||||
|
counter: BigInt(ergebnis.authenticationInfo.newCounter),
|
||||||
|
zuletztBenutztAm: new Date(),
|
||||||
|
// Beides kann sich nachtraeglich aendern, etwa wenn der Benutzer die
|
||||||
|
// Schluesselbund-Synchronisierung einschaltet.
|
||||||
|
geraeteArt: ergebnis.authenticationInfo.credentialDeviceType,
|
||||||
|
gesichert: ergebnis.authenticationInfo.credentialBackedUp,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { benutzer: passkey.user, passkey }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verwalten und Aufraeumen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type PasskeyUebersicht = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
geraeteArt: string | null
|
||||||
|
gesichert: boolean
|
||||||
|
erstelltAm: Date
|
||||||
|
zuletztBenutztAm: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function passkeysDesBenutzers(userId: string): Promise<PasskeyUebersicht[]> {
|
||||||
|
return db.passkey.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ zuletztBenutztAm: { sort: 'desc', nulls: 'last' } }, { erstelltAm: 'desc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
geraeteArt: true,
|
||||||
|
gesichert: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutztAm: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raeumt liegengebliebene Challenges weg; wird vom Wartungslauf aufgerufen. */
|
||||||
|
export async function abgelaufeneAnfragenEntfernen(): Promise<number> {
|
||||||
|
const { count } = await db.passkeyAnfrage.deleteMany({
|
||||||
|
where: { ablaufAm: { lt: new Date() } },
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -185,6 +185,35 @@ export const env = {
|
|||||||
return (process.env.AUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
|
return (process.env.AUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Herkunft und Bereichskennung fuer Passkeys (WebAuthn).
|
||||||
|
*
|
||||||
|
* Beides wird aus AUTH_URL abgeleitet und nicht getrennt konfiguriert -
|
||||||
|
* eine zweite Quelle waere eine zweite Gelegenheit, sie auseinanderlaufen
|
||||||
|
* zu lassen.
|
||||||
|
*
|
||||||
|
* Die "rpId" ist der nackte Hostname ohne Schema und ohne Port. An sie
|
||||||
|
* bindet der Authenticator den Schluessel: Ein Passkey fuer
|
||||||
|
* cockpit.example.de funktioniert auf keiner anderen Adresse - genau das
|
||||||
|
* macht ihn gegen Phishing immun. Umgekehrt heisst das: Wer die Domain
|
||||||
|
* wechselt, muss alle Passkeys neu einrichten.
|
||||||
|
*
|
||||||
|
* Der Browser gibt WebAuthn ausserdem nur in einem sicheren Kontext frei -
|
||||||
|
* also unter https oder auf localhost. Hinter einem Proxy, der auf http
|
||||||
|
* weiterreicht, muss AUTH_URL trotzdem die oeffentliche https-Adresse
|
||||||
|
* nennen; verglichen wird gegen das, was im Browser in der Adresszeile
|
||||||
|
* stand.
|
||||||
|
*/
|
||||||
|
get passkey() {
|
||||||
|
const url = new URL(process.env.AUTH_URL ?? 'http://localhost:3000')
|
||||||
|
return {
|
||||||
|
rpId: url.hostname,
|
||||||
|
rpName: 'Betreuungs-Cockpit',
|
||||||
|
herkunft: url.origin,
|
||||||
|
sicherAufrufbar: url.protocol === 'https:' || url.hostname === 'localhost',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
get istProduktion() {
|
get istProduktion() {
|
||||||
return process.env.NODE_ENV === 'production'
|
return process.env.NODE_ENV === 'production'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Browserseitige Hilfen fuer Passkeys.
|
||||||
|
*
|
||||||
|
* Bewusst ohne "server-only" und ohne Datenbankzugriff: Diese Datei laeuft
|
||||||
|
* im Browser. Die eigentliche Pruefung passiert auf dem Server
|
||||||
|
* (src/lib/auth/passkey.ts) - hier geht es nur darum, das Geraet
|
||||||
|
* anzusprechen und seine Fehler in verstaendliche Saetze zu uebersetzen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
browserSupportsWebAuthn,
|
||||||
|
startAuthentication,
|
||||||
|
startRegistration,
|
||||||
|
} from '@simplewebauthn/browser'
|
||||||
|
|
||||||
|
export { browserSupportsWebAuthn, startAuthentication, startRegistration }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebersetzt die Ausnahmen der WebAuthn-Schnittstelle.
|
||||||
|
*
|
||||||
|
* Der Browser wirft DOMExceptions mit sehr knappen Namen und einer
|
||||||
|
* englischen Beschreibung, die man niemandem zeigen kann. Vor allem
|
||||||
|
* "NotAllowedError" ist irrefuehrend: Er bedeutet meistens schlicht, dass
|
||||||
|
* der Vorgang abgebrochen wurde oder die Zeit abgelaufen ist - nicht, dass
|
||||||
|
* etwas verboten waere.
|
||||||
|
*/
|
||||||
|
export function passkeyBrowserfehler(fehler: unknown, beimEinrichten = false): string {
|
||||||
|
const name = fehler instanceof Error ? fehler.name : ''
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'NotAllowedError':
|
||||||
|
return 'Abgebrochen oder zu lange gewartet. Du kannst es gleich noch einmal versuchen.'
|
||||||
|
|
||||||
|
case 'InvalidStateError':
|
||||||
|
return beimEinrichten
|
||||||
|
? 'Für dieses Gerät ist hier bereits ein Passkey hinterlegt. Ein zweiter bringt nichts — ' +
|
||||||
|
'nimm ein anderes Gerät oder entferne zuerst den vorhandenen.'
|
||||||
|
: 'Dieses Gerät kann den Passkey gerade nicht verwenden.'
|
||||||
|
|
||||||
|
case 'SecurityError':
|
||||||
|
return (
|
||||||
|
'Die Adresse im Browser passt nicht zu der, für die der Passkey ausgestellt wurde. ' +
|
||||||
|
'Das ist genau der Schutz, der Phishing verhindert — prüfe die Adresszeile.'
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'NotSupportedError':
|
||||||
|
return 'Dieses Gerät unterstützt keine Passkeys mit Fingerabdruck, Gesicht oder PIN.'
|
||||||
|
|
||||||
|
case 'AbortError':
|
||||||
|
return 'Abgebrochen.'
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'Der Passkey konnte nicht verwendet werden. Bitte versuche es noch einmal.'
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Muss zur "version" in der package.json passen. */
|
/** Muss zur "version" in der package.json passen. */
|
||||||
export const VERSION = '1.0.5'
|
export const VERSION = '1.1.0'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, papierkorbGrenze } from '@/lib/db'
|
|||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
import { fristErinnerung, mailSenden } from '@/lib/mail'
|
import { fristErinnerung, mailSenden } from '@/lib/mail'
|
||||||
import { alteVersucheEntfernen } from '@/lib/auth/ratelimit'
|
import { alteVersucheEntfernen } from '@/lib/auth/ratelimit'
|
||||||
|
import { abgelaufeneAnfragenEntfernen } from '@/lib/auth/passkey'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Taeglicher Wartungslauf.
|
* Taeglicher Wartungslauf.
|
||||||
@@ -14,6 +15,7 @@ import { alteVersucheEntfernen } from '@/lib/auth/ratelimit'
|
|||||||
* 2. Papierkorb leeren (30 Tage)
|
* 2. Papierkorb leeren (30 Tage)
|
||||||
* 3. Alte Anmeldeversuche entfernen (30 Tage)
|
* 3. Alte Anmeldeversuche entfernen (30 Tage)
|
||||||
* 4. Altes Audit-Log entfernen (10 Jahre, ueber die DB-Funktion)
|
* 4. Altes Audit-Log entfernen (10 Jahre, ueber die DB-Funktion)
|
||||||
|
* 5. Liegengebliebene Passkey-Challenges entfernen (5 Minuten)
|
||||||
*
|
*
|
||||||
* Jeder Schritt faengt seine Fehler selbst ab. Faellt die Mail aus, soll
|
* Jeder Schritt faengt seine Fehler selbst ab. Faellt die Mail aus, soll
|
||||||
* trotzdem aufgeraeumt werden - sonst laeuft die Datenbank voll, weil ein
|
* trotzdem aufgeraeumt werden - sonst laeuft die Datenbank voll, weil ein
|
||||||
@@ -25,6 +27,7 @@ export type Wartungsergebnis = {
|
|||||||
papierkorbGeleert: number
|
papierkorbGeleert: number
|
||||||
loginversucheEntfernt: number
|
loginversucheEntfernt: number
|
||||||
auditEintraegeEntfernt: number
|
auditEintraegeEntfernt: number
|
||||||
|
passkeyAnfragenEntfernt: number
|
||||||
fehler: string[]
|
fehler: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ export async function wartungslauf(): Promise<Wartungsergebnis> {
|
|||||||
papierkorbGeleert: 0,
|
papierkorbGeleert: 0,
|
||||||
loginversucheEntfernt: 0,
|
loginversucheEntfernt: 0,
|
||||||
auditEintraegeEntfernt: 0,
|
auditEintraegeEntfernt: 0,
|
||||||
|
passkeyAnfragenEntfernt: 0,
|
||||||
fehler: [],
|
fehler: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +97,15 @@ export async function wartungslauf(): Promise<Wartungsergebnis> {
|
|||||||
ergebnis.fehler.push(`Audit-Log: ${beschreiben(fehler)}`)
|
ergebnis.fehler.push(`Audit-Log: ${beschreiben(fehler)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 5. Passkey-Challenges ----------------------------------------------
|
||||||
|
try {
|
||||||
|
// Normalerweise raeumt jede Anmeldung selbst auf. Bleibt der letzte
|
||||||
|
// Versuch des Tages liegen, faengt es dieser Lauf ab.
|
||||||
|
ergebnis.passkeyAnfragenEntfernt = await abgelaufeneAnfragenEntfernen()
|
||||||
|
} catch (fehler) {
|
||||||
|
ergebnis.fehler.push(`Passkey-Anfragen: ${beschreiben(fehler)}`)
|
||||||
|
}
|
||||||
|
|
||||||
return ergebnis
|
return ergebnis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { AuthError } from 'next-auth'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type {
|
||||||
|
AuthenticationResponseJSON,
|
||||||
|
PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
RegistrationResponseJSON,
|
||||||
|
} from '@simplewebauthn/server'
|
||||||
|
import { signIn } from '@/auth'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { protokollieren } from '@/lib/audit'
|
||||||
|
import {
|
||||||
|
PASSKEY_MELDUNG,
|
||||||
|
PasskeyFehler,
|
||||||
|
anmeldungPruefen,
|
||||||
|
anmeldungVorbereiten,
|
||||||
|
registrierungPruefen,
|
||||||
|
registrierungVorbereiten,
|
||||||
|
} from '@/lib/auth/passkey'
|
||||||
|
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Actions rund um Passkeys.
|
||||||
|
*
|
||||||
|
* Ein Passkey-Vorgang besteht immer aus zwei Aufrufen: erst holt der Browser
|
||||||
|
* die Optionen samt Challenge, dann schickt er die Antwort des Geraets
|
||||||
|
* zurueck. Dazwischen liegt der Griff zum Fingerabdrucksensor, und der
|
||||||
|
* dauert unbestimmt lange - deshalb zwei Schritte und keine Sitzungsvariable.
|
||||||
|
*
|
||||||
|
* Die Antworten des Authenticators kommen als JSON-Zeichenkette herein, nicht
|
||||||
|
* als Objekt. Grund: Was ueber die Grenze zum Server geht, wird hier
|
||||||
|
* ausdruecklich geparst und geprueft, statt sich auf die Serialisierung von
|
||||||
|
* React zu verlassen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ergebnis eines Schritts, der Optionen liefert. */
|
||||||
|
export type PasskeyStart<T> = { ok: true; optionen: T } | { ok: false; fehler: string }
|
||||||
|
|
||||||
|
/** Ergebnis eines abschliessenden Schritts. */
|
||||||
|
export type PasskeyErgebnis = { ok: true; meldung?: string } | { ok: false; fehler: string }
|
||||||
|
|
||||||
|
function alsFehler(fehler: unknown): { ok: false; fehler: string } {
|
||||||
|
if (fehler instanceof PasskeyFehler) {
|
||||||
|
return { ok: false, fehler: PASSKEY_MELDUNG[fehler.grund] }
|
||||||
|
}
|
||||||
|
console.error('[passkey] Unerwarteter Fehler:', fehler)
|
||||||
|
return { ok: false, fehler: PASSKEY_MELDUNG.ungueltig }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst die Antwort des Browsers.
|
||||||
|
*
|
||||||
|
* Absichtlich nur eine grobe Formpruefung: Was zaehlt, prueft anschliessend
|
||||||
|
* die Bibliothek kryptografisch. Hier geht es nur darum, dass kein
|
||||||
|
* offensichtlicher Unsinn bis dorthin durchlaeuft.
|
||||||
|
*/
|
||||||
|
const AntwortSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
rawId: z.string().min(1),
|
||||||
|
type: z.literal('public-key'),
|
||||||
|
response: z.object({ clientDataJSON: z.string().min(1) }).passthrough(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function antwortLesen<T>(roh: string): T {
|
||||||
|
const gelesen = AntwortSchema.safeParse(JSON.parse(roh))
|
||||||
|
if (!gelesen.success) throw new PasskeyFehler('ungueltig')
|
||||||
|
return gelesen.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Einrichten (angemeldet, unter "Mein Konto")
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function passkeyEinrichtungStarten(): Promise<
|
||||||
|
PasskeyStart<PublicKeyCredentialCreationOptionsJSON>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const benutzer = await erfordereBenutzer()
|
||||||
|
return { ok: true, optionen: await registrierungVorbereiten(benutzer) }
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsFehler(fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'Bitte gib dem Gerät einen Namen.')
|
||||||
|
.max(60, 'Bitte höchstens 60 Zeichen.')
|
||||||
|
|
||||||
|
export async function passkeyEinrichtungAbschliessen(
|
||||||
|
name: string,
|
||||||
|
antwortJson: string,
|
||||||
|
): Promise<PasskeyErgebnis> {
|
||||||
|
try {
|
||||||
|
const benutzer = await erfordereBenutzer()
|
||||||
|
|
||||||
|
const geprueft = NameSchema.safeParse(name)
|
||||||
|
if (!geprueft.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: geprueft.error.issues[0]?.message ?? 'Der Name passt nicht.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = antwortLesen<RegistrationResponseJSON>(antwortJson)
|
||||||
|
const passkey = await registrierungPruefen(benutzer, geprueft.data, antwort)
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'PASSKEY_REGISTRIERT',
|
||||||
|
entitaet: 'Passkey',
|
||||||
|
entitaetId: passkey.id,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
bezeichnung: passkey.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/konto')
|
||||||
|
return { ok: true, meldung: `„${passkey.name}“ ist eingerichtet.` }
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsFehler(fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verwalten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function passkeyEntfernen(id: string): Promise<PasskeyErgebnis> {
|
||||||
|
try {
|
||||||
|
const benutzer = await erfordereBenutzer()
|
||||||
|
|
||||||
|
// Zeilenweise Pruefung: Es genuegt nicht, dass die ID existiert - sie
|
||||||
|
// muss zu DIESEM Benutzer gehoeren. deleteMany mit userId im WHERE
|
||||||
|
// erledigt Pruefung und Loeschen in einem Zug; ein fremder Passkey
|
||||||
|
// trifft dann auf null Zeilen.
|
||||||
|
const passkey = await db.passkey.findFirst({
|
||||||
|
where: { id, userId: benutzer.id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
if (!passkey)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Dieser Passkey gehört nicht zu deinem Zugang.',
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.passkey.delete({ where: { id: passkey.id } })
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'PASSKEY_ENTFERNT',
|
||||||
|
entitaet: 'Passkey',
|
||||||
|
entitaetId: passkey.id,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
bezeichnung: passkey.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/konto')
|
||||||
|
return { ok: true, meldung: `„${passkey.name}“ wurde entfernt.` }
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsFehler(fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Anmelden (nicht angemeldet)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function passkeyAnmeldungStarten(): Promise<
|
||||||
|
PasskeyStart<PublicKeyCredentialRequestOptionsJSON>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
return { ok: true, optionen: await anmeldungVorbereiten() }
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsFehler(fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verhindert offene Weiterleitungen auf fremde Seiten. */
|
||||||
|
function sicheresZiel(ziel: string): string {
|
||||||
|
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
|
||||||
|
return ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft.
|
||||||
|
* Wie in anmelden.ts ueber das digest-Feld statt ueber einen Import aus
|
||||||
|
* next/dist - interne Pfade aendern sich zwischen Nebenversionen.
|
||||||
|
*/
|
||||||
|
function istWeiterleitung(fehler: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof fehler === 'object' &&
|
||||||
|
fehler !== null &&
|
||||||
|
'digest' in fehler &&
|
||||||
|
typeof (fehler as { digest?: unknown }).digest === 'string' &&
|
||||||
|
(fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebersetzt den Fehlercode aus authorize() in einen lesbaren Satz.
|
||||||
|
* "passkey:<grund>" kommt aus PasskeyAnmeldeFehler, alles andere aus dem
|
||||||
|
* gewoehnlichen Anmeldeweg.
|
||||||
|
*/
|
||||||
|
function fehlertext(code: string): string {
|
||||||
|
const [art, wert] = code.split(':')
|
||||||
|
|
||||||
|
if (art === 'passkey') {
|
||||||
|
const grund = wert as keyof typeof PASSKEY_MELDUNG
|
||||||
|
return PASSKEY_MELDUNG[grund] ?? PASSKEY_MELDUNG.ungueltig
|
||||||
|
}
|
||||||
|
if (art === 'gesperrt') {
|
||||||
|
const minuten = Math.ceil(Number.parseInt(wert ?? '0', 10) / 60)
|
||||||
|
return (
|
||||||
|
`Zu viele Fehlversuche. Aus Sicherheitsgründen ist die Anmeldung für ` +
|
||||||
|
`${minuten > 1 ? `etwa ${minuten} Minuten` : 'kurze Zeit'} gesperrt.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (art === 'konto-inaktiv') {
|
||||||
|
return 'Dieser Zugang ist deaktiviert. Bitte wende dich an den Administrator.'
|
||||||
|
}
|
||||||
|
return PASSKEY_MELDUNG.ungueltig
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function passkeyAnmeldungAbschliessen(
|
||||||
|
antwortJson: string,
|
||||||
|
weiter: string,
|
||||||
|
): Promise<PasskeyErgebnis> {
|
||||||
|
try {
|
||||||
|
// Nur die Form pruefen; signIn() macht die eigentliche Arbeit.
|
||||||
|
antwortLesen<AuthenticationResponseJSON>(antwortJson)
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsFehler(fehler)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn('passkey', {
|
||||||
|
antwort: antwortJson,
|
||||||
|
redirectTo: sicheresZiel(weiter),
|
||||||
|
})
|
||||||
|
} catch (fehler) {
|
||||||
|
// Bei Erfolg wirft signIn eine Weiterleitung - die muss durch.
|
||||||
|
if (istWeiterleitung(fehler)) throw fehler
|
||||||
|
|
||||||
|
if (fehler instanceof AuthError) {
|
||||||
|
const code =
|
||||||
|
'code' in fehler && typeof fehler.code === 'string' ? fehler.code : 'passkey:ungueltig'
|
||||||
|
return { ok: false, fehler: fehlertext(code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wird nicht erreicht - signIn leitet weiter.
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Erneut bestaetigen (angemeldet, vor sensiblen Aktionen)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function passkeyBestaetigungStarten(): Promise<
|
||||||
|
PasskeyStart<PublicKeyCredentialRequestOptionsJSON>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await erfordereBenutzer()
|
||||||
|
return { ok: true, optionen: await anmeldungVorbereiten() }
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsFehler(fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestaetigt die laufende Sitzung mit einem Passkey.
|
||||||
|
*
|
||||||
|
* Wichtig ist die letzte Pruefung: Der Passkey muss dem angemeldeten
|
||||||
|
* Benutzer gehoeren. Sonst koennte jemand mit seinem eigenen Passkey die
|
||||||
|
* fremde Sitzung freischalten, die auf einem unbeaufsichtigten Rechner
|
||||||
|
* offen steht - genau der Fall, gegen den die erneute Bestaetigung da ist.
|
||||||
|
*/
|
||||||
|
export async function passkeyBestaetigen(antwortJson: string): Promise<PasskeyErgebnis> {
|
||||||
|
try {
|
||||||
|
const benutzer = await erfordereBenutzer()
|
||||||
|
const antwort = antwortLesen<AuthenticationResponseJSON>(antwortJson)
|
||||||
|
|
||||||
|
const { benutzer: eigentuemer } = await anmeldungPruefen(antwort)
|
||||||
|
|
||||||
|
if (eigentuemer.id !== benutzer.id) {
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
||||||
|
entitaet: 'User',
|
||||||
|
entitaetId: benutzer.id,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
bezeichnung: 'Bestätigung mit fremdem Passkey',
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Dieser Passkey gehört zu einem anderen Zugang als dem gerade angemeldeten.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await reauthBestaetigen(benutzer.id)
|
||||||
|
return { ok: true }
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsFehler(fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
MAX_PASSKEYS_JE_BENUTZER,
|
||||||
|
PASSKEY_MELDUNG,
|
||||||
|
PasskeyFehler,
|
||||||
|
challengeAusAntwort,
|
||||||
|
passkeysMoeglich,
|
||||||
|
} from '@/lib/auth/passkey'
|
||||||
|
import { passkeyBrowserfehler } from '@/lib/passkey-browser'
|
||||||
|
import { env } from '@/lib/env'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkeys — die Teile, die sich ohne Datenbank und ohne Browser prüfen
|
||||||
|
* lassen.
|
||||||
|
*
|
||||||
|
* Der Schwerpunkt liegt auf der Herleitung von `rpId` und Herkunft aus
|
||||||
|
* `AUTH_URL`. Das ist bei WebAuthn die Stelle, an der eine Installation
|
||||||
|
* still kaputtgeht: Stimmt die Bereichskennung nicht, lässt sich zwar ein
|
||||||
|
* Passkey einrichten, aber nie wieder verwenden — und die Fehlermeldung des
|
||||||
|
* Browsers zeigt auf alles Mögliche, nur nicht auf die Konfiguration.
|
||||||
|
*
|
||||||
|
* Dieselbe Fehlerklasse hat uns bei `APP_BIND` und `ENC_KEY_V2` schon zwei
|
||||||
|
* Fassungen gekostet: eine Umgebungsvariable, die falsch ausgewertet wird,
|
||||||
|
* und ein Fehlerbild, das woandershin zeigt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let vorher: string | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vorher = process.env.AUTH_URL
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (vorher === undefined) delete process.env.AUTH_URL
|
||||||
|
else process.env.AUTH_URL = vorher
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Bereichskennung aus AUTH_URL', () => {
|
||||||
|
it('nimmt den nackten Hostnamen als rpId, ohne Schema und ohne Port', () => {
|
||||||
|
process.env.AUTH_URL = 'https://cockpit.example.de'
|
||||||
|
expect(env.passkey.rpId).toBe('cockpit.example.de')
|
||||||
|
expect(env.passkey.herkunft).toBe('https://cockpit.example.de')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt den Port in der Herkunft stehen, aber nicht in der rpId', () => {
|
||||||
|
// Die Herkunft wird zeichengenau mit dem verglichen, was im Browser in
|
||||||
|
// der Adresszeile stand - der Port gehoert dazu. Die rpId ist dagegen
|
||||||
|
// die Domain, an die der Schluessel gebunden wird; ein Port darf dort
|
||||||
|
// nicht auftauchen, sonst weist der Authenticator sie zurueck.
|
||||||
|
process.env.AUTH_URL = 'https://cockpit.example.de:8443'
|
||||||
|
expect(env.passkey.rpId).toBe('cockpit.example.de')
|
||||||
|
expect(env.passkey.herkunft).toBe('https://cockpit.example.de:8443')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verträgt einen Schrägstrich am Ende', () => {
|
||||||
|
process.env.AUTH_URL = 'https://cockpit.example.de/'
|
||||||
|
expect(env.passkey.herkunft).toBe('https://cockpit.example.de')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt eine Unterdomain als eigene Kennung', () => {
|
||||||
|
// Ein Passkey fuer betreuung.example.de gilt NICHT fuer example.de -
|
||||||
|
// genau das ist der Phishingschutz.
|
||||||
|
process.env.AUTH_URL = 'https://betreuung.example.de'
|
||||||
|
expect(env.passkey.rpId).toBe('betreuung.example.de')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Verfügbarkeit', () => {
|
||||||
|
it('bietet Passkeys über https an', () => {
|
||||||
|
process.env.AUTH_URL = 'https://cockpit.example.de'
|
||||||
|
expect(env.passkey.sicherAufrufbar).toBe(true)
|
||||||
|
expect(passkeysMoeglich()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bietet sie auf localhost an, auch ohne https', () => {
|
||||||
|
// Der Browser wertet localhost als sicheren Kontext - sonst liesse
|
||||||
|
// sich in der Entwicklung nichts ausprobieren.
|
||||||
|
process.env.AUTH_URL = 'http://localhost:3000'
|
||||||
|
expect(env.passkey.sicherAufrufbar).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bietet sie NICHT über einfaches http an', () => {
|
||||||
|
// Wichtig, dass das der Server entscheidet und nicht der Browser: Ein
|
||||||
|
// Knopf, der beim Klick eine kryptische DOMException wirft, ist
|
||||||
|
// schlechter als gar kein Knopf.
|
||||||
|
process.env.AUTH_URL = 'http://cockpit.example.de'
|
||||||
|
expect(env.passkey.sicherAufrufbar).toBe(false)
|
||||||
|
expect(passkeysMoeglich()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bietet sie auch über eine nackte IP ohne https nicht an', () => {
|
||||||
|
process.env.AUTH_URL = 'http://10.0.0.5:3000'
|
||||||
|
expect(env.passkey.sicherAufrufbar).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Challenge aus der Antwort des Browsers', () => {
|
||||||
|
function clientData(inhalt: unknown): string {
|
||||||
|
return Buffer.from(JSON.stringify(inhalt), 'utf8').toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
it('liest die Challenge aus den clientData', () => {
|
||||||
|
const daten = clientData({
|
||||||
|
type: 'webauthn.get',
|
||||||
|
challenge: 'AbCdEf-1234_xyz',
|
||||||
|
origin: 'https://cockpit.example.de',
|
||||||
|
})
|
||||||
|
expect(challengeAusAntwort(daten)).toBe('AbCdEf-1234_xyz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine Antwort ohne Challenge ab', () => {
|
||||||
|
const daten = clientData({ type: 'webauthn.get', origin: 'https://cockpit.example.de' })
|
||||||
|
expect(() => challengeAusAntwort(daten)).toThrowError(PasskeyFehler)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine leere Challenge ab', () => {
|
||||||
|
expect(() => challengeAusAntwort(clientData({ challenge: '' }))).toThrowError(PasskeyFehler)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine Challenge ab, die keine Zeichenkette ist', () => {
|
||||||
|
expect(() => challengeAusAntwort(clientData({ challenge: 42 }))).toThrowError(PasskeyFehler)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist Unsinn ab, statt daran abzustürzen', () => {
|
||||||
|
expect(() => challengeAusAntwort('das ist kein base64url-JSON')).toThrowError(PasskeyFehler)
|
||||||
|
expect(() => challengeAusAntwort('')).toThrowError(PasskeyFehler)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Meldungen', () => {
|
||||||
|
it('hat zu jedem Grund einen Satz, den man jemandem zeigen kann', () => {
|
||||||
|
// Ein fehlender Eintrag wuerde als "undefined" auf dem Bildschirm
|
||||||
|
// landen. Der Typ verhindert das beim Uebersetzen; hier wird geprueft,
|
||||||
|
// dass auch nichts Leeres durchrutscht.
|
||||||
|
for (const [grund, text] of Object.entries(PASSKEY_MELDUNG)) {
|
||||||
|
expect(text.length, `Meldung für "${grund}"`).toBeGreaterThan(20)
|
||||||
|
expect(text, `Meldung für "${grund}"`).not.toMatch(/undefined/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt in der Obergrenze die tatsächliche Zahl', () => {
|
||||||
|
expect(PASSKEY_MELDUNG['zu-viele']).toContain(String(MAX_PASSKEYS_JE_BENUTZER))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erklärt bei fehlender Nutzerverifikation, warum abgelehnt wird', () => {
|
||||||
|
// Das ist die Meldung, die am ehesten nach Schikane aussieht. Sie muss
|
||||||
|
// den Grund nennen, sonst haelt man sie fuer einen Fehler.
|
||||||
|
expect(PASSKEY_MELDUNG['nicht-verifiziert']).toMatch(/Fingerabdruck|PIN/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Fehler des Browsers', () => {
|
||||||
|
function dom(name: string): Error {
|
||||||
|
const fehler = new Error('irgendwas Englisches')
|
||||||
|
fehler.name = name
|
||||||
|
return fehler
|
||||||
|
}
|
||||||
|
|
||||||
|
it('deutet NotAllowedError als Abbruch, nicht als Verbot', () => {
|
||||||
|
// Der Name ist irrefuehrend: Er kommt auch, wenn schlicht niemand den
|
||||||
|
// Finger aufgelegt hat. "Nicht erlaubt" waere die falsche Auskunft.
|
||||||
|
const text = passkeyBrowserfehler(dom('NotAllowedError'))
|
||||||
|
expect(text).toMatch(/Abgebrochen/)
|
||||||
|
expect(text).not.toMatch(/verboten|nicht erlaubt/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erklärt beim Einrichten, dass dieses Gerät schon einen Passkey hat', () => {
|
||||||
|
expect(passkeyBrowserfehler(dom('InvalidStateError'), true)).toMatch(/bereits/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sagt bei SecurityError, dass die Adresse nicht passt', () => {
|
||||||
|
expect(passkeyBrowserfehler(dom('SecurityError'))).toMatch(/Adress/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fällt bei unbekannten Fehlern auf einen brauchbaren Satz zurück', () => {
|
||||||
|
expect(passkeyBrowserfehler(dom('VoellingNeuerFehler'))).toMatch(/noch einmal/)
|
||||||
|
expect(passkeyBrowserfehler('gar kein Fehlerobjekt')).toMatch(/noch einmal/)
|
||||||
|
expect(passkeyBrowserfehler(undefined)).toMatch(/noch einmal/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt nie die englische Originalmeldung weiter', () => {
|
||||||
|
for (const name of [
|
||||||
|
'NotAllowedError',
|
||||||
|
'InvalidStateError',
|
||||||
|
'SecurityError',
|
||||||
|
'NotSupportedError',
|
||||||
|
'AbortError',
|
||||||
|
'Unbekannt',
|
||||||
|
]) {
|
||||||
|
expect(passkeyBrowserfehler(dom(name))).not.toContain('irgendwas Englisches')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user