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:
+143
-55
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user