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
+313
View File
@@ -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<T> = { 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<T>(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<PublicKeyCredentialCreationOptionsJSON>
> {
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<PasskeyErgebnis> {
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<RegistrationResponseJSON>(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<PasskeyErgebnis> {
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<PublicKeyCredentialRequestOptionsJSON>
> {
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:<grund>" 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<PasskeyErgebnis> {
try {
// Nur die Form pruefen; signIn() macht die eigentliche Arbeit.
antwortLesen<AuthenticationResponseJSON>(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<PublicKeyCredentialRequestOptionsJSON>
> {
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<PasskeyErgebnis> {
try {
const benutzer = await erfordereBenutzer()
const antwort = antwortLesen<AuthenticationResponseJSON>(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)
}
}