Vier Stellen, an denen eine Zusage nicht gehalten wurde.
1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit
dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan
hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90
Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch
zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von
updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen
nicht beide eine Zeile.
2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das
ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage
eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend
Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern
irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY
gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine
Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim
ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt.
3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der
dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen
Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten.
AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
nicht stillschweigend uebergangen wird.
4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen.
Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel
ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks.
Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste
alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst
verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft
werden".
Refs #38
Die Sperre hielt sich selbst am Leben. Jeder Aufruf wurde festgehalten,
auch der, der wegen der laufenden Sperre gar nicht geprueft wurde; die
Frist lief zugleich ab dem juengsten Fehlversuch. Wer also alle zehn
Minuten ein falsches Passwort an eine fremde Adresse schickte, sperrte
deren Inhaber dauerhaft aus - ohne das Passwort zu kennen. Jeder dieser
Aufrufe hinterliess ausserdem eine Zeile, die erst der Wartungslauf nach
30 Tagen wieder los wurde.
Jetzt zwei getrennte Dinge: Ein geprueft fehlgeschlagener Versuch zaehlt
und wird einzeln festgehalten. Ein waehrend der Sperre abgewiesener
Aufruf zaehlt nicht und wird hoechstens einmal je Sperre vermerkt.
Die Auswertung geht die Versuche von alt nach neu durch und zieht die
Runden nach: Ist das Mass voll, beginnt an diesem Versuch eine Sperre
und der Zaehler faengt von vorn an. Damit laeuft die Frist auch dann ab,
wenn die Fehlversuche noch im Zeitfenster stehen - vorher blieb der
Zugang bis zum Fensterende dicht.
Die Tests sind gegen vier Mutationen geprueft; jede wird rot.
Refs #38
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.
Basisbausteine im shadcn/ui-Stil, aber mit drei bewussten Abweichungen:
- Bedienelemente sind 44px hoch statt 36px. Ich bediene die App oft am
Handy im Heim oder auf dem Amt; kleiner ist dort nicht treffsicher.
- Auswahlfelder benutzen das native <select>. Android und iOS oeffnen
dafuer ihre eigene Liste - schneller als jeder Nachbau, und mit
Screenreader und Tastatur funktioniert es ohne Zutun.
- Eingabefelder haben mindestens 16px Schrift, sonst zoomt iOS-Safari
beim Hineintippen das Formular heran.
<Feld> verdrahtet Beschriftung, Erklaerung und Fehler ueber
aria-describedby. Der Erklaerungstext ist kein Beiwerk: die Anwendung
benutzt Begriffe aus dem Betreuungsrecht, die niemand nachschlagen soll.
<Rechtshinweis> ist der stehende Vermerk, dass hier keine Rechtsberatung
stattfindet. Er steht an jeder Stelle mit Paragraphenbezug.
Der Anwendungsrahmen fuehrt drei Bedingungen zusammen, ohne die niemand
an Fachdaten kommt: angemeldet, zweiter Faktor eingerichtet, mindestens
eine betreute Person zugeordnet. Die Navigation zeigt Zaehler fuer
ueberfaellige Fristen und neuen Posteingang - die Frage "wo muss ich
hin?" wird schon im Menue beantwortet.
Das Farbschema wird per Inline-Skript mit CSP-Nonce gesetzt, bevor die
Seite gezeichnet wird - sonst blitzt beim Laden das helle Thema auf.
docs/KONVENTIONEN.md haelt fest, was in allen weiteren Etappen gilt:
Verzeichnisaufbau, Cent-Betraege, Umgang mit verschluesselten Feldern,
Zugriffspruefung, Audit-Pflicht, Ampelfarben, Commit-Trailer.
Etappe: 1
Phase: Fundament
Auth.js mit Zugangsdaten-Provider: E-Mail, Passwort und TOTP-Code werden
in einem Formular abgefragt. Ein zweistufiger Ablauf muesste zwischen den
Schritten einen Zwischenzustand halten - genau das ist die uebliche
Schwachstelle, und am Handy ist ein Formular ohnehin schneller.
Anmeldung:
- Sperre wird VOR dem Passwortvergleich geprueft, damit ein gesperrter
Zugang nicht einmal Argon2-Rechenzeit kostet.
- Bei unbekannter Adresse wird gegen einen Dummy-Hash gerechnet und die
Antwort auf eine Mindestdauer gestreckt. Sonst verraet die Antwortzeit,
welche Adressen existieren.
- Wiederherstellungscodes werden nach Gebrauch entfernt.
- Veraltete Argon2-Parameter werden beim erfolgreichen Login nachgezogen.
Rate-Limiting in der Datenbank statt Redis - ein Server, wenige Benutzer,
kein Grund fuer eine weitere Komponente. Gezaehlt wird nach E-Mail und
nach Anschluss, damit weder viele Anschluesse noch viele Adressen helfen.
Zugriffsschutz (sitzung.ts):
- Die Sitzung allein entscheidet nichts. Bei jedem Aufruf wird der
Benutzer frisch geladen und die Sitzungsgeneration verglichen - so
lassen sich alle Anmeldungen auf einen Schlag beenden.
- erfordereZugang() prueft immer gegen die betreuteId, nie nur im UI.
- Erneute Bestaetigung vor sensiblen Aktionen ueber ein signiertes,
kurzlebiges Cookie statt ueber Serverzustand.
Audit-Log: aenderungErmitteln() protokolliert nur die tatsaechlich
geaenderten Felder; Passwoerter und TOTP-Geheimnisse werden ersetzt, nie
gespeichert. Ein Fehler im Protokoll bricht die Fachaktion nicht ab -
ausser die Aktion laeuft in einer Transaktion, dann faellt beides.
CSP mit Nonce pro Anfrage in der Middleware: keine Fremdquellen, kein
CDN, keine Fonts von aussen. Fachseiten werden nicht zwischengespeichert.
Etappe: 1
Phase: Fundament