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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BestaetigenFormular weiter={weiter} />
|
||||
<BestaetigenFormular weiter={weiter} passkeysMoeglich={passkeysMoeglich()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten und zweiter Faktor." />
|
||||
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten, zweiter Faktor und Passkeys." />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -89,6 +92,28 @@ export default async function KontoSeite() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Fingerprint className="size-5" aria-hidden="true" />
|
||||
Passkeys
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasskeyVerwaltung
|
||||
passkeys={passkeys}
|
||||
zweiterFaktorAktiv={Boolean(benutzer.totpAktiviertAm)}
|
||||
moeglich={passkeysMoeglich()}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
|
||||
@@ -34,6 +34,8 @@ const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
|
||||
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<AuditAktion, string> = {
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<CardTitle>Anmelden</CardTitle>
|
||||
<CardDescription>
|
||||
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
|
||||
{passkeysMoeglich() && ' Oder in einem Schritt mit deinem Passkey.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-5">
|
||||
<AnmeldeFormular weiter={weiter} />
|
||||
{/* Nur wenn der Server ueber https laeuft - sonst gibt der
|
||||
Browser WebAuthn gar nicht frei. */}
|
||||
{passkeysMoeglich() && <PasskeyAnmeldung weiter={weiter} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -43,7 +49,9 @@ export default async function AnmeldenSeite({
|
||||
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>
|
||||
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
|
||||
deshalb Pflicht und lässt sich nicht abschalten.
|
||||
deshalb Pflicht und lässt sich nicht abschalten. Ein Passkey erfüllt ihn ebenfalls: Er
|
||||
verlangt Fingerabdruck, Gesicht oder Geräte-PIN — und lässt sich, anders als ein
|
||||
abgetippter Code, nicht auf einer nachgebauten Seite abgreifen.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+112
@@ -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:<grund>"
|
||||
* 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: {
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [fehler, setFehler] = useState<string | null>(null)
|
||||
const [erfolg, setErfolg] = useState<string | null>(null)
|
||||
const [nachfrage, setNachfrage] = useState<string | null>(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 (
|
||||
<Alert variant="hinweis">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation ist nicht über
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">https</code>
|
||||
erreichbar — sobald sie es ist, erscheint hier die Verwaltung.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!zweiterFaktorAktiv) {
|
||||
return (
|
||||
<Alert variant="hinweis">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
Richte zuerst die Authenticator-App ein. 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. Der Passkey
|
||||
ist die bequeme Tür, nicht die einzige.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{erfolg && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{erfolg}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passkeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch kein Passkey eingerichtet. Du meldest dich weiterhin mit E-Mail, Passwort und Code an
|
||||
— das bleibt auch danach möglich.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{passkeys.map((passkey) => (
|
||||
<li key={passkey.id} className="flex flex-wrap items-start gap-3 p-3">
|
||||
<Smartphone
|
||||
className="mt-0.5 size-5 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{passkey.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Eingerichtet am {formatDatum(passkey.erstelltAm)}
|
||||
{passkey.zuletztBenutztAm
|
||||
? ` · zuletzt benutzt ${formatDatumZeit(passkey.zuletztBenutztAm)}`
|
||||
: ' · noch nicht benutzt'}
|
||||
</p>
|
||||
{passkey.geraeteArt === 'singleDevice' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Liegt nur auf diesem Gerät. Geht es verloren, ist auch der Passkey weg.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{passkey.gesichert && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
gesichert
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{nachfrage === passkey.id ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void entfernen(passkey.id)}
|
||||
disabled={entfernt === passkey.id}
|
||||
>
|
||||
{entfernt === passkey.id ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : null}
|
||||
Wirklich entfernen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setNachfrage(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setNachfrage(passkey.id)}
|
||||
aria-label={`Passkey „${passkey.name}“ entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
Entfernen
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{browserKann ? (
|
||||
<div className="space-y-3 rounded-md border border-dashed p-3">
|
||||
<Feld
|
||||
id="passkey-name"
|
||||
beschriftung="Neues Gerät"
|
||||
erklaerung="Ein Name, an dem du es später wiedererkennst — etwa „iPhone“ oder „Laptop Büro“."
|
||||
>
|
||||
<Input
|
||||
id="passkey-name"
|
||||
value={name}
|
||||
onChange={(ereignis) => setName(ereignis.target.value)}
|
||||
maxLength={60}
|
||||
placeholder="iPhone"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Button type="button" onClick={() => void einrichten()} disabled={laeuft}>
|
||||
{laeuft ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
Warte auf das Gerät …
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus aria-hidden="true" />
|
||||
Passkey einrichten
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Alert variant="hinweis">
|
||||
<Fingerprint aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
Dieser Browser kann keine Passkeys. Auf einem anderen Gerät klappt es vermutlich — die
|
||||
bereits eingerichteten bleiben davon unberührt.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -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<PasskeyGrund, string> = {
|
||||
'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<ArrayBufferLike> -
|
||||
* die Bibliothek verlangt ausdruecklich Uint8Array<ArrayBuffer>. 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<ArrayBuffer> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<PublicKeyCredentialCreationOptionsJSON> {
|
||||
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<Passkey> {
|
||||
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<PublicKeyCredentialRequestOptionsJSON> {
|
||||
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<PasskeyAnmeldung> {
|
||||
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<PasskeyUebersicht[]> {
|
||||
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<number> {
|
||||
const { count } = await db.passkeyAnfrage.deleteMany({
|
||||
where: { ablaufAm: { lt: new Date() } },
|
||||
})
|
||||
return count
|
||||
}
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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<Wartungsergebnis> {
|
||||
papierkorbGeleert: 0,
|
||||
loginversucheEntfernt: 0,
|
||||
auditEintraegeEntfernt: 0,
|
||||
passkeyAnfragenEntfernt: 0,
|
||||
fehler: [],
|
||||
}
|
||||
|
||||
@@ -93,6 +97,15 @@ export async function wartungslauf(): Promise<Wartungsergebnis> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user