diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b2995..9a55fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,80 @@ Wiederherstellung*. _Noch nichts._ +## [1.1.0] – 2026-08-22 + +### Hinzugefügt + +- **Anmelden mit Passkey.** Auf der Anmeldeseite steht jetzt unter dem + Formular ein zweiter Weg: *Mit Passkey anmelden*. Ein Fingerabdruck, ein + Blick in die Kamera oder die Geräte-PIN — keine E-Mail, kein Passwort, + kein abgetippter Code. + + **Der Passkey ersetzt den zweiten Faktor, er kommt nicht zusätzlich.** + Das ist kein Nachlassen: Ein Passkey verlangt den Besitz des Geräts + *und* dessen Entsperrung, das sind dieselben zwei Faktoren wie vorher. + Die Anwendung fordert die Nutzerverifikation ausdrücklich an und weist + eine Antwort ohne sie zurück. Ein Gerät, das auf blosses Antippen hin + signiert, kommt nicht herein. + + In einem Punkt ist er dem bisherigen Weg sogar überlegen: Ein Passkey + ist an die Adresse gebunden, unter der er eingerichtet wurde. Auf einer + nachgebauten Anmeldeseite funktioniert er nicht — und Phishing ist der + übliche Weg, an einen TOTP-Code zu kommen. + + **Die Authenticator-App muss trotzdem eingerichtet sein**, bevor sich + ein Passkey anlegen lässt. Ein Passkey hängt an einem Gerät. Ginge das + verloren und wäre er der einzige Weg hinein, käme niemand mehr an die + Akte — und darin stehen Unterlagen, die eine betreute Person braucht. + Der Passkey ist die bequeme Tür, nicht die einzige. + + Verwaltet werden sie unter **Mein Konto → Passkeys**: einrichten, + benennen, entfernen. Die Liste zeigt, wann ein Gerät zuletzt benutzt + wurde und ob der Schlüssel gesichert ist (iCloud, Google, Bitwarden) + oder nur auf diesem einen Gerät liegt. Einrichten und Entfernen stehen + im Protokoll, jede Anmeldung nennt den verwendeten Passkey namentlich. + +- **Bestätigen mit Passkey.** Export, endgültiges Löschen und die + Benutzerverwaltung verlangen eine frische Anmeldung. Auch die geht jetzt + mit dem Passkey. Geprüft wird zusätzlich, dass er zum angemeldeten + Zugang gehört — sonst liesse sich mit dem eigenen Finger eine fremde, + offen stehende Sitzung freischalten. Genau der Fall, gegen den diese + Zwischenfrage gebaut ist. + +### Zu beachten + +- **Passkeys brauchen `https`.** Der Browser gibt das Verfahren nur in + einem sicheren Kontext frei. Läuft die Anwendung ohne TLS, erscheint der + Knopf gar nicht erst, statt beim Klick mit einer kryptischen Meldung + abzubrechen. + +- **`AUTH_URL` muss stimmen — jetzt noch mehr als vorher.** Aus ihr wird + die Kennung abgeleitet, an die der Authenticator den Schlüssel bindet. + Steht dort eine andere Adresse als die im Browser, lässt sich zwar ein + Passkey einrichten, aber nie wieder verwenden. + + **Ein Wechsel der Domain macht alle Passkeys unbrauchbar.** Das ist + keine Panne, sondern der Phishingschutz. Nach einem Umzug richtet man + sie neu ein; die Anmeldung mit Passwort und Code bleibt währenddessen + offen. + +- **Eine Datenbank-Migration ist nötig** (`20260822160000_passkey_anmeldung`). + Sie läuft beim Start des Behälters automatisch mit. Bestehende Zugänge + sind nicht betroffen: Ohne eingerichteten Passkey ändert sich nichts. + +### Geprüft + +- 21 neue Prüfungen (`tests/passkey.test.ts`), vor allem für die Herleitung + von Kennung und Herkunft aus `AUTH_URL`: Port, Schrägstrich am Ende, + Unterdomain, `http` gegen `https`, `localhost`. Das ist die Stelle, an + der eine Installation still kaputtginge — dieselbe Fehlerklasse wie + seinerzeit bei `APP_BIND` und `ENC_KEY_V2`. +- Drei End-to-End-Tests gegen einen echten Browser (`e2e/passkey.spec.ts`), + mit Chromiums virtuellem Authenticator: einrichten, abmelden, mit Passkey + anmelden, Benutzung vermerkt, entfernen. Dazu die Bestätigung vor dem + Vollexport und der Fall, dass ein Gerät ohne Nutzerverifikation nicht + hereinkommt. + ## [1.0.5] – 2026-08-22 ### Hinzugefügt @@ -392,7 +466,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md). --- -[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.5...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.0...HEAD +[1.1.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.5...v1.1.0 [1.0.5]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.4...v1.0.5 [1.0.4]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.3...v1.0.4 [1.0.3]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.2...v1.0.3 diff --git a/README.md b/README.md index 2006630..3e76ee4 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,15 @@ entscheiden kann. Was dafür umgesetzt ist: - Passwörter mit **Argon2id** (19 MiB, t=2, p=1 — OWASP-Empfehlung). - **Zweiter Faktor ist Pflicht** und lässt sich nicht abschalten. TOTP nach RFC 6238, kompatibel mit Aegis, 2FAS, Bitwarden, 1Password. +- **Passkeys** (WebAuthn/FIDO2) als zweiter Anmeldeweg — auch für die + erneute Bestätigung vor sensiblen Aktionen. Sie *ersetzen* den zweiten + Faktor, statt zu ihm hinzuzukommen: Nutzerverifikation wird verlangt + (`userVerification: 'required'`) und serverseitig nachgeprüft, ein + Schlüssel ohne sie wird abgewiesen. Damit sind es dieselben zwei + Faktoren — Besitz des Geräts und dessen Entsperrung —, nur an die Domain + gebunden und deshalb nicht abphishbar. TOTP muss trotzdem eingerichtet + sein, bevor sich ein Passkey anlegen lässt: Er hängt an einem Gerät und + darf nicht der einzige Weg hinein sein. - **Rate-Limiting** am Login, nach E-Mail *und* nach Anschluss. Die Sperre wird geprüft, bevor Argon2 überhaupt rechnet. - Die Antwortzeit ist auf eine Mindestdauer gestreckt, damit sie nicht @@ -559,14 +568,17 @@ Fehler, die während des Baus aufgefallen sind. ## Stand -| | | -|---|---:| -| Zeilen TypeScript/TSX | 71.051 | -| Prisma-Modelle | 24 | -| Seiten | 46 | -| Unit-Tests (Vitest) | 458 | -| End-to-End-Tests (Playwright) | 34 | -| TypeScript-Fehler | 0 | +| | | nachzuzählen mit | +|---|---:|---| +| Zeilen TypeScript/TSX | 72.080 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | +| Prisma-Modelle | 33 | `grep -c '^model ' prisma/schema.prisma` | +| Seiten | 79 | `find src/app -name page.tsx \| wc -l` | +| Unit-Tests (Vitest) | 496 | `npm test` | +| End-to-End-Tests (Playwright) | 37 | `npm run test:e2e` | +| TypeScript-Fehler | 0 | `npm run typecheck` | + +Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es +jemandem auffällt — bei den Modellen und Seiten war genau das passiert. Alle acht Etappen sind umgesetzt. Der Produktionsbuild ist gegen den fertigen Standalone-Server geprüft: strenge CSP aktiv, keine diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index f2310a5..3dcf89e 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -57,6 +57,10 @@ Anschreiben wieder auf. **Zum Schluss:** Geh auf **Fristen** und klicke einmal auf *Pflichtfristen ableiten*. Danach stehen deine gesetzlichen Termine drin. +Und wenn du magst: Richte unter **Mein Konto → Passkeys** einen Passkey +ein. Dann meldest du dich künftig mit dem Finger an, statt drei Felder +auszufüllen. Wie das geht, steht gleich im nächsten Abschnitt. + --- ## Der Morgen: die Seite „Heute" @@ -328,6 +332,86 @@ Anwendung jederzeit verlassen. Verlangt eine erneute Anmeldung. --- +## Anmelden mit dem Finger: Passkeys + +Drei Felder auszufüllen, nur um kurz eine Frist nachzusehen, ist lästig — +und lästige Sicherheit wird umgangen. Ein Passkey macht daraus einen +Handgriff. + +### Einrichten + +**Mein Konto → Passkeys.** Gib dem Gerät einen Namen, an dem du es später +wiedererkennst („iPhone", „Laptop Büro"), und klicke auf *Passkey +einrichten*. Dein Gerät fragt nach Fingerabdruck, Gesicht oder PIN — +fertig. + +Mach das auf **jedem Gerät**, mit dem du arbeitest. Sie stören sich nicht +gegenseitig. + +### Anmelden + +Auf der Anmeldeseite steht unter dem Formular *Mit Passkey anmelden*. Ein +Klick, ein Finger, drin. Keine E-Mail, kein Passwort, kein Code. + +### Warum das nicht unsicherer ist + +Es sieht nach weniger aus, ist aber dasselbe — zweimal: + +| | Bisher | Mit Passkey | +|---|---|---| +| Erster Faktor | Passwort (Wissen) | Das Gerät selbst (Besitz) | +| Zweiter Faktor | Code aus der App (Besitz) | Fingerabdruck, Gesicht oder PIN | + +Der entscheidende Zusatz: **Ein Passkey ist an die Adresse gebunden**, +unter der du ihn eingerichtet hast. Landest du auf einer nachgebauten +Anmeldeseite, funktioniert er dort schlicht nicht — er lässt sich nicht +abtippen und nicht weitergeben. Genau das ist der übliche Weg, an einen +Code aus der Authenticator-App zu kommen. + +Ein Gerät, das den Passkey ohne Fingerabdruck oder PIN freigibt, wird +abgewiesen. Das wäre nur ein Faktor, und dafür ist hier zu viel zu +verlieren. + +### Die Authenticator-App bleibt + +Du kannst sie nicht abschaffen, und das ist Absicht. **Ein Passkey hängt +an einem Gerät.** Geht das Handy in der Donau baden und wäre der Passkey +der einzige Weg hinein, käme niemand mehr an die Akte — auch der Bezirk +nicht, auch das Gericht nicht. + +Deshalb: erst Authenticator-App und Wiederherstellungscodes, dann Passkeys. +Die Anmeldung mit E-Mail, Passwort und Code bleibt daneben immer offen. + +### Was in der Liste steht + +Unter **Mein Konto → Passkeys** siehst du zu jedem Gerät, wann es +eingerichtet und wann es zuletzt benutzt wurde. **Sieh da gelegentlich +hinein.** Ein Eintrag, den du nicht zuordnen kannst, gehört sofort +entfernt — und danach das Passwort gewechselt. + +Steht bei einem Eintrag *gesichert*, wird der Schlüssel vom Anbieter +mitgesichert (iCloud-Schlüsselbund, Google, Bitwarden) und taucht auf +einem neuen Gerät von selbst wieder auf. Ohne diesen Vermerk liegt er nur +auf diesem einen Gerät. + +### Wenn ein Gerät verloren geht + +1. Auf einem anderen Gerät mit E-Mail, Passwort und Code anmelden. +2. **Mein Konto → Passkeys**, den Eintrag des verlorenen Geräts entfernen. +3. Fertig — der Schlüssel auf dem Gerät ist damit wertlos. + +Ein Passkey allein reicht ohnehin niemandem, der das Gerät findet: Ohne +Fingerabdruck oder PIN gibt das Gerät ihn nicht heraus. + +### Wenn der Knopf gar nicht erscheint + +Dann bietet der Server Passkeys nicht an — das Verfahren verlangt eine +verschlüsselte Verbindung (`https`). Oder der Browser ist zu alt. Melde +dich mit E-Mail, Passwort und Code an; alles andere funktioniert +unverändert. + +--- + ## Am Handy Die Anwendung ist am Telefon vollwertig bedienbar — sie ist dafür gebaut, diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 227985f..ec60e10 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -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 · `. +- Unten links in der Seitenleiste steht `Version 1.1.0 · `. --- @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0acb80c..c09d1e3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre. Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb als wichtiger herausstellt, rutscht nach oben. -Stand: 22.08.2026 · Aktuelle Fassung: **1.0.0** ([CHANGELOG](../CHANGELOG.md)) +Stand: 22.08.2026 · Aktuelle Fassung: **1.1.0** ([CHANGELOG](../CHANGELOG.md)) --- @@ -136,6 +136,31 @@ Jahresbericht und bei einer Höherstufung gebraucht. --- +## 1.2b — Passkeys abrunden + +Der Grundstock steht seit 1.1.0. Was noch fehlt, ist Bequemlichkeit, nicht +Sicherheit. + +### Passkey direkt im Anmeldefeld anbieten + +Browser können einen Passkey im Ausfüllvorschlag des E-Mail-Feldes +anbieten (*conditional UI*), statt hinter einem eigenen Knopf. Ein Schritt +weniger. Braucht einen zweiten Challenge-Weg und sorgfältiges Abbrechen, +wenn stattdessen doch das Passwort getippt wird. + +### Passkey umbenennen + +Heute geht nur anlegen und entfernen. Wer sein Gerät „Handy" genannt hat +und inzwischen zwei hat, muss den Passkey neu einrichten. + +### Warnen, wenn der letzte Passkey nur auf einem Gerät liegt + +Die Liste sagt bereits, ob ein Schlüssel gesichert ist. Wer ausschließlich +ungesicherte Passkeys hat und dazu keine Wiederherstellungscodes mehr, +sollte das deutlicher zu sehen bekommen als in einer Zeile Kleingedrucktem. + +--- + ## 1.3 — Belastbarkeit **Ziel:** Die Stellen absichern, die bisher nur unter Laborbedingungen @@ -164,6 +189,14 @@ Monat automatisch durchspielt — Sicherung zurückspielen, Diagnosen im Klartext prüfen, Testdatenbank verwerfen — wäre der Beweis statt der Absichtserklärung. +### Passkey-Ablehnung ohne Browser nachstellen + +Die Prüfung „Passkey ohne Nutzerverifikation wird abgewiesen" ist im Code +doppelt abgesichert und im Browsertest belegt — dort weist allerdings +schon der Browser ab, der Server kommt gar nicht dran. Ein Test, der die +Antwort von Hand baut und den Browser umgeht, würde den serverseitigen +Rückhalt tatsächlich beweisen. + ### Barrierefreiheits-Durchgang mit einem Screenreader Semantik, Fokusreihenfolge und Kontraste sind beim Bauen beachtet und in diff --git a/e2e/passkey.spec.ts b/e2e/passkey.spec.ts new file mode 100644 index 0000000..bf1c710 --- /dev/null +++ b/e2e/passkey.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from '@playwright/test' +import type { BrowserContext, CDPSession, Page } from '@playwright/test' +import { anmelden, fehlversucheLoeschen } from './hilfen' + +/** + * Passkeys gegen einen echten Browser. + * + * WebAuthn laesst sich nicht sinnvoll mit Einheitstests pruefen: Was zaehlt, + * passiert zwischen Browser und Authenticator. Chromium bringt dafuer einen + * virtuellen Authenticator mit, der ueber das Debugprotokoll gesteuert wird - + * er signiert echt, nur ohne Fingerabdrucksensor. + * + * Damit laesst sich der Ablauf vollstaendig durchspielen: einrichten, + * abmelden, mit Passkey anmelden, entfernen. Und, wichtiger, der Fall, den + * die Zusage dieser Anwendung traegt - ein Geraet OHNE Nutzerverifikation + * darf nicht hereinkommen. + */ + +type VirtuellerAuthenticator = { + cdp: CDPSession + id: string +} + +async function authenticatorAnlegen( + kontext: BrowserContext, + seite: Page, + verifiziert = true, +): Promise { + const cdp = await kontext.newCDPSession(seite) + await cdp.send('WebAuthn.enable') + + const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + // "internal" = im Geraet verbaut, wie Face ID oder Windows Hello. + transport: 'internal', + // Auffindbarer Schluessel - Voraussetzung fuer die Anmeldung ohne + // vorherige Eingabe der E-Mail-Adresse. + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: verifiziert, + automaticPresenceSimulation: true, + }, + }) + + return { cdp, id: authenticatorId } +} + +async function passkeyEinrichten(seite: Page, name: string): Promise { + await seite.goto('/konto') + await seite.getByLabel('Neues Gerät').fill(name) + await seite.getByRole('button', { name: 'Passkey einrichten' }).click() + await expect(seite.getByText(`„${name}“ ist eingerichtet.`)).toBeVisible() +} + +test.describe('Passkey', () => { + test.beforeEach(async () => { + await fehlversucheLoeschen() + }) + + test('richtet einen Passkey ein und meldet sich anschliessend damit an', async ({ + page, + context, + }) => { + const authenticator = await authenticatorAnlegen(context, page) + + // --- 1. Auf dem gewoehnlichen Weg herein -------------------------------- + await anmelden(page) + + // --- 2. Passkey einrichten --------------------------------------------- + await passkeyEinrichten(page, 'Testgerät') + + // Er steht in der Liste, mit Datum und dem Hinweis "noch nicht benutzt". + const eintrag = page.locator('li', { hasText: 'Testgerät' }) + await expect(eintrag).toBeVisible() + await expect(eintrag).toContainText('noch nicht benutzt') + + // --- 3. Abmelden -------------------------------------------------------- + await context.clearCookies() + await page.goto('/anmelden') + await expect(page.getByRole('button', { name: 'Mit Passkey anmelden' })).toBeVisible() + + // --- 4. Mit Passkey anmelden ------------------------------------------- + await Promise.all([ + page.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }), + page.getByRole('button', { name: 'Mit Passkey anmelden' }).click(), + ]) + + // Kein zweiter Schritt: Der Passkey IST der zweite Faktor. Wer hier auf + // der Einrichtungsseite fuer TOTP landet, hat einen Fehler im Ablauf. + expect(new URL(page.url()).pathname).not.toContain('/zwei-faktor') + + // --- 5. Die Benutzung ist vermerkt ------------------------------------- + await page.goto('/konto') + await expect(page.locator('li', { hasText: 'Testgerät' })).toContainText('zuletzt benutzt') + + // --- 6. Wieder entfernen, damit der naechste Lauf sauber startet ------- + await page.locator('li', { hasText: 'Testgerät' }).getByRole('button').click() + await page.getByRole('button', { name: 'Wirklich entfernen' }).click() + await expect(page.getByText('„Testgerät“ wurde entfernt.')).toBeVisible() + + await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', { + authenticatorId: authenticator.id, + }) + }) + + test('laesst ein Geraet ohne Nutzerverifikation nicht herein', async ({ page, context }) => { + // Das ist die Zusage, auf der alles steht: Ein Passkey darf den zweiten + // Faktor nur ersetzen, wenn das Geraet den Benutzer wirklich verifiziert + // hat - Fingerabdruck, Gesicht oder PIN. Ein Schluessel, der auf blosses + // Antippen hin signiert, ist ein Faktor, kein zweiter. + // + // Was dieser Test belegt: Die Anforderung erreicht das Geraet, und ein + // Geraet ohne Verifikation kommt nicht herein. Abgewiesen wird dabei + // schon vom Browser - im Protokoll steht deshalb kein fehlgeschlagener + // Versuch. Die Pruefung auf dem Server (requireUserVerification und die + // ausdrueckliche Abfrage von userVerified in anmeldungPruefen) ist der + // Rueckhalt fuer den Fall, dass jemand die Antwort von Hand baut und den + // Browser umgeht. Der laesst sich hier nicht nachstellen. + const authenticator = await authenticatorAnlegen(context, page, true) + + await anmelden(page) + await passkeyEinrichten(page, 'Ungesichertes Gerät') + + // Jetzt die Verifikation abschalten - so verhaelt sich ein Geraet ohne + // eingerichtete Bildschirmsperre. + await authenticator.cdp.send('WebAuthn.setUserVerified', { + authenticatorId: authenticator.id, + isUserVerified: false, + }) + + await context.clearCookies() + await page.goto('/anmelden') + await page.getByRole('button', { name: 'Mit Passkey anmelden' }).click() + + // Entscheidend ist nicht der Wortlaut, sondern: Es geht nicht herein. + await expect(page.getByRole('alert')).toBeVisible({ timeout: 30_000 }) + await page.waitForTimeout(1000) + expect(new URL(page.url()).pathname).toBe('/anmelden') + + // Aufraeumen: wieder verifizieren, anmelden, Passkey entfernen. + await authenticator.cdp.send('WebAuthn.setUserVerified', { + authenticatorId: authenticator.id, + isUserVerified: true, + }) + await fehlversucheLoeschen() + await anmelden(page) + await page.goto('/konto') + await page.locator('li', { hasText: 'Ungesichertes Gerät' }).getByRole('button').click() + await page.getByRole('button', { name: 'Wirklich entfernen' }).click() + await expect(page.getByText('„Ungesichertes Gerät“ wurde entfernt.')).toBeVisible() + + await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', { + authenticatorId: authenticator.id, + }) + }) + + test('bestaetigt eine sensible Aktion mit dem Passkey statt mit Passwort und Code', async ({ + page, + context, + }) => { + const authenticator = await authenticatorAnlegen(context, page) + + await anmelden(page) + await passkeyEinrichten(page, 'Bestätigungsgerät') + + // Der Vollexport verlangt eine frische Anmeldung. + await page.goto('/berichte/export') + await page.waitForURL(/\/(bestaetigen|berichte)/) + + if (new URL(page.url()).pathname === '/bestaetigen') { + await Promise.all([ + page.waitForURL((url) => url.pathname !== '/bestaetigen', { timeout: 45_000 }), + page.getByRole('button', { name: 'Mit Passkey bestätigen' }).click(), + ]) + expect(new URL(page.url()).pathname).toContain('/berichte/export') + } + + await page.goto('/konto') + await page.locator('li', { hasText: 'Bestätigungsgerät' }).getByRole('button').click() + await page.getByRole('button', { name: 'Wirklich entfernen' }).click() + await expect(page.getByText('„Bestätigungsgerät“ wurde entfernt.')).toBeVisible() + + await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', { + authenticatorId: authenticator.id, + }) + }) +}) diff --git a/package-lock.json b/package-lock.json index 2d2e6f9..053c988 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "betreuungs-cockpit", - "version": "0.1.0", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "betreuungs-cockpit", - "version": "0.1.0", + "version": "1.0.5", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-s3": "3.735.0", @@ -18,6 +18,8 @@ "@radix-ui/react-label": "2.1.1", "@radix-ui/react-slot": "1.1.1", "@react-pdf/renderer": "4.1.6", + "@simplewebauthn/browser": "13.3.0", + "@simplewebauthn/server": "13.3.2", "archiver": "7.0.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -1573,6 +1575,12 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2161,6 +2169,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -2706,6 +2720,207 @@ "pako": "^1.0.10" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz", + "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4033,6 +4248,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@smithy/config-resolver": { "version": "4.7.2", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.7.2.tgz", @@ -5855,6 +6095,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -10192,6 +10446,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -10398,6 +10670,12 @@ "node": ">=8.10.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -11687,6 +11965,24 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 923379d..2727f67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.0.5", + "version": "1.1.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { @@ -44,6 +44,8 @@ "@radix-ui/react-label": "2.1.1", "@radix-ui/react-slot": "1.1.1", "@react-pdf/renderer": "4.1.6", + "@simplewebauthn/browser": "13.3.0", + "@simplewebauthn/server": "13.3.2", "archiver": "7.0.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -90,5 +92,11 @@ "prisma@6.3.1": true, "sharp@0.33.5": true, "unrs-resolver@1.12.2": true + }, + "overrides": { + "next-auth": { + "@simplewebauthn/server": "$@simplewebauthn/server", + "@simplewebauthn/browser": "$@simplewebauthn/browser" + } } } diff --git a/prisma/migrations/20260822160000_passkey_anmeldung/migration.sql b/prisma/migrations/20260822160000_passkey_anmeldung/migration.sql new file mode 100644 index 0000000..fe2c5af --- /dev/null +++ b/prisma/migrations/20260822160000_passkey_anmeldung/migration.sql @@ -0,0 +1,67 @@ +-- Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg. +-- +-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte +-- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und +-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und +-- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt, +-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus +-- jeder erzeugten Migration entfernt. + +-- CreateEnum +CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "AuditAktion" ADD VALUE 'PASSKEY_REGISTRIERT'; +ALTER TYPE "AuditAktion" ADD VALUE 'PASSKEY_ENTFERNT'; + +-- CreateTable +CREATE TABLE "passkey" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "credentialId" TEXT NOT NULL, + "publicKey" BYTEA NOT NULL, + "counter" BIGINT NOT NULL DEFAULT 0, + "transports" TEXT[] DEFAULT ARRAY[]::TEXT[], + "name" TEXT NOT NULL, + "geraeteArt" TEXT, + "gesichert" BOOLEAN NOT NULL DEFAULT false, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zuletztBenutztAm" TIMESTAMP(3), + + CONSTRAINT "passkey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "passkey_anfrage" ( + "id" TEXT NOT NULL, + "challenge" TEXT NOT NULL, + "art" "PasskeyAnfrageArt" NOT NULL, + "userId" TEXT, + "ablaufAm" TIMESTAMP(3) NOT NULL, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "passkey_anfrage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "passkey_credentialId_key" ON "passkey"("credentialId"); + +-- CreateIndex +CREATE INDEX "passkey_userId_idx" ON "passkey"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "passkey_anfrage_challenge_key" ON "passkey_anfrage"("challenge"); + +-- CreateIndex +CREATE INDEX "passkey_anfrage_ablaufAm_idx" ON "passkey_anfrage"("ablaufAm"); + +-- AddForeignKey +ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 96485cd..2cd5a75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/app/(app)/bestaetigen/formular.tsx b/src/app/(app)/bestaetigen/formular.tsx index 7418e36..690e9c1 100644 --- a/src/app/(app)/bestaetigen/formular.tsx +++ b/src/app/(app)/bestaetigen/formular.tsx @@ -1,9 +1,16 @@ 'use client' -import { useActionState } from 'react' +import { useActionState, useEffect, useState } from 'react' import { useFormStatus } from 'react-dom' -import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { AlertCircle, Fingerprint, Loader2, ShieldCheck } from 'lucide-react' import { anmeldungBestaetigen, type BestaetigenZustand } from '@/server/actions/bestaetigen' +import { passkeyBestaetigen, passkeyBestaetigungStarten } from '@/server/actions/passkey' +import { + browserSupportsWebAuthn, + passkeyBrowserfehler, + startAuthentication, +} from '@/lib/passkey-browser' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -19,36 +26,133 @@ function Absenden() { ) } -export function BestaetigenFormular({ weiter }: { weiter: string }) { +export function BestaetigenFormular({ + weiter, + passkeysMoeglich, +}: { + weiter: string + passkeysMoeglich: boolean +}) { const [zustand, aktion] = useActionState(anmeldungBestaetigen, {}) return ( -
- +
+ + - {zustand.fehler && ( + {zustand.fehler && ( + + + )} + + + + + + + + + + + + + {passkeysMoeglich && } +
+ ) +} + +/** + * Bestaetigung mit Passkey statt mit Passwort und Code. + * + * Geprueft wird zusaetzlich, dass der Passkey zum angemeldeten Benutzer + * gehoert - sonst koennte jemand mit dem eigenen Finger eine fremde, + * offen stehende Sitzung freischalten. Genau der Fall, gegen den diese + * Seite gebaut ist. + */ +function PasskeyBestaetigung({ weiter }: { weiter: string }) { + const router = useRouter() + const [browserKann, setBrowserKann] = useState(false) + const [laeuft, setLaeuft] = useState(false) + const [fehler, setFehler] = useState(null) + + useEffect(() => { + setBrowserKann(browserSupportsWebAuthn()) + }, []) + + async function bestaetigen() { + setFehler(null) + setLaeuft(true) + + try { + const start = await passkeyBestaetigungStarten() + if (!start.ok) { + setFehler(start.fehler) + return + } + + const antwort = await startAuthentication({ optionsJSON: start.optionen }) + const ergebnis = await passkeyBestaetigen(JSON.stringify(antwort)) + + if (!ergebnis.ok) { + setFehler(ergebnis.fehler) + return + } + + router.replace(weiter) + router.refresh() + } catch (ausnahme) { + setFehler(passkeyBrowserfehler(ausnahme)) + } finally { + setLaeuft(false) + } + } + + if (!browserKann) return null + + return ( +
+
+ + oder + +
+ + {fehler && ( )} - - - - - - - - - - + +
) } diff --git a/src/app/(app)/bestaetigen/page.tsx b/src/app/(app)/bestaetigen/page.tsx index a92fde9..59322d0 100644 --- a/src/app/(app)/bestaetigen/page.tsx +++ b/src/app/(app)/bestaetigen/page.tsx @@ -3,6 +3,7 @@ import { ShieldAlert } from 'lucide-react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Alert, AlertDescription } from '@/components/ui/alert' import { erfordereBenutzer } from '@/lib/auth/sitzung' +import { passkeysMoeglich } from '@/lib/auth/passkey' import { BestaetigenFormular } from './formular' export const metadata: Metadata = { title: 'Bestätigen' } @@ -26,7 +27,7 @@ export default async function BestaetigenSeite({ - + diff --git a/src/app/(app)/konto/page.tsx b/src/app/(app)/konto/page.tsx index e863cce..70bac3d 100644 --- a/src/app/(app)/konto/page.tsx +++ b/src/app/(app)/konto/page.tsx @@ -1,11 +1,13 @@ import type { Metadata } from 'next' import Link from 'next/link' -import { KeyRound, ShieldCheck } from 'lucide-react' +import { Fingerprint, KeyRound, ShieldCheck } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { SeitenKopf } from '@/components/seiten-kopf' import { erfordereBenutzer } from '@/lib/auth/sitzung' +import { passkeysDesBenutzers, passkeysMoeglich } from '@/lib/auth/passkey' +import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung' import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen' import { formatDatumZeit } from '@/lib/utils' import { PasswortFormular } from './passwort-formular' @@ -16,10 +18,11 @@ export const dynamic = 'force-dynamic' export default async function KontoSeite() { const benutzer = await erfordereBenutzer() const codesUebrig = benutzer.wiederherstellungscodes.length + const passkeys = await passkeysDesBenutzers(benutzer.id) return (
- + @@ -89,6 +92,28 @@ export default async function KontoSeite() { + + + + + + Anmelden mit Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail, Passwort und Code. + Ein Passkey gilt als zweiter Faktor, weil er Besitz des Geräts und dessen Entsperrung + verlangt. Er ist an diese Adresse gebunden: Auf einer nachgebauten Anmeldeseite + funktioniert er nicht. + + + + + + + diff --git a/src/app/(app)/protokoll/page.tsx b/src/app/(app)/protokoll/page.tsx index 67bec95..f68fb51 100644 --- a/src/app/(app)/protokoll/page.tsx +++ b/src/app/(app)/protokoll/page.tsx @@ -34,6 +34,8 @@ const AKTION_BESCHRIFTUNG: Record = { ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen', ABMELDUNG: 'abgemeldet', ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet', + PASSKEY_REGISTRIERT: 'Passkey eingerichtet', + PASSKEY_ENTFERNT: 'Passkey entfernt', PASSWORT_GEAENDERT: 'Passwort geändert', ROLLE_GEAENDERT: 'Rolle geändert', FREIGABE_GEAENDERT: 'Freigabe geändert', @@ -41,7 +43,8 @@ const AKTION_BESCHRIFTUNG: Record = { function variante(aktion: AuditAktion) { if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot' - if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT') return 'orange' + if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT' || aktion === 'PASSKEY_ENTFERNT') + return 'orange' if (aktion === 'ERSTELLT') return 'gruen' return 'secondary' } diff --git a/src/app/(auth)/anmelden/page.tsx b/src/app/(auth)/anmelden/page.tsx index 1a94be9..ed5a677 100644 --- a/src/app/(auth)/anmelden/page.tsx +++ b/src/app/(auth)/anmelden/page.tsx @@ -4,6 +4,8 @@ import { ShieldCheck } from 'lucide-react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { aktuellerBenutzer } from '@/lib/auth/sitzung' import { db } from '@/lib/db' +import { passkeysMoeglich } from '@/lib/auth/passkey' +import { PasskeyAnmeldung } from '@/components/passkey-anmeldung' import { AnmeldeFormular } from './formular' export const metadata: Metadata = { title: 'Anmelden' } @@ -32,10 +34,14 @@ export default async function AnmeldenSeite({ Anmelden E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen. + {passkeysMoeglich() && ' Oder in einem Schritt mit deinem Passkey.'} - + + {/* Nur wenn der Server ueber https laeuft - sonst gibt der + Browser WebAuthn gar nicht frei. */} + {passkeysMoeglich() && } @@ -43,7 +49,9 @@ export default async function AnmeldenSeite({
diff --git a/src/auth.ts b/src/auth.ts index f8504a7..d70aecd 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -12,6 +12,8 @@ import { versuchFesthalten, } from '@/lib/auth/ratelimit' import { protokollieren } from '@/lib/audit' +import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey' +import type { AuthenticationResponseJSON } from '@simplewebauthn/server' /** * Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt. @@ -30,6 +32,8 @@ export type AnmeldeGrund = | 'gesperrt' | 'konto-inaktiv' | 'code-falsch' + /** Passkey-Fehler; der genaue Grund haengt mit Doppelpunkt daran. */ + | 'passkey' /** * Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code". @@ -45,6 +49,19 @@ export class AnmeldeFehler extends CredentialsSignin { } } +/** + * Wie AnmeldeFehler, nur fuer Passkeys: der Grund wird als "passkey:" + * kodiert, damit die Anmeldeseite die passende Erklaerung zeigen kann. + */ +export class PasskeyAnmeldeFehler extends CredentialsSignin { + code: string + + constructor(grund: string) { + super(grund) + this.code = `passkey:${grund}` + } +} + function ipAusAnfrage(anfrage: Request | undefined): string | null { if (!anfrage) return null const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim() @@ -203,6 +220,101 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ } }, }), + + /** + * Anmeldung mit Passkey - ein Schritt, ohne E-Mail und ohne Passwort. + * + * Die vollstaendige Pruefung laeuft hier drin und nicht in der Server + * Action davor. Das ist Absicht: Wuerde die Action pruefen und das + * Ergebnis anschliessend an signIn() weiterreichen, gaebe es einen + * Zwischenzustand, dem authorize() glauben muesste. Genau solche + * "war schon geprueft"-Uebergaben sind die Stelle, an der sich + * Anmeldungen faelschen lassen. Hier gibt es sie nicht. + */ + Credentials({ + id: 'passkey', + name: 'Passkey', + credentials: { + antwort: { label: 'Antwort des Authenticators', type: 'text' }, + }, + + async authorize(daten, anfrage) { + const ip = ipAusAnfrage(anfrage) + + let antwort: AuthenticationResponseJSON + try { + antwort = JSON.parse(String(daten?.antwort ?? '')) as AuthenticationResponseJSON + } catch { + throw new PasskeyAnmeldeFehler('ungueltig') + } + + let geprueft + try { + geprueft = await anmeldungPruefen(antwort) + } catch (fehler) { + // Fehlversuche zaehlen mit, auch wenn der Zugang unbekannt ist - + // sonst waere der Passkey-Weg ein Schlupfloch an der Sperre vorbei. + await versuchFesthalten('(passkey)', ip, false, 'passkey-ungueltig') + await protokollieren({ + aktion: 'ANMELDUNG_FEHLGESCHLAGEN', + entitaet: 'User', + userEmail: '(passkey)', + bezeichnung: + fehler instanceof PasskeyFehler ? `Passkey: ${fehler.grund}` : 'Passkey abgewiesen', + }) + throw new PasskeyAnmeldeFehler( + fehler instanceof PasskeyFehler ? fehler.grund : 'ungueltig', + ) + } + + const { benutzer, passkey } = geprueft + + // Die Sperre gilt fuer den Zugang, nicht fuer das Verfahren. Sie + // wird erst hier geprueft, weil vorher niemand weiss, wer sich + // anmeldet - erraten laesst sich ein Passkey ohnehin nicht. + const sperre = await anmeldungErlaubt(benutzer.email, ip) + if (sperre.gesperrt) { + await versuchFesthalten(benutzer.email, ip, false, 'gesperrt') + throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) + } + + if (!benutzer.aktiv || benutzer.geloeschtAm) { + await versuchFesthalten(benutzer.email, ip, false, 'konto-inaktiv') + throw new AnmeldeFehler('konto-inaktiv') + } + + await Promise.all([ + versuchFesthalten(benutzer.email, ip, true, 'passkey'), + fehlversucheZuruecksetzen(benutzer.email), + db.user.update({ + where: { id: benutzer.id }, + data: { letzteAnmeldung: new Date() }, + }), + protokollieren({ + aktion: 'ANMELDUNG', + entitaet: 'User', + entitaetId: benutzer.id, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + bezeichnung: `Mit Passkey „${passkey.name}“`, + }), + ]) + + return { + id: benutzer.id, + email: benutzer.email, + name: benutzer.name, + rolle: benutzer.rolle, + sitzungsGeneration: benutzer.sitzungsGeneration, + // Ein Passkey mit Nutzerverifikation IST der zweite Faktor. Ohne + // eingerichtetes TOTP laesst er sich gar nicht erst anlegen, das + // Flag ist hier also immer wahr - es wird trotzdem aus dem + // Benutzer gelesen und nicht fest gesetzt. + zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm), + } + }, + }), ], callbacks: { diff --git a/src/components/konto/passkey-verwaltung.tsx b/src/components/konto/passkey-verwaltung.tsx new file mode 100644 index 0000000..b359e4f --- /dev/null +++ b/src/components/konto/passkey-verwaltung.tsx @@ -0,0 +1,277 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { + AlertCircle, + CheckCircle2, + Fingerprint, + Loader2, + Plus, + Smartphone, + Trash2, +} from 'lucide-react' +import { + passkeyEinrichtungAbschliessen, + passkeyEinrichtungStarten, + passkeyEntfernen, +} from '@/server/actions/passkey' +import { + browserSupportsWebAuthn, + passkeyBrowserfehler, + startRegistration, +} from '@/lib/passkey-browser' +import type { PasskeyUebersicht } from '@/lib/auth/passkey' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Feld } from '@/components/feld' +import { formatDatum, formatDatumZeit } from '@/lib/utils' + +/** + * Verwaltung der eigenen Passkeys unter "Mein Konto". + * + * Die Liste kommt vom Server; nach jeder Aenderung wird die Seite neu + * geladen (router.refresh) statt den Zustand hier mitzufuehren. Bei einer + * Handvoll Eintraegen ist das die ehrlichere Loesung: Was auf dem Bildschirm + * steht, steht auch so in der Datenbank. + */ +export function PasskeyVerwaltung({ + passkeys, + zweiterFaktorAktiv, + moeglich, +}: { + passkeys: PasskeyUebersicht[] + zweiterFaktorAktiv: boolean + /** Ob der Server Passkeys ueberhaupt anbieten kann (https). */ + moeglich: boolean +}) { + const router = useRouter() + const [browserKann, setBrowserKann] = useState(false) + const [name, setName] = useState('') + const [laeuft, setLaeuft] = useState(false) + const [entfernt, setEntfernt] = useState(null) + const [fehler, setFehler] = useState(null) + const [erfolg, setErfolg] = useState(null) + const [nachfrage, setNachfrage] = useState(null) + + useEffect(() => { + setBrowserKann(browserSupportsWebAuthn()) + }, []) + + async function einrichten() { + setFehler(null) + setErfolg(null) + + if (name.trim() === '') { + setFehler('Bitte gib dem Gerät zuerst einen Namen.') + return + } + + setLaeuft(true) + try { + const start = await passkeyEinrichtungStarten() + if (!start.ok) { + setFehler(start.fehler) + return + } + + const antwort = await startRegistration({ optionsJSON: start.optionen }) + const ergebnis = await passkeyEinrichtungAbschliessen(name, JSON.stringify(antwort)) + + if (!ergebnis.ok) { + setFehler(ergebnis.fehler) + return + } + + setName('') + setErfolg(ergebnis.meldung ?? 'Passkey eingerichtet.') + router.refresh() + } catch (ausnahme) { + setFehler(passkeyBrowserfehler(ausnahme, true)) + } finally { + setLaeuft(false) + } + } + + async function entfernen(id: string) { + setFehler(null) + setErfolg(null) + setNachfrage(null) + setEntfernt(id) + + try { + const ergebnis = await passkeyEntfernen(id) + if (!ergebnis.ok) { + setFehler(ergebnis.fehler) + return + } + setErfolg(ergebnis.meldung ?? 'Passkey entfernt.') + router.refresh() + } finally { + setEntfernt(null) + } + } + + if (!moeglich) { + return ( + + + ) + } + + if (!zweiterFaktorAktiv) { + return ( + + + ) + } + + return ( +
+ {fehler && ( + + + )} + + {erfolg && ( + + + )} + + {passkeys.length === 0 ? ( +

+ Noch kein Passkey eingerichtet. Du meldest dich weiterhin mit E-Mail, Passwort und Code an + — das bleibt auch danach möglich. +

+ ) : ( +
    + {passkeys.map((passkey) => ( +
  • +
  • + ))} +
+ )} + + {browserKann ? ( +
+ + setName(ereignis.target.value)} + maxLength={60} + placeholder="iPhone" + autoComplete="off" + /> + + + +
+ ) : ( + + + )} +
+ ) +} diff --git a/src/components/passkey-anmeldung.tsx b/src/components/passkey-anmeldung.tsx new file mode 100644 index 0000000..dffbf6f --- /dev/null +++ b/src/components/passkey-anmeldung.tsx @@ -0,0 +1,104 @@ +'use client' + +import { useEffect, useState } from 'react' +import { AlertCircle, Fingerprint, Loader2 } from 'lucide-react' +import { passkeyAnmeldungAbschliessen, passkeyAnmeldungStarten } from '@/server/actions/passkey' +import { + browserSupportsWebAuthn, + passkeyBrowserfehler, + startAuthentication, +} from '@/lib/passkey-browser' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' + +/** + * Knopf "Mit Passkey anmelden" auf der Anmeldeseite. + * + * Der Ablauf ist dreiteilig: Optionen vom Server holen, das Geraet fragen, + * die Antwort zurueckschicken. Gelingt der letzte Schritt, leitet die Server + * Action weiter - dieser Baustein sieht das Ergebnis dann gar nicht mehr. + * + * Sichtbar ist der Knopf nur, wenn der Server ihn anbietet (https) UND der + * Browser WebAuthn kann. Die zweite Pruefung laeuft erst nach dem ersten + * Rendern: Auf dem Server gibt es kein navigator-Objekt, und ein Knopf, der + * beim Laden verschwindet, ist unangenehmer als einer, der kurz spaeter + * erscheint. + */ +export function PasskeyAnmeldung({ weiter }: { weiter: string }) { + const [laeuft, setLaeuft] = useState(false) + const [fehler, setFehler] = useState(null) + const [browserKann, setBrowserKann] = useState(false) + + useEffect(() => { + setBrowserKann(browserSupportsWebAuthn()) + }, []) + + async function anmelden() { + setFehler(null) + setLaeuft(true) + + try { + const start = await passkeyAnmeldungStarten() + if (!start.ok) { + setFehler(start.fehler) + return + } + + const antwort = await startAuthentication({ + optionsJSON: start.optionen, + }) + + const ergebnis = await passkeyAnmeldungAbschliessen(JSON.stringify(antwort), weiter) + // Bei Erfolg wird gar nichts zurueckgegeben - die Action leitet weiter. + if (!ergebnis.ok) setFehler(ergebnis.fehler) + } catch (ausnahme) { + setFehler(passkeyBrowserfehler(ausnahme)) + } finally { + setLaeuft(false) + } + } + + if (!browserKann) return null + + return ( +
+
+ + oder + +
+ + {fehler && ( + + + )} + + + +

+ Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail und Passwort. +

+
+ ) +} diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts new file mode 100644 index 0000000..5c23190 --- /dev/null +++ b/src/lib/auth/passkey.ts @@ -0,0 +1,464 @@ +import 'server-only' +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from '@simplewebauthn/server' +import type { + AuthenticationResponseJSON, + AuthenticatorTransportFuture, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/server' +import type { Passkey, PasskeyAnfrageArt, User } from '@prisma/client' +import { db } from '@/lib/db' +import { env } from '@/lib/env' + +/** + * Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg. + * + * --------------------------------------------------------------------------- + * Warum ein Passkey hier den zweiten Faktor ERSETZEN darf + * --------------------------------------------------------------------------- + * + * Die Zusage dieser Anwendung lautet: zwei Faktoren, nicht abschaltbar. Ein + * Passkey sieht auf den ersten Blick nach einem einzigen Klick aus - er ist + * aber zwei Faktoren, sofern der Authenticator die Nutzerverifikation + * durchfuehrt: + * + * 1. Besitz - der private Schluessel liegt im Sicherheitsbaustein des + * Geraets und laesst sich nicht auslesen. + * 2. Wissen - Geraete-PIN + * oder Sein - Fingerabdruck, Gesicht. + * + * Diese Bedingung wird nicht gehofft, sondern erzwungen: bei der Anmeldung + * ebenso wie beim Einrichten steht userVerification auf "required", und die + * Pruefung laeuft mit requireUserVerification. Ein Authenticator, der nur + * Anwesenheit meldet (blosses Antippen), wird abgewiesen. + * + * Zusaetzlich ist ein Passkey dem Zahlenkomma aus Passwort + TOTP in einem + * Punkt ueberlegen: Er ist an die Domain gebunden. Eine nachgebaute + * Anmeldeseite bekommt keine Signatur - Phishing, der haeufigste Weg an + * einen TOTP-Code zu kommen, laeuft ins Leere. + * + * --------------------------------------------------------------------------- + * Warum TOTP trotzdem zuerst eingerichtet sein muss + * --------------------------------------------------------------------------- + * + * Ein Passkey haengt am Geraet. Geht das Handy verloren und ist es der + * einzige Zugangsweg, ist die Akte zu - und in ihr stehen Unterlagen, die + * eine betreute Person braucht. Deshalb laesst sich ein Passkey erst + * anlegen, wenn der zweite Faktor samt Wiederherstellungscodes steht. Der + * Passkey ist die bequeme Tuer, nicht die einzige. + */ + +/** + * Wie lange eine begonnene Passkey-Aktion abgeschlossen werden kann. + * + * Fuenf Minuten sind reichlich fuer "Handy suchen, Finger auflegen" und + * kurz genug, dass eine abgefangene Challenge nichts mehr wert ist. + */ +export const ANFRAGE_GUELTIG_SEKUNDEN = 300 + +/** + * Obergrenze je Benutzer. Nicht aus technischen Gruenden - die Liste soll + * uebersichtlich bleiben, damit ein fremder Eintrag auffaellt. + */ +export const MAX_PASSKEYS_JE_BENUTZER = 10 + +/** + * Obergrenze fuer gleichzeitig offene Anmeldeversuche. + * + * Eine Anmeldung darf jeder beginnen, auch ohne angemeldet zu sein - anders + * ginge es nicht. Damit daraus kein Weg wird, die Tabelle vollzuschreiben, + * gilt eine Decke. Fuer eine Handvoll Benutzer ist sie unerreichbar; fuer + * ein Skript ist sie schnell erreicht, und dann ist Schluss. + */ +export const MAX_OFFENE_ANMELDUNGEN = 200 + +export type PasskeyGrund = + | 'nicht-verfuegbar' + | 'anfrage-abgelaufen' + | 'unbekannt' + | 'nicht-verifiziert' + | 'geklont' + | 'bereits-vorhanden' + | 'zu-viele' + | 'zweiter-faktor-fehlt' + | 'zu-viele-anfragen' + | 'ungueltig' + +/** Traegt einen Grund, damit die Oberflaeche einen brauchbaren Satz daraus macht. */ +export class PasskeyFehler extends Error { + constructor( + readonly grund: PasskeyGrund, + nachricht?: string, + ) { + super(nachricht ?? grund) + this.name = 'PasskeyFehler' + } +} + +export const PASSKEY_MELDUNG: Record = { + 'nicht-verfuegbar': + 'Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation läuft nicht über https — ' + + 'melde dich mit Passwort und Code an.', + 'anfrage-abgelaufen': + 'Das hat zu lange gedauert. Bitte klicke noch einmal auf „Mit Passkey anmelden“.', + unbekannt: + 'Dieser Passkey ist hier nicht hinterlegt. Vielleicht gehört er zu einer anderen Adresse ' + + 'oder wurde inzwischen entfernt.', + 'nicht-verifiziert': + 'Dein Gerät hat den Passkey ohne Fingerabdruck, Gesichtserkennung oder PIN freigegeben. ' + + 'Das wäre nur ein Faktor — und hier liegen Gesundheitsdaten. Bitte richte die Gerätesperre ein.', + geklont: + 'Der Zähler dieses Passkeys ist nicht weitergelaufen. Das kann auf eine Kopie hindeuten. ' + + 'Der Passkey wurde nicht akzeptiert — bitte entferne ihn und richte einen neuen ein.', + 'bereits-vorhanden': 'Dieser Passkey ist bereits hinterlegt.', + 'zu-viele': `Es sind höchstens ${MAX_PASSKEYS_JE_BENUTZER} Passkeys möglich. Entferne zuerst einen alten.`, + 'zweiter-faktor-fehlt': + 'Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ohne einen ' + + 'zweiten Weg wäre der Zugang bei Verlust zu.', + 'zu-viele-anfragen': + 'Gerade laufen ungewöhnlich viele Anmeldeversuche. Bitte versuche es in ein paar Minuten ' + + 'noch einmal oder melde dich mit Passwort und Code an.', + ungueltig: 'Der Passkey konnte nicht geprüft werden. Bitte versuche es noch einmal.', +} + +/** + * Kopiert Bytes in ein Uint8Array mit eigenem, nicht geteiltem Puffer. + * + * Prisma liefert einen Buffer, TextEncoder ein Uint8Array - + * die Bibliothek verlangt ausdruecklich Uint8Array. Der + * Unterschied ist nicht kosmetisch: ArrayBufferLike schliesst + * SharedArrayBuffer ein, und auf geteiltem Speicher darf keine + * Signaturpruefung arbeiten, weil sich der Inhalt waehrenddessen aendern + * koennte. + */ +function alsUint8(daten: Uint8Array): Uint8Array { + const kopie = new Uint8Array(new ArrayBuffer(daten.byteLength)) + kopie.set(daten) + return kopie +} + +/** + * Holt die Challenge aus der Antwort des Browsers. + * + * Sie steckt in den clientData, die der Authenticator mitsigniert hat. Wir + * lesen sie hier nur, um die passende Zeile zu finden - geprueft wird sie + * anschliessend von der Bibliothek gegen genau diesen Wert. + * + * Exportiert, damit sie sich ohne Datenbank pruefen laesst. + */ +export function challengeAusAntwort(clientDataJSON: string): string { + try { + const roh = Buffer.from(clientDataJSON, 'base64url').toString('utf8') + const gelesen: unknown = JSON.parse(roh) + const challenge = + typeof gelesen === 'object' && gelesen !== null && 'challenge' in gelesen + ? (gelesen as { challenge: unknown }).challenge + : null + if (typeof challenge !== 'string' || challenge.length === 0) { + throw new PasskeyFehler('ungueltig') + } + return challenge + } catch (fehler) { + if (fehler instanceof PasskeyFehler) throw fehler + throw new PasskeyFehler('ungueltig') + } +} + +/** Legt eine Challenge ab. Nur der Server kennt sie, bis der Browser antwortet. */ +async function anfrageMerken( + challenge: string, + art: PasskeyAnfrageArt, + userId: string | null, +): Promise { + await db.passkeyAnfrage.create({ + data: { + challenge, + art, + userId, + ablaufAm: new Date(Date.now() + ANFRAGE_GUELTIG_SEKUNDEN * 1000), + }, + }) +} + +/** + * Verbraucht eine Challenge - und zwar genau einmal. + * + * Das DELETE ist der eigentliche Schutz gegen das erneute Einspielen einer + * mitgeschnittenen Antwort: Die Datenbank entscheidet, wer die Zeile + * bekommt. Wurde keine Zeile geloescht, war sie schon verbraucht oder + * abgelaufen - beides fuehrt zum Abbruch. + */ +async function anfrageVerbrauchen( + challenge: string, + art: PasskeyAnfrageArt, + userId?: string, +): Promise { + const { count } = await db.passkeyAnfrage.deleteMany({ + where: { + challenge, + art, + ablaufAm: { gt: new Date() }, + ...(userId ? { userId } : {}), + }, + }) + if (count !== 1) throw new PasskeyFehler('anfrage-abgelaufen') +} + +/** Wirft, wenn die Installation gar nicht ueber https erreichbar ist. */ +function verfuegbarkeitPruefen(): void { + if (!env.passkey.sicherAufrufbar) throw new PasskeyFehler('nicht-verfuegbar') +} + +/** Ob Passkeys unter der aktuellen Adresse ueberhaupt angeboten werden koennen. */ +export function passkeysMoeglich(): boolean { + return env.passkey.sicherAufrufbar +} + +// --------------------------------------------------------------------------- +// Einrichten +// --------------------------------------------------------------------------- + +export async function registrierungVorbereiten( + benutzer: User, +): Promise { + verfuegbarkeitPruefen() + + // Ohne Authenticator-App kein Passkey - siehe Kopfkommentar. + if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt') + + const vorhandene = await db.passkey.findMany({ + where: { userId: benutzer.id }, + select: { credentialId: true, transports: true }, + }) + + if (vorhandene.length >= MAX_PASSKEYS_JE_BENUTZER) throw new PasskeyFehler('zu-viele') + + const optionen = await generateRegistrationOptions({ + rpName: env.passkey.rpName, + rpID: env.passkey.rpId, + // Stabil aus der Benutzer-ID. Damit erkennt ein Geraet beim zweiten + // Einrichten, dass es derselbe Zugang ist, und ersetzt den Eintrag, + // statt einen zweiten anzulegen. + userID: alsUint8(new TextEncoder().encode(benutzer.id)), + userName: benutzer.email, + userDisplayName: benutzer.name, + // Keine Attestierung anfordern: Wir wollen nicht wissen, welches + // Fabrikat der Benutzer benutzt. Das ist ein Datum, das uns nichts + // angeht, und es macht den Ablauf auf manchen Geraeten umstaendlicher. + attestationType: 'none', + // Schon hinterlegte Schluessel ausschliessen, sonst legt dasselbe + // Geraet stillschweigend einen zweiten an. + excludeCredentials: vorhandene.map((eintrag) => ({ + id: eintrag.credentialId, + transports: eintrag.transports as AuthenticatorTransportFuture[], + })), + authenticatorSelection: { + // "required": der Schluessel wird auf dem Geraet auffindbar + // gespeichert. Nur dann geht die Anmeldung ohne vorherige Eingabe + // der E-Mail-Adresse. + residentKey: 'required', + requireResidentKey: true, + // Der Punkt, an dem aus einem Faktor zwei werden. + userVerification: 'required', + }, + timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000, + }) + + await anfrageMerken(optionen.challenge, 'REGISTRIERUNG', benutzer.id) + return optionen +} + +export async function registrierungPruefen( + benutzer: User, + name: string, + antwort: RegistrationResponseJSON, +): Promise { + verfuegbarkeitPruefen() + if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt') + + const challenge = challengeAusAntwort(antwort.response.clientDataJSON) + await anfrageVerbrauchen(challenge, 'REGISTRIERUNG', benutzer.id) + + let ergebnis + try { + ergebnis = await verifyRegistrationResponse({ + response: antwort, + expectedChallenge: challenge, + expectedOrigin: env.passkey.herkunft, + expectedRPID: env.passkey.rpId, + requireUserVerification: true, + }) + } catch { + throw new PasskeyFehler('ungueltig') + } + + if (!ergebnis.verified) throw new PasskeyFehler('ungueltig') + + const { credential, credentialDeviceType, credentialBackedUp, userVerified } = + ergebnis.registrationInfo + + // Doppelte Absicherung: die Bibliothek prueft es bereits, aber diese + // Zusage ist zu wichtig, um sie an einer Voreinstellung haengen zu lassen. + if (!userVerified) throw new PasskeyFehler('nicht-verifiziert') + + const schonDa = await db.passkey.findUnique({ + where: { credentialId: credential.id }, + select: { id: true }, + }) + if (schonDa) throw new PasskeyFehler('bereits-vorhanden') + + return db.passkey.create({ + data: { + userId: benutzer.id, + credentialId: credential.id, + publicKey: Buffer.from(credential.publicKey), + counter: BigInt(credential.counter), + transports: credential.transports ?? [], + name, + geraeteArt: credentialDeviceType, + gesichert: credentialBackedUp, + }, + }) +} + +// --------------------------------------------------------------------------- +// Anmelden +// --------------------------------------------------------------------------- + +export async function anmeldungVorbereiten(): Promise { + verfuegbarkeitPruefen() + + // Erst aufraeumen, dann die Decke pruefen: Abgelaufenes soll nicht als + // laufender Versuch zaehlen. + await abgelaufeneAnfragenEntfernen() + const offen = await db.passkeyAnfrage.count({ where: { art: 'ANMELDUNG' } }) + if (offen >= MAX_OFFENE_ANMELDUNGEN) throw new PasskeyFehler('zu-viele-anfragen') + + const optionen = await generateAuthenticationOptions({ + rpID: env.passkey.rpId, + // Bewusst ohne allowCredentials: Der Server soll vor der Anmeldung + // nicht verraten, welche Schluessel es zu einer Adresse gibt - und der + // Benutzer soll seine E-Mail gar nicht erst eintippen muessen. Das + // Geraet weiss selbst, welchen Schluessel es fuer diese Domain hat. + userVerification: 'required', + timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000, + }) + + await anfrageMerken(optionen.challenge, 'ANMELDUNG', null) + return optionen +} + +export type PasskeyAnmeldung = { + benutzer: User + passkey: Passkey +} + +/** + * Prueft eine Anmeldeantwort vollstaendig und meldet, wem sie gehoert. + * + * Alles, was hier durchgeht, ist kryptografisch belegt: die Signatur passt + * zum hinterlegten oeffentlichen Schluessel, die Challenge war frisch und + * genau einmal gueltig, die Domain stimmt, und das Geraet hat den Benutzer + * verifiziert. + * + * Rate-Limiting und Audit-Log passieren NICHT hier, sondern beim Aufrufer - + * dort ist bekannt, ob es eine Anmeldung oder eine erneute Bestaetigung ist. + */ +export async function anmeldungPruefen( + antwort: AuthenticationResponseJSON, +): Promise { + verfuegbarkeitPruefen() + + const challenge = challengeAusAntwort(antwort.response.clientDataJSON) + await anfrageVerbrauchen(challenge, 'ANMELDUNG') + + const passkey = await db.passkey.findUnique({ + where: { credentialId: antwort.id }, + include: { user: true }, + }) + + if (!passkey) throw new PasskeyFehler('unbekannt') + + let ergebnis + try { + ergebnis = await verifyAuthenticationResponse({ + response: antwort, + expectedChallenge: challenge, + expectedOrigin: env.passkey.herkunft, + expectedRPID: env.passkey.rpId, + requireUserVerification: true, + credential: { + id: passkey.credentialId, + publicKey: alsUint8(passkey.publicKey), + counter: Number(passkey.counter), + transports: passkey.transports as AuthenticatorTransportFuture[], + }, + }) + } catch (fehler) { + // Die Bibliothek meldet einen nicht weitergelaufenen Zaehler als + // gewoehnlichen Fehler. Der verdient eine eigene Meldung: er ist der + // einzige Hinweis auf einen kopierten Schluessel, den wir bekommen. + const text = fehler instanceof Error ? fehler.message : '' + if (text.includes('counter value')) throw new PasskeyFehler('geklont') + throw new PasskeyFehler('ungueltig') + } + + if (!ergebnis.verified) throw new PasskeyFehler('ungueltig') + if (!ergebnis.authenticationInfo.userVerified) throw new PasskeyFehler('nicht-verifiziert') + + await db.passkey.update({ + where: { id: passkey.id }, + data: { + counter: BigInt(ergebnis.authenticationInfo.newCounter), + zuletztBenutztAm: new Date(), + // Beides kann sich nachtraeglich aendern, etwa wenn der Benutzer die + // Schluesselbund-Synchronisierung einschaltet. + geraeteArt: ergebnis.authenticationInfo.credentialDeviceType, + gesichert: ergebnis.authenticationInfo.credentialBackedUp, + }, + }) + + return { benutzer: passkey.user, passkey } +} + +// --------------------------------------------------------------------------- +// Verwalten und Aufraeumen +// --------------------------------------------------------------------------- + +export type PasskeyUebersicht = { + id: string + name: string + geraeteArt: string | null + gesichert: boolean + erstelltAm: Date + zuletztBenutztAm: Date | null +} + +export async function passkeysDesBenutzers(userId: string): Promise { + return db.passkey.findMany({ + where: { userId }, + orderBy: [{ zuletztBenutztAm: { sort: 'desc', nulls: 'last' } }, { erstelltAm: 'desc' }], + select: { + id: true, + name: true, + geraeteArt: true, + gesichert: true, + erstelltAm: true, + zuletztBenutztAm: true, + }, + }) +} + +/** Raeumt liegengebliebene Challenges weg; wird vom Wartungslauf aufgerufen. */ +export async function abgelaufeneAnfragenEntfernen(): Promise { + const { count } = await db.passkeyAnfrage.deleteMany({ + where: { ablaufAm: { lt: new Date() } }, + }) + return count +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 52f685b..318826e 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -185,6 +185,35 @@ export const env = { return (process.env.AUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') }, + /** + * Herkunft und Bereichskennung fuer Passkeys (WebAuthn). + * + * Beides wird aus AUTH_URL abgeleitet und nicht getrennt konfiguriert - + * eine zweite Quelle waere eine zweite Gelegenheit, sie auseinanderlaufen + * zu lassen. + * + * Die "rpId" ist der nackte Hostname ohne Schema und ohne Port. An sie + * bindet der Authenticator den Schluessel: Ein Passkey fuer + * cockpit.example.de funktioniert auf keiner anderen Adresse - genau das + * macht ihn gegen Phishing immun. Umgekehrt heisst das: Wer die Domain + * wechselt, muss alle Passkeys neu einrichten. + * + * Der Browser gibt WebAuthn ausserdem nur in einem sicheren Kontext frei - + * also unter https oder auf localhost. Hinter einem Proxy, der auf http + * weiterreicht, muss AUTH_URL trotzdem die oeffentliche https-Adresse + * nennen; verglichen wird gegen das, was im Browser in der Adresszeile + * stand. + */ + get passkey() { + const url = new URL(process.env.AUTH_URL ?? 'http://localhost:3000') + return { + rpId: url.hostname, + rpName: 'Betreuungs-Cockpit', + herkunft: url.origin, + sicherAufrufbar: url.protocol === 'https:' || url.hostname === 'localhost', + } + }, + get istProduktion() { return process.env.NODE_ENV === 'production' }, diff --git a/src/lib/passkey-browser.ts b/src/lib/passkey-browser.ts new file mode 100644 index 0000000..cb8589f --- /dev/null +++ b/src/lib/passkey-browser.ts @@ -0,0 +1,55 @@ +/** + * Browserseitige Hilfen fuer Passkeys. + * + * Bewusst ohne "server-only" und ohne Datenbankzugriff: Diese Datei laeuft + * im Browser. Die eigentliche Pruefung passiert auf dem Server + * (src/lib/auth/passkey.ts) - hier geht es nur darum, das Geraet + * anzusprechen und seine Fehler in verstaendliche Saetze zu uebersetzen. + */ + +import { + browserSupportsWebAuthn, + startAuthentication, + startRegistration, +} from '@simplewebauthn/browser' + +export { browserSupportsWebAuthn, startAuthentication, startRegistration } + +/** + * Uebersetzt die Ausnahmen der WebAuthn-Schnittstelle. + * + * Der Browser wirft DOMExceptions mit sehr knappen Namen und einer + * englischen Beschreibung, die man niemandem zeigen kann. Vor allem + * "NotAllowedError" ist irrefuehrend: Er bedeutet meistens schlicht, dass + * der Vorgang abgebrochen wurde oder die Zeit abgelaufen ist - nicht, dass + * etwas verboten waere. + */ +export function passkeyBrowserfehler(fehler: unknown, beimEinrichten = false): string { + const name = fehler instanceof Error ? fehler.name : '' + + switch (name) { + case 'NotAllowedError': + return 'Abgebrochen oder zu lange gewartet. Du kannst es gleich noch einmal versuchen.' + + case 'InvalidStateError': + return beimEinrichten + ? 'Für dieses Gerät ist hier bereits ein Passkey hinterlegt. Ein zweiter bringt nichts — ' + + 'nimm ein anderes Gerät oder entferne zuerst den vorhandenen.' + : 'Dieses Gerät kann den Passkey gerade nicht verwenden.' + + case 'SecurityError': + return ( + 'Die Adresse im Browser passt nicht zu der, für die der Passkey ausgestellt wurde. ' + + 'Das ist genau der Schutz, der Phishing verhindert — prüfe die Adresszeile.' + ) + + case 'NotSupportedError': + return 'Dieses Gerät unterstützt keine Passkeys mit Fingerabdruck, Gesicht oder PIN.' + + case 'AbortError': + return 'Abgebrochen.' + + default: + return 'Der Passkey konnte nicht verwendet werden. Bitte versuche es noch einmal.' + } +} diff --git a/src/lib/version.ts b/src/lib/version.ts index 95253f1..8c00fcc 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -17,7 +17,7 @@ */ /** Muss zur "version" in der package.json passen. */ -export const VERSION = '1.0.5' +export const VERSION = '1.1.0' /** * Kurzer Git-Stand, beim Bauen hineingereicht: diff --git a/src/lib/wartung.ts b/src/lib/wartung.ts index d1468e5..e9d5ec8 100644 --- a/src/lib/wartung.ts +++ b/src/lib/wartung.ts @@ -3,6 +3,7 @@ import { db, papierkorbGrenze } from '@/lib/db' import { env } from '@/lib/env' import { fristErinnerung, mailSenden } from '@/lib/mail' import { alteVersucheEntfernen } from '@/lib/auth/ratelimit' +import { abgelaufeneAnfragenEntfernen } from '@/lib/auth/passkey' /** * Taeglicher Wartungslauf. @@ -14,6 +15,7 @@ import { alteVersucheEntfernen } from '@/lib/auth/ratelimit' * 2. Papierkorb leeren (30 Tage) * 3. Alte Anmeldeversuche entfernen (30 Tage) * 4. Altes Audit-Log entfernen (10 Jahre, ueber die DB-Funktion) + * 5. Liegengebliebene Passkey-Challenges entfernen (5 Minuten) * * Jeder Schritt faengt seine Fehler selbst ab. Faellt die Mail aus, soll * trotzdem aufgeraeumt werden - sonst laeuft die Datenbank voll, weil ein @@ -25,6 +27,7 @@ export type Wartungsergebnis = { papierkorbGeleert: number loginversucheEntfernt: number auditEintraegeEntfernt: number + passkeyAnfragenEntfernt: number fehler: string[] } @@ -56,6 +59,7 @@ export async function wartungslauf(): Promise { papierkorbGeleert: 0, loginversucheEntfernt: 0, auditEintraegeEntfernt: 0, + passkeyAnfragenEntfernt: 0, fehler: [], } @@ -93,6 +97,15 @@ export async function wartungslauf(): Promise { ergebnis.fehler.push(`Audit-Log: ${beschreiben(fehler)}`) } + // --- 5. Passkey-Challenges ---------------------------------------------- + try { + // Normalerweise raeumt jede Anmeldung selbst auf. Bleibt der letzte + // Versuch des Tages liegen, faengt es dieser Lauf ab. + ergebnis.passkeyAnfragenEntfernt = await abgelaufeneAnfragenEntfernen() + } catch (fehler) { + ergebnis.fehler.push(`Passkey-Anfragen: ${beschreiben(fehler)}`) + } + return ergebnis } diff --git a/src/server/actions/passkey.ts b/src/server/actions/passkey.ts new file mode 100644 index 0000000..41f3919 --- /dev/null +++ b/src/server/actions/passkey.ts @@ -0,0 +1,313 @@ +'use server' + +import { AuthError } from 'next-auth' +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import type { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/server' +import { signIn } from '@/auth' +import { db } from '@/lib/db' +import { protokollieren } from '@/lib/audit' +import { + PASSKEY_MELDUNG, + PasskeyFehler, + anmeldungPruefen, + anmeldungVorbereiten, + registrierungPruefen, + registrierungVorbereiten, +} from '@/lib/auth/passkey' +import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung' + +/** + * Server Actions rund um Passkeys. + * + * Ein Passkey-Vorgang besteht immer aus zwei Aufrufen: erst holt der Browser + * die Optionen samt Challenge, dann schickt er die Antwort des Geraets + * zurueck. Dazwischen liegt der Griff zum Fingerabdrucksensor, und der + * dauert unbestimmt lange - deshalb zwei Schritte und keine Sitzungsvariable. + * + * Die Antworten des Authenticators kommen als JSON-Zeichenkette herein, nicht + * als Objekt. Grund: Was ueber die Grenze zum Server geht, wird hier + * ausdruecklich geparst und geprueft, statt sich auf die Serialisierung von + * React zu verlassen. + */ + +/** Ergebnis eines Schritts, der Optionen liefert. */ +export type PasskeyStart = { ok: true; optionen: T } | { ok: false; fehler: string } + +/** Ergebnis eines abschliessenden Schritts. */ +export type PasskeyErgebnis = { ok: true; meldung?: string } | { ok: false; fehler: string } + +function alsFehler(fehler: unknown): { ok: false; fehler: string } { + if (fehler instanceof PasskeyFehler) { + return { ok: false, fehler: PASSKEY_MELDUNG[fehler.grund] } + } + console.error('[passkey] Unerwarteter Fehler:', fehler) + return { ok: false, fehler: PASSKEY_MELDUNG.ungueltig } +} + +/** + * Parst die Antwort des Browsers. + * + * Absichtlich nur eine grobe Formpruefung: Was zaehlt, prueft anschliessend + * die Bibliothek kryptografisch. Hier geht es nur darum, dass kein + * offensichtlicher Unsinn bis dorthin durchlaeuft. + */ +const AntwortSchema = z.object({ + id: z.string().min(1), + rawId: z.string().min(1), + type: z.literal('public-key'), + response: z.object({ clientDataJSON: z.string().min(1) }).passthrough(), +}) + +function antwortLesen(roh: string): T { + const gelesen = AntwortSchema.safeParse(JSON.parse(roh)) + if (!gelesen.success) throw new PasskeyFehler('ungueltig') + return gelesen.data as T +} + +// --------------------------------------------------------------------------- +// Einrichten (angemeldet, unter "Mein Konto") +// --------------------------------------------------------------------------- + +export async function passkeyEinrichtungStarten(): Promise< + PasskeyStart +> { + try { + const benutzer = await erfordereBenutzer() + return { ok: true, optionen: await registrierungVorbereiten(benutzer) } + } catch (fehler) { + return alsFehler(fehler) + } +} + +const NameSchema = z + .string() + .trim() + .min(1, 'Bitte gib dem Gerät einen Namen.') + .max(60, 'Bitte höchstens 60 Zeichen.') + +export async function passkeyEinrichtungAbschliessen( + name: string, + antwortJson: string, +): Promise { + try { + const benutzer = await erfordereBenutzer() + + const geprueft = NameSchema.safeParse(name) + if (!geprueft.success) { + return { + ok: false, + fehler: geprueft.error.issues[0]?.message ?? 'Der Name passt nicht.', + } + } + + const antwort = antwortLesen(antwortJson) + const passkey = await registrierungPruefen(benutzer, geprueft.data, antwort) + + await protokollieren({ + aktion: 'PASSKEY_REGISTRIERT', + entitaet: 'Passkey', + entitaetId: passkey.id, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + bezeichnung: passkey.name, + }) + + revalidatePath('/konto') + return { ok: true, meldung: `„${passkey.name}“ ist eingerichtet.` } + } catch (fehler) { + return alsFehler(fehler) + } +} + +// --------------------------------------------------------------------------- +// Verwalten +// --------------------------------------------------------------------------- + +export async function passkeyEntfernen(id: string): Promise { + try { + const benutzer = await erfordereBenutzer() + + // Zeilenweise Pruefung: Es genuegt nicht, dass die ID existiert - sie + // muss zu DIESEM Benutzer gehoeren. deleteMany mit userId im WHERE + // erledigt Pruefung und Loeschen in einem Zug; ein fremder Passkey + // trifft dann auf null Zeilen. + const passkey = await db.passkey.findFirst({ + where: { id, userId: benutzer.id }, + select: { id: true, name: true }, + }) + if (!passkey) + return { + ok: false, + fehler: 'Dieser Passkey gehört nicht zu deinem Zugang.', + } + + await db.passkey.delete({ where: { id: passkey.id } }) + + await protokollieren({ + aktion: 'PASSKEY_ENTFERNT', + entitaet: 'Passkey', + entitaetId: passkey.id, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + bezeichnung: passkey.name, + }) + + revalidatePath('/konto') + return { ok: true, meldung: `„${passkey.name}“ wurde entfernt.` } + } catch (fehler) { + return alsFehler(fehler) + } +} + +// --------------------------------------------------------------------------- +// Anmelden (nicht angemeldet) +// --------------------------------------------------------------------------- + +export async function passkeyAnmeldungStarten(): Promise< + PasskeyStart +> { + try { + return { ok: true, optionen: await anmeldungVorbereiten() } + } catch (fehler) { + return alsFehler(fehler) + } +} + +/** Verhindert offene Weiterleitungen auf fremde Seiten. */ +function sicheresZiel(ziel: string): string { + if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute' + return ziel +} + +/** + * Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft. + * Wie in anmelden.ts ueber das digest-Feld statt ueber einen Import aus + * next/dist - interne Pfade aendern sich zwischen Nebenversionen. + */ +function istWeiterleitung(fehler: unknown): boolean { + return ( + typeof fehler === 'object' && + fehler !== null && + 'digest' in fehler && + typeof (fehler as { digest?: unknown }).digest === 'string' && + (fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT') + ) +} + +/** + * Uebersetzt den Fehlercode aus authorize() in einen lesbaren Satz. + * "passkey:" kommt aus PasskeyAnmeldeFehler, alles andere aus dem + * gewoehnlichen Anmeldeweg. + */ +function fehlertext(code: string): string { + const [art, wert] = code.split(':') + + if (art === 'passkey') { + const grund = wert as keyof typeof PASSKEY_MELDUNG + return PASSKEY_MELDUNG[grund] ?? PASSKEY_MELDUNG.ungueltig + } + if (art === 'gesperrt') { + const minuten = Math.ceil(Number.parseInt(wert ?? '0', 10) / 60) + return ( + `Zu viele Fehlversuche. Aus Sicherheitsgründen ist die Anmeldung für ` + + `${minuten > 1 ? `etwa ${minuten} Minuten` : 'kurze Zeit'} gesperrt.` + ) + } + if (art === 'konto-inaktiv') { + return 'Dieser Zugang ist deaktiviert. Bitte wende dich an den Administrator.' + } + return PASSKEY_MELDUNG.ungueltig +} + +export async function passkeyAnmeldungAbschliessen( + antwortJson: string, + weiter: string, +): Promise { + try { + // Nur die Form pruefen; signIn() macht die eigentliche Arbeit. + antwortLesen(antwortJson) + } catch (fehler) { + return alsFehler(fehler) + } + + try { + await signIn('passkey', { + antwort: antwortJson, + redirectTo: sicheresZiel(weiter), + }) + } catch (fehler) { + // Bei Erfolg wirft signIn eine Weiterleitung - die muss durch. + if (istWeiterleitung(fehler)) throw fehler + + if (fehler instanceof AuthError) { + const code = + 'code' in fehler && typeof fehler.code === 'string' ? fehler.code : 'passkey:ungueltig' + return { ok: false, fehler: fehlertext(code) } + } + + throw fehler + } + + // Wird nicht erreicht - signIn leitet weiter. + return { ok: true } +} + +// --------------------------------------------------------------------------- +// Erneut bestaetigen (angemeldet, vor sensiblen Aktionen) +// --------------------------------------------------------------------------- + +export async function passkeyBestaetigungStarten(): Promise< + PasskeyStart +> { + try { + await erfordereBenutzer() + return { ok: true, optionen: await anmeldungVorbereiten() } + } catch (fehler) { + return alsFehler(fehler) + } +} + +/** + * Bestaetigt die laufende Sitzung mit einem Passkey. + * + * Wichtig ist die letzte Pruefung: Der Passkey muss dem angemeldeten + * Benutzer gehoeren. Sonst koennte jemand mit seinem eigenen Passkey die + * fremde Sitzung freischalten, die auf einem unbeaufsichtigten Rechner + * offen steht - genau der Fall, gegen den die erneute Bestaetigung da ist. + */ +export async function passkeyBestaetigen(antwortJson: string): Promise { + try { + const benutzer = await erfordereBenutzer() + const antwort = antwortLesen(antwortJson) + + const { benutzer: eigentuemer } = await anmeldungPruefen(antwort) + + if (eigentuemer.id !== benutzer.id) { + await protokollieren({ + aktion: 'ANMELDUNG_FEHLGESCHLAGEN', + entitaet: 'User', + entitaetId: benutzer.id, + userId: benutzer.id, + userEmail: benutzer.email, + bezeichnung: 'Bestätigung mit fremdem Passkey', + }) + return { + ok: false, + fehler: 'Dieser Passkey gehört zu einem anderen Zugang als dem gerade angemeldeten.', + } + } + + await reauthBestaetigen(benutzer.id) + return { ok: true } + } catch (fehler) { + return alsFehler(fehler) + } +} diff --git a/tests/passkey.test.ts b/tests/passkey.test.ts new file mode 100644 index 0000000..226aceb --- /dev/null +++ b/tests/passkey.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + MAX_PASSKEYS_JE_BENUTZER, + PASSKEY_MELDUNG, + PasskeyFehler, + challengeAusAntwort, + passkeysMoeglich, +} from '@/lib/auth/passkey' +import { passkeyBrowserfehler } from '@/lib/passkey-browser' +import { env } from '@/lib/env' + +/** + * Passkeys — die Teile, die sich ohne Datenbank und ohne Browser prüfen + * lassen. + * + * Der Schwerpunkt liegt auf der Herleitung von `rpId` und Herkunft aus + * `AUTH_URL`. Das ist bei WebAuthn die Stelle, an der eine Installation + * still kaputtgeht: Stimmt die Bereichskennung nicht, lässt sich zwar ein + * Passkey einrichten, aber nie wieder verwenden — und die Fehlermeldung des + * Browsers zeigt auf alles Mögliche, nur nicht auf die Konfiguration. + * + * Dieselbe Fehlerklasse hat uns bei `APP_BIND` und `ENC_KEY_V2` schon zwei + * Fassungen gekostet: eine Umgebungsvariable, die falsch ausgewertet wird, + * und ein Fehlerbild, das woandershin zeigt. + */ + +let vorher: string | undefined + +beforeEach(() => { + vorher = process.env.AUTH_URL +}) + +afterEach(() => { + if (vorher === undefined) delete process.env.AUTH_URL + else process.env.AUTH_URL = vorher +}) + +describe('Bereichskennung aus AUTH_URL', () => { + it('nimmt den nackten Hostnamen als rpId, ohne Schema und ohne Port', () => { + process.env.AUTH_URL = 'https://cockpit.example.de' + expect(env.passkey.rpId).toBe('cockpit.example.de') + expect(env.passkey.herkunft).toBe('https://cockpit.example.de') + }) + + it('lässt den Port in der Herkunft stehen, aber nicht in der rpId', () => { + // Die Herkunft wird zeichengenau mit dem verglichen, was im Browser in + // der Adresszeile stand - der Port gehoert dazu. Die rpId ist dagegen + // die Domain, an die der Schluessel gebunden wird; ein Port darf dort + // nicht auftauchen, sonst weist der Authenticator sie zurueck. + process.env.AUTH_URL = 'https://cockpit.example.de:8443' + expect(env.passkey.rpId).toBe('cockpit.example.de') + expect(env.passkey.herkunft).toBe('https://cockpit.example.de:8443') + }) + + it('verträgt einen Schrägstrich am Ende', () => { + process.env.AUTH_URL = 'https://cockpit.example.de/' + expect(env.passkey.herkunft).toBe('https://cockpit.example.de') + }) + + it('erkennt eine Unterdomain als eigene Kennung', () => { + // Ein Passkey fuer betreuung.example.de gilt NICHT fuer example.de - + // genau das ist der Phishingschutz. + process.env.AUTH_URL = 'https://betreuung.example.de' + expect(env.passkey.rpId).toBe('betreuung.example.de') + }) +}) + +describe('Verfügbarkeit', () => { + it('bietet Passkeys über https an', () => { + process.env.AUTH_URL = 'https://cockpit.example.de' + expect(env.passkey.sicherAufrufbar).toBe(true) + expect(passkeysMoeglich()).toBe(true) + }) + + it('bietet sie auf localhost an, auch ohne https', () => { + // Der Browser wertet localhost als sicheren Kontext - sonst liesse + // sich in der Entwicklung nichts ausprobieren. + process.env.AUTH_URL = 'http://localhost:3000' + expect(env.passkey.sicherAufrufbar).toBe(true) + }) + + it('bietet sie NICHT über einfaches http an', () => { + // Wichtig, dass das der Server entscheidet und nicht der Browser: Ein + // Knopf, der beim Klick eine kryptische DOMException wirft, ist + // schlechter als gar kein Knopf. + process.env.AUTH_URL = 'http://cockpit.example.de' + expect(env.passkey.sicherAufrufbar).toBe(false) + expect(passkeysMoeglich()).toBe(false) + }) + + it('bietet sie auch über eine nackte IP ohne https nicht an', () => { + process.env.AUTH_URL = 'http://10.0.0.5:3000' + expect(env.passkey.sicherAufrufbar).toBe(false) + }) +}) + +describe('Challenge aus der Antwort des Browsers', () => { + function clientData(inhalt: unknown): string { + return Buffer.from(JSON.stringify(inhalt), 'utf8').toString('base64url') + } + + it('liest die Challenge aus den clientData', () => { + const daten = clientData({ + type: 'webauthn.get', + challenge: 'AbCdEf-1234_xyz', + origin: 'https://cockpit.example.de', + }) + expect(challengeAusAntwort(daten)).toBe('AbCdEf-1234_xyz') + }) + + it('weist eine Antwort ohne Challenge ab', () => { + const daten = clientData({ type: 'webauthn.get', origin: 'https://cockpit.example.de' }) + expect(() => challengeAusAntwort(daten)).toThrowError(PasskeyFehler) + }) + + it('weist eine leere Challenge ab', () => { + expect(() => challengeAusAntwort(clientData({ challenge: '' }))).toThrowError(PasskeyFehler) + }) + + it('weist eine Challenge ab, die keine Zeichenkette ist', () => { + expect(() => challengeAusAntwort(clientData({ challenge: 42 }))).toThrowError(PasskeyFehler) + }) + + it('weist Unsinn ab, statt daran abzustürzen', () => { + expect(() => challengeAusAntwort('das ist kein base64url-JSON')).toThrowError(PasskeyFehler) + expect(() => challengeAusAntwort('')).toThrowError(PasskeyFehler) + }) +}) + +describe('Meldungen', () => { + it('hat zu jedem Grund einen Satz, den man jemandem zeigen kann', () => { + // Ein fehlender Eintrag wuerde als "undefined" auf dem Bildschirm + // landen. Der Typ verhindert das beim Uebersetzen; hier wird geprueft, + // dass auch nichts Leeres durchrutscht. + for (const [grund, text] of Object.entries(PASSKEY_MELDUNG)) { + expect(text.length, `Meldung für "${grund}"`).toBeGreaterThan(20) + expect(text, `Meldung für "${grund}"`).not.toMatch(/undefined/) + } + }) + + it('nennt in der Obergrenze die tatsächliche Zahl', () => { + expect(PASSKEY_MELDUNG['zu-viele']).toContain(String(MAX_PASSKEYS_JE_BENUTZER)) + }) + + it('erklärt bei fehlender Nutzerverifikation, warum abgelehnt wird', () => { + // Das ist die Meldung, die am ehesten nach Schikane aussieht. Sie muss + // den Grund nennen, sonst haelt man sie fuer einen Fehler. + expect(PASSKEY_MELDUNG['nicht-verifiziert']).toMatch(/Fingerabdruck|PIN/) + }) +}) + +describe('Fehler des Browsers', () => { + function dom(name: string): Error { + const fehler = new Error('irgendwas Englisches') + fehler.name = name + return fehler + } + + it('deutet NotAllowedError als Abbruch, nicht als Verbot', () => { + // Der Name ist irrefuehrend: Er kommt auch, wenn schlicht niemand den + // Finger aufgelegt hat. "Nicht erlaubt" waere die falsche Auskunft. + const text = passkeyBrowserfehler(dom('NotAllowedError')) + expect(text).toMatch(/Abgebrochen/) + expect(text).not.toMatch(/verboten|nicht erlaubt/i) + }) + + it('erklärt beim Einrichten, dass dieses Gerät schon einen Passkey hat', () => { + expect(passkeyBrowserfehler(dom('InvalidStateError'), true)).toMatch(/bereits/) + }) + + it('sagt bei SecurityError, dass die Adresse nicht passt', () => { + expect(passkeyBrowserfehler(dom('SecurityError'))).toMatch(/Adress/) + }) + + it('fällt bei unbekannten Fehlern auf einen brauchbaren Satz zurück', () => { + expect(passkeyBrowserfehler(dom('VoellingNeuerFehler'))).toMatch(/noch einmal/) + expect(passkeyBrowserfehler('gar kein Fehlerobjekt')).toMatch(/noch einmal/) + expect(passkeyBrowserfehler(undefined)).toMatch(/noch einmal/) + }) + + it('gibt nie die englische Originalmeldung weiter', () => { + for (const name of [ + 'NotAllowedError', + 'InvalidStateError', + 'SecurityError', + 'NotSupportedError', + 'AbortError', + 'Unbekannt', + ]) { + expect(passkeyBrowserfehler(dom(name))).not.toContain('irgendwas Englisches') + } + }) +})