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.
105 lines
3.1 KiB
TypeScript
105 lines
3.1 KiB
TypeScript
'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<string | null>(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 (
|
|
<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>{fehler}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="lg"
|
|
className="w-full"
|
|
onClick={anmelden}
|
|
disabled={laeuft}
|
|
>
|
|
{laeuft ? (
|
|
<>
|
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
Warte auf das Gerät …
|
|
</>
|
|
) : (
|
|
<>
|
|
<Fingerprint aria-hidden="true" />
|
|
Mit Passkey anmelden
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<p className="text-center text-xs text-muted-foreground">
|
|
Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail und Passwort.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|