feat: Anmelden mit Passkey

Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite
und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter
"Mein Konto".

Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das
ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets
und dessen Entsperrung. Damit das keine Annahme bleibt, wird die
Nutzerverifikation angefordert (userVerification: 'required') und
serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen.

TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet;
waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte
schliessen.

Aufbau:
- Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey",
  nicht in der Server Action davor. Ein "war schon geprueft" ueber die
  Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen
  lassen.
- Challenges liegen serverseitig und werden per DELETE genau einmal
  verbraucht - die Datenbank entscheidet, wer sie bekommt.
- Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer
  zweiten Konfigurationsquelle.
- Ohne https wird der Knopf gar nicht erst angeboten.
- Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die
  Anmeldung nennt den verwendeten Passkey namentlich.

Zwei Anmerkungen zur Migration:
- "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus
  prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind
  entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe
  Suche stillgelegt, ohne dass etwas fehlgeschlagen waere.
- "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne
  Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine
  geloeschte.

Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums
virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen,
Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests,
null Typfehler, Produktionsbau steht.

Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46
statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man
sie nachzaehlt.
This commit is contained in:
Florian Wilde
2026-08-22 18:44:56 +02:00
parent 9603917a86
commit c38c988c3c
25 changed files with 2702 additions and 104 deletions
+128 -24
View File
@@ -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<BestaetigenZustand, FormData>(anmeldungBestaetigen, {})
return (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="weiter" value={weiter} />
<div className="space-y-5">
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="weiter" value={weiter} />
{zustand.fehler && (
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="passwort" beschriftung="Passwort" pflicht>
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
</Feld>
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
/>
</Feld>
<Absenden />
</form>
{passkeysMoeglich && <PasskeyBestaetigung weiter={weiter} />}
</div>
)
}
/**
* 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<string | null>(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 (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-muted-foreground">oder</span>
<span className="h-px flex-1 bg-border" />
</div>
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
<Feld id="passwort" beschriftung="Passwort" pflicht>
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
</Feld>
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
/>
</Feld>
<Absenden />
</form>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => void bestaetigen()}
disabled={laeuft}
>
{laeuft ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Warte auf das Gerät …
</>
) : (
<>
<Fingerprint aria-hidden="true" />
Mit Passkey bestätigen
</>
)}
</Button>
</div>
)
}