Anmeldung per Passkey (WebAuthn) #6
Notifications
Total Time Spent: 19 minutes
florian.wilde
19 minutes
No due date set.
Dependencies
No dependencies set.
Reference: florian.wilde/flimmer#6
Reference in New Issue
Block a user
Worum es geht
Anmelden per Passkey — Windows Hello, Face ID, Touch ID oder Sicherheitsschlüssel — statt Passwort. Auf dem Handy ist das der Unterschied zwischen einem Blick in die Kamera und dem Abtippen eines langen Passworts.
Was dafür spricht, dass es sich gut einfügt
Die Sitzungsschicht ist schon unabhängig davon, wie jemand seine Identität nachgewiesen hat.
createSession(userId)(src/lib/auth.ts:49) legt Token, Cookie und Sitzungszeile an und interessiert sich nicht für den Weg dorthin — OIDC hängt bereits genau da ein. Ein Passkey wäre der dritte Weg in denselben Trichter, kein Umbau der Anmeldung.Zwei weitere Dinge sind bereits vorbereitet:
User.passwordHashist nullable (prisma/schema.prisma) — ein Konto ganz ohne Passwort ist im Datenmodell schon darstellbar, das kam mit OIDC.OidcAccountgibt das Muster für eine verknüpfte Zugangstabelle vor.Datenmodell
Ein neues Modell, an
Usergehängt, mitonDelete: Cascadewie die anderen:credentialId@uniquepublicKeyBytes)countertransportsusb,nfc,ble,internal,hybriddeviceType/backedUpnamecreatedAt/lastUsedAtDie Challenge braucht eine kurzlebige Ablage zwischen den zwei Schritten. Vorschlag: signiertes, httpOnly-Cookie mit kurzer Laufzeit statt einer Tabelle — spart Schema und einen Aufräumjob.
Bibliothek
@simplewebauthn/serverund@simplewebauthn/browser. Reines JavaScript, keine nativen Build-Abhängigkeiten — passt zu der Entscheidung, die auch schon zubcryptjsstattargon2geführt hat. Selbst implementieren wäre bei CBOR-Zerlegung und Attestationsprüfung der falsche Ehrgeiz.Der Fallstrick beim Selbsthosten — bitte vorher lesen
Ein Passkey ist fest an den Hostnamen gebunden (die RP ID). Daraus folgt dreierlei, und das ist bei einer selbst gehosteten App wichtiger als bei einem SaaS:
localhost. Wer Flimmer im LAN über nacktes HTTP betreibt, kann Passkeys nicht nutzen. Das muss die Oberfläche sagen, statt einen kryptischen Browserfehler zu zeigen.APP_URLabgeleitet werden (src/lib/env.ts:40), nicht aus demHost-Header — sonst hängt sie hinter einem Reverse Proxy am falschen Namen.flimmer.fritz.boxaufflimmer.example.comumzieht, muss sie neu anlegen. Gehört in die README, nicht in eine Fehlermeldung.Nicht aussperren
Das Passwort bleibt. Ein Passkey kommt zusätzlich, und wer alle Wege bis auf einen entfernt, bekommt eine deutliche Warnung — besonders bei einem Passkey, der nicht synchronisiert ist (
backedUp: false): geht das Gerät verloren, ist das Konto weg. Für den Notfall sollte ein Administrator ein Passwort zurücksetzen können.Umzusetzen
Passkeysamt Migrationsrc/lib/passkeys.ts: Optionen erzeugen und Antworten prüfen, für Registrierung und Anmeldung; am EndecreateSession(userId)/api/auth/passkey/…: je ein Paar aus „Optionen holen" und „Antwort prüfen" für Registrierung und Anmeldung/anmelden: Knopf „Mit Passkey anmelden" über auffindbare Zugangsdaten (ohne vorherige E-Mail-Eingabe); optional später die Browser-AutovervollständigungAbnahme
lastUsedAtwird gepflegtAufwand
Mittel. Das Datenmodell ist klein und die Sitzungsschicht muss nicht angefasst werden — der Aufwand steckt in den vier Endpunkten, der Oberfläche und den Tests. Ein Nachmittag, kein Wochenende.
Angelegt auf ausdrücklichen Wunsch direkt in Gitea, ohne Verknüpfung zu einem Nebula-Task.
🤖 Claude Code hat mit der Umsetzung begonnen.
feat/6-passkey-anmeldungPasskeysamt Migration; Challenge in einem kurzlebigen, signierten httpOnly-Cookie statt in einer Tabellesrc/lib/passkeys.tsmit@simplewebauthn/server, RP ID ausAPP_URLabgeleitet; vier Endpunkte unter/api/auth/passkey/…, die am EndecreateSession()aufrufenEin Hinweis vorweg: Passkeys verlangen einen echten Hostnamen. Auf
127.0.0.1lehnt der Browser sie ab, weil eine IP-Adresse keine gültige RP ID ist — geprüft wird deshalb gegenhttp://localhost.Zeiterfassung läuft.
✅ Umsetzung abgeschlossen — bereit zum Testen.
Was geändert wurde
Passkeysamt Migration;src/lib/passkeys.tsmit@simplewebauthn/server/api/auth/passkey/…, dazu/api/passkeys/[id]zum Umbenennen und Entfernen/anmeldenmit „Mit Passkey anmelden" ohne E-Mail-EingabeAPP_URL; über nacktes HTTP verschwindet die Verwaltung mit einem HinweisENABLE_PASSKEYSschaltet alles ab.env.examplenachgezogenGeänderte Dateien
prisma/schema.prisma,prisma/migrations/20260902044634_passkeys/src/lib/passkeys.ts,src/lib/env.tssrc/app/api/auth/passkey/**(vier Endpunkte),src/app/api/passkeys/[id]/route.tssrc/components/settings/Passkeys.tsx,src/app/einstellungen/page.tsx,src/app/(auth)/anmelden/page.tsxtests/passkeys.test.ts,README.md,.env.exampleAbweichung von der Vorgabe: Die Beschreibung sagte,
finishLogin()solle am EndecreateSession()aufrufen. Das steht jetzt im Endpunkt statt in der Domänenschicht —passkeys.tsenthält nur WebAuthn, Cookie und Sitzungszeile gehören zur HTTP-Schicht. Nebeneffekt: das Modul bleibt in Tests importierbar, was es überlib/auth(wegennext/navigation) nicht wäre.Eine Feststellung zum Signaturzähler: Meine eigene Prüfung lief ins Leere —
@simplewebauthn/serverprüft den Zähler bereits selbst und wirft dabei einen gewöhnlichenError. Der landete als „Das hat nicht geklappt." beim Nutzer und als unerwarteter Serverfehler im Log. Ist jetzt übersetzt; die eigene Prüfung bleibt als zusätzliche Sicherung für den Fall, den die Bibliothek durchlässt.Geprüft — mit virtuellem Authenticator (Playwright/CDP) gegen einen Produktionsbau:
/anmelden/, Bibliothek erreichbarflimmer_sessiongesetzt, Challenge-Cookie verbrauchtAPP_URL66 Tests grün (sieben neue), Typecheck sauber. Die Test-Passkeys sind aus der Entwicklungsdatenbank wieder entfernt.
So testest du es
git switch feat/6-passkey-anmeldungnpm run db:migrate(die Migration muss eingespielt werden)npm run dev— wichtig: überhttp://localhost:3011aufrufen, nicht über127.0.0.1. Eine IP-Adresse ist als RP ID unzulässig, der Browser lehnt Passkeys dort ab./anmelden„Mit Passkey anmelden" — ohne E-Mail-Eingabe.Aufgewendete Zeit: 00:20
Pull Request: #12
Nach Freigabe:
/issue-freigabe 6📝 Nebenbei aufgefallen — hat nichts mit Passkeys zu tun, fiel aber beim Prüfen auf:
Über nacktes HTTP ist die Instanz gar nicht benutzbar — auch mit Passwort nicht.
Beim Test des HTTPS-Hinweises habe ich eine Instanz mit
APP_URL=http://10.1.5.238:3013gestartet und versucht, mich normal mit E-Mail und Passwort anzumelden. Die Anmeldung meldet Erfolg, aber der nächste Seitenaufruf landet wieder auf/anmelden.Ursache:
createSession()setzt das Sitzungscookie mitsecure: process.env.NODE_ENV === "production"(src/lib/auth.ts:71). Im Produktionsbau ist dastrue, und einSecure-Cookie sendet der Browser über nacktes HTTP nicht. Auflocalhostfällt es nicht auf, weil Browser localhost als sicheren Kontext behandeln — auf einer LAN-IP schon.Das heißt: wer Flimmer im Heimnetz ohne HTTPS betreibt, kann sich nicht anmelden. Es gibt dabei keine Fehlermeldung, man landet nur immer wieder auf der Anmeldeseite. Das ist kein neuer Fehler und auch keine falsche Entscheidung —
Securein der Produktion ist richtig —, aber es ist nirgends dokumentiert und die Sackgasse ist stumm.Drei denkbare Wege, alle nicht Teil dieses PR:
APP_URLmithttp://beginnt und der Host nichtlocalhostist.ALLOW_INSECURE_COOKIESfür bewusste LAN-Installationen — mit deutlicher Warnung, weil das Sitzungstoken dann im Klartext über das Netz geht.Ich würde 1 und 2 nehmen; 3 nur, wenn du es wirklich brauchst.
🚀 Freigegeben und nach
mainübernommen.824cc77Vor dem Merge noch einmal geprüft: Typecheck sauber, 66 Tests grün.
⚠️ Beim Aktualisieren der Instanz kommt eine neue Migration mit (
20260902044634_passkeys). Der Container spielt sie beim Start selbst ein;./update.shlegt vorher eine Sicherung an.Issue wird geschlossen.