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:
Florian Wilde
2026-08-22 18:44:56 +02:00
parent 9603917a86
commit c38c988c3c
25 changed files with 2702 additions and 104 deletions
+51 -5
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand
git checkout v1.0.0
git checkout v1.1.0
```
### Kontrolle
```bash
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
> Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular,
> 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:
@@ -293,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
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:
@@ -643,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash
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
@@ -694,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- 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
```
### 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
```bash