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
+143 -55
View File
@@ -89,6 +89,7 @@ model User {
geloeschtAm DateTime?
zugaenge BetreuungsZugang[]
passkeys Passkey[]
auditEintraege AuditLog[]
angelegteVorgaenge Vorgang[] @relation("VorgangAngelegtVon")
berichte Bericht[]
@@ -135,6 +136,91 @@ model LoginVersuch {
@@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
// =============================================================================
@@ -191,8 +277,8 @@ model Betreute {
/// Seit der Reform 2023 Leitschnur des Betreuerhandelns.
ermittelterWilleVerschluesselt String?
patientenverfuegungVorhanden Boolean @default(false)
vorsorgevollmachtVorhanden Boolean @default(false)
patientenverfuegungVorhanden Boolean @default(false)
vorsorgevollmachtVorhanden Boolean @default(false)
// --- Betreuungsgericht ---------------------------------------------------
betreuungsgericht String?
@@ -315,8 +401,8 @@ model Vorgang {
kontaktprotokolle Kontaktprotokoll[]
aufgaben Aufgabe[]
verknuepfungen DokumentVerknuepfung[]
tags Tag[] @relation("VorgangTags")
kontakte Kontakt[] @relation("VorgangKontakte")
tags Tag[] @relation("VorgangTags")
kontakte Kontakt[] @relation("VorgangKontakte")
@@index([betreuteId, status])
@@index([betreuteId, kategorie])
@@ -754,21 +840,21 @@ model Buchung {
vorgangId String?
vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull)
datum DateTime @db.Date
wertstellung DateTime? @db.Date
betragCent BigInt
richtung BuchungRichtung
kategorie BuchungKategorie @default(SONSTIGE_AUSGABE)
gegenstelle String?
datum DateTime @db.Date
wertstellung DateTime? @db.Date
betragCent BigInt
richtung BuchungRichtung
kategorie BuchungKategorie @default(SONSTIGE_AUSGABE)
gegenstelle String?
verwendungszweckVerschluesselt String?
belegId String?
beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull)
belegId String?
beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull)
/// Buchung ohne Beleg wird in der Liste markiert - Belegzwang.
belegFehltBegruendung String?
/// 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
/// Einnahme/Ausgabe. Relevant fuer die Vermoegensuebersicht.
vermoegenswirksam Boolean @default(false)
@@ -827,7 +913,7 @@ model Vermoegensposten {
/// Passiva werden als eigener Posten mit Art VERBINDLICHKEIT gefuehrt;
/// 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,
/// Schonvermoegen, Bestattungsvorsorge, selbst bewohntes Hausgrundstueck.
bewertungBegruendungVerschluesselt String?
@@ -852,8 +938,8 @@ model Wertstand {
vermoegenspostenId String
vermoegensposten Vermoegensposten @relation(fields: [vermoegenspostenId], references: [id], onDelete: Cascade)
stichtag DateTime @db.Date
wertCent BigInt
stichtag DateTime @db.Date
wertCent BigInt
/// Woher stammt der Wert? "Rueckkaufswertmitteilung vom 12.03.2026",
/// "Maklergutachten", "Bodenrichtwert", "eigene Schaetzung".
wertquelle String
@@ -905,7 +991,7 @@ model Immobilie {
bewohntStatus String? // "leerstehend", "vermietet", "selbst bewohnt"
leerstandSeit DateTime? @db.Date
statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT)
statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT)
statusBegruendungVerschluesselt String?
notizenVerschluesselt String?
@@ -951,14 +1037,14 @@ model ImmobilienCheckpunkt {
immobilieId String
immobilie Immobilie @relation(fields: [immobilieId], references: [id], onDelete: Cascade)
titel String
beschreibung String?
erledigt Boolean @default(false)
erledigtAm DateTime? @db.Date
faelligAm DateTime? @db.Date
titel String
beschreibung String?
erledigt Boolean @default(false)
erledigtAm DateTime? @db.Date
faelligAm DateTime? @db.Date
/// Wiederkehrend (z.B. Kontrollgang, Zaehlerstand) als RRULE.
wiederholungRrule String?
reihenfolge Int @default(0)
reihenfolge Int @default(0)
erstelltAm DateTime @default(now())
@@ -1024,8 +1110,8 @@ model Vertrag {
vertragsnummerVerschluesselt String?
beginnAm DateTime? @db.Date
endeAm DateTime? @db.Date
beginnAm DateTime? @db.Date
endeAm DateTime? @db.Date
/// Kuendigungsfrist in Monaten zum Ende der Laufzeit.
kuendigungsfristMonate Int?
kuendigungZum DateTime? @db.Date
@@ -1095,13 +1181,13 @@ model Kontakt {
organisation String?
rolle KontaktRolle @default(SONSTIGES)
telefon String?
mobil String?
fax String?
email String?
strasse String?
plz String?
ort String?
telefon String?
mobil String?
fax String?
email String?
strasse String?
plz String?
ort String?
/// Aktenzeichen, unter dem dieser Kontakt den Vorgang fuehrt.
aktenzeichen String?
@@ -1112,7 +1198,7 @@ model Kontakt {
geloeschtAm DateTime?
protokolle Kontaktprotokoll[]
vorgaenge Vorgang[] @relation("VorgangKontakte")
vorgaenge Vorgang[] @relation("VorgangKontakte")
verknuepfungen DokumentVerknuepfung[]
@@index([betreuteId, rolle])
@@ -1149,7 +1235,7 @@ model Kontaktprotokoll {
/// true = persoenlicher Kontakt zur betreuten Person selbst.
mitBetreuter Boolean @default(false)
inhaltVerschluesselt String?
inhaltVerschluesselt String?
/// Geaeusserte Wuensche - fuer § 1821 BGB und den Jahresbericht.
wuenscheVerschluesselt String?
folgeaufgabenVerschluesselt String?
@@ -1224,10 +1310,10 @@ model Heimvertrag {
plz String?
ort String?
vertragAm DateTime? @db.Date
einzugAm DateTime? @db.Date
auszugAm DateTime? @db.Date
kuendigungsfristTage Int? @default(14)
vertragAm DateTime? @db.Date
einzugAm DateTime? @db.Date
auszugAm DateTime? @db.Date
kuendigungsfristTage Int? @default(14)
// Aktuelle Saetze - historische Werte stehen in Heimkostenmonat.
pflegeEigenanteilCent BigInt? // einrichtungseinheitlicher Eigenanteil
@@ -1261,7 +1347,7 @@ model Heimkostenmonat {
zusatzleistungenCent BigInt @default(0)
// Deckungsseite
pflegekasseCent BigInt @default(0) // § 43 SGB XI
pflegekasseCent BigInt @default(0) // § 43 SGB XI
/// Leistungszuschlag nach § 43c SGB XI, abhaengig von der Verweildauer.
leistungszuschlagProzent Int @default(0)
leistungszuschlagCent BigInt @default(0)
@@ -1360,14 +1446,14 @@ model Nachforderung {
erlaeuterung String?
status NachforderungStatus @default(OFFEN)
angefordertAm DateTime? @db.Date
fristBis DateTime? @db.Date
uebersandtAm DateTime? @db.Date
angefordertAm DateTime? @db.Date
fristBis DateTime? @db.Date
uebersandtAm DateTime? @db.Date
/// Wenn die Unterlage nicht existiert: warum nicht? Geht als Erklaerung
/// mit ins Unterlagenpaket.
begruendungNichtVorhanden String?
reihenfolge Int @default(0)
reihenfolge Int @default(0)
/// Vorbefuellte Standardunterlage aus der Checkliste.
ausCheckliste Boolean @default(false)
@@ -1387,12 +1473,12 @@ model Bescheid {
antragId String
antrag SozialhilfeAntrag @relation(fields: [antragId], references: [id], onDelete: Cascade)
art BescheidArt
bescheidVom DateTime @db.Date
art BescheidArt
bescheidVom DateTime @db.Date
/// Massgeblich fuer den Fristbeginn - nicht das Datum auf dem Bescheid.
zugegangenAm DateTime? @db.Date
zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date
zugegangenAm DateTime? @db.Date
zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date
betragCent BigInt?
/// In der Regel ein Monat ab Bekanntgabe (§ 84 SGG). Bei fehlender oder
@@ -1482,11 +1568,11 @@ model Bericht {
betreuteId String
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
art BerichtArt
titel String
zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date
stichtag DateTime? @db.Date
art BerichtArt
titel String
zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date
stichtag DateTime? @db.Date
/// Vom Nutzer ueberarbeiteter Freitext (Jahresbericht) - verschluesselt.
entwurfVerschluesselt String?
@@ -1527,6 +1613,8 @@ enum AuditAktion {
ANMELDUNG_FEHLGESCHLAGEN
ABMELDUNG
ZWEITER_FAKTOR_EINGERICHTET
PASSKEY_REGISTRIERT
PASSKEY_ENTFERNT
PASSWORT_GEAENDERT
ROLLE_GEAENDERT
FREIGABE_GEAENDERT
@@ -1539,8 +1627,8 @@ model AuditLog {
id String @id @default(cuid())
zeitpunkt DateTime @default(now())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
/// Momentaufnahme - bleibt lesbar, auch wenn der Benutzer geloescht wird.
userEmail String?
userName String?