Anmeldung per Passkey (WebAuthn)
CI / pruefen (pull_request) Canceled after 0s

Ein Passkey ist der dritte Weg in denselben Trichter: finishLogin() prueft die
Antwort und gibt die Kennung zurueck, der Endpunkt legt damit ueber
createSession() dieselbe Sitzung an wie bei Passwort und OIDC. Die
Sitzungsschicht bleibt unangetastet.

Die Trennung zwischen Pruefung und Sitzung ist Absicht: in passkeys.ts steht
nur WebAuthn, das Setzen von Cookie und Sitzungszeile gehoert zur HTTP-Schicht.

Die Challenge liegt in einem signierten, fuenf Minuten gueltigen httpOnly-
Cookie statt in einer Tabelle — das spart Schema und Aufraeumjob fuer alles,
was liegen bleibt, wenn jemand den Dialog abbricht.

Die RP ID kommt aus APP_URL, nicht aus dem Host-Header. Hinter einem Reverse
Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen
Namen gebunden. Ueber nacktes HTTP verschwindet die Verwaltung mit einem
Hinweis statt einen kryptischen Browserfehler zu zeigen.

Den letzten verbleibenden Anmeldeweg laesst sich nicht entfernen: wer kein
Passwort und keine OIDC-Verknuepfung hat, behaelt seinen letzten Passkey.

Die Bibliothek prueft den Signaturzaehler selbst und wirft dabei gewoehnliche
Error. Die werden jetzt uebersetzt, damit eine erwartete Ablehnung nicht als
unerwarteter Serverfehler im Log landet. Die eigene Zaehlerpruefung bleibt als
zusaetzliche Sicherung fuer den Fall, den die Bibliothek durchlaesst.

Geprueft mit virtuellem Authenticator gegen einen Produktionsbau: anlegen,
abmelden, Anmeldung allein per Passkey, Sitzungscookie gesetzt,
Challenge-Cookie verbraucht, Passwortweg unveraendert. Mit kuenstlich
hochgesetztem Zaehler wird die Anmeldung abgelehnt und der Zaehler bleibt
stehen. Dazu sieben Tests fuer Verwaltung, Rechte und die Aussperrsperre.

Refs #6
This commit is contained in:
Florian Wilde
2026-09-02 07:04:45 +02:00
parent 58a1d177df
commit c0579c6f61
17 changed files with 1381 additions and 6 deletions
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
startAuthentication,
startRegistration,
WebAuthnError,
} from "@simplewebauthn/browser";
import { useToasts } from "@/components/Toasts";
import { formatDate } from "@/lib/labels";
/**
* Passkeys anlegen und verwalten.
*
* Der Browser übernimmt den heiklen Teil; hier laufen nur zwei Anfragen —
* Optionen holen, Antwort einreichen. Fehler des Authenticators tragen
* kryptische Namen, deshalb werden die häufigen übersetzt.
*/
export type PasskeyEintrag = {
id: string;
name: string;
deviceType: string;
backedUp: boolean;
createdAt: string;
lastUsedAt: string | null;
};
/** Übersetzt die Fehler des Browsers in Sätze, mit denen man etwas anfangen kann. */
export function passkeyFehlertext(error: unknown): string | null {
if (error instanceof WebAuthnError) {
switch (error.name) {
case "InvalidStateError":
return "Dieses Gerät ist hier schon hinterlegt.";
case "NotAllowedError":
// Auch der Abbruch durch den Nutzer landet hier — dann ist Schweigen
// die richtige Antwort.
return null;
case "SecurityError":
return "Passkeys brauchen HTTPS oder localhost. Über diese Adresse geht es nicht.";
default:
return "Der Passkey konnte nicht verwendet werden.";
}
}
if (error instanceof DOMException && error.name === "NotAllowedError") return null;
return "Das hat nicht geklappt.";
}
export function PasskeyList({
passkeys,
available,
hinweis,
}: {
passkeys: PasskeyEintrag[];
available: boolean;
hinweis: string | null;
}) {
const router = useRouter();
const { show } = useToasts();
const [pending, startTransition] = useTransition();
const [name, setName] = useState("");
async function anlegen() {
try {
const antwort = await fetch("/api/auth/passkey/registrierung/start", { method: "POST" });
const optionen = await antwort.json();
if (!antwort.ok) {
show(optionen.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
const ergebnis = await startRegistration({ optionsJSON: optionen });
const fertig = await fetch("/api/auth/passkey/registrierung/abschluss", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: name.trim() || geraeteVorschlag(), response: ergebnis }),
});
const daten = await fertig.json();
if (!fertig.ok) {
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
setName("");
show("Passkey angelegt");
startTransition(() => router.refresh());
} catch (error) {
const text = passkeyFehlertext(error);
if (text) show(text, { tone: "error" });
}
}
async function umbenennen(id: string, alt: string, neu: string) {
const name = neu.trim();
if (!name || name === alt) return;
const antwort = await fetch(`/api/passkeys/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name }),
});
if (!antwort.ok) {
const daten = await antwort.json().catch(() => ({}));
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
show("Umbenannt");
startTransition(() => router.refresh());
}
async function entfernen(id: string, wie: string) {
const antwort = await fetch(`/api/passkeys/${id}`, { method: "DELETE" });
const daten = await antwort.json().catch(() => ({}));
if (!antwort.ok) {
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
show(`„${wie}" entfernt`);
startTransition(() => router.refresh());
}
if (!available) {
return (
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
{hinweis}
</p>
);
}
return (
<div className="space-y-4">
{passkeys.length > 0 ? (
<ul className="divide-y divide-[var(--fl-border)]">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex flex-wrap items-center gap-3 py-2.5">
<div className="min-w-0 flex-1">
{/* Der Name ist direkt überschreibbar — ein eigener
Bearbeiten-Modus wäre für ein einzelnes Feld zu viel. */}
<input
defaultValue={passkey.name}
aria-label={`Name von „${passkey.name}"`}
maxLength={60}
onBlur={(event) => umbenennen(passkey.id, passkey.name, event.target.value)}
className="w-full truncate rounded-md border border-transparent bg-transparent px-1 py-0.5 font-medium transition-colors hover:border-[var(--fl-border)] focus:border-[var(--fl-border-strong)] focus:outline-none"
/>
<p className="px-1 text-xs text-[var(--fl-faint)]">
{passkey.backedUp ? "synchronisiert" : "nur auf diesem Gerät"}
{" · angelegt "}
{formatDate(passkey.createdAt)}
{passkey.lastUsedAt
? ` · zuletzt benutzt ${formatDate(passkey.lastUsedAt)}`
: " · noch nicht benutzt"}
</p>
</div>
<button
type="button"
disabled={pending}
onClick={() => entfernen(passkey.id, passkey.name)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Entfernen
</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-[var(--fl-muted)]">
Noch kein Passkey hinterlegt. Mit einem kannst du dich ohne Passwort anmelden — per
Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={geraeteVorschlag()}
aria-label="Name für den neuen Passkey"
maxLength={60}
className="fl-input w-56 py-1.5 text-sm"
/>
<button type="button" onClick={anlegen} disabled={pending} className="fl-btn fl-btn-primary">
Passkey anlegen
</button>
</div>
{passkeys.some((passkey) => !passkey.backedUp) && (
<p className="text-xs text-[var(--fl-faint)]">
Ein Passkey, der nur auf einem Gerät liegt, ist mit dem Gerät verloren. Halte fürs
Erste einen zweiten Anmeldeweg bereit.
</p>
)}
</div>
);
}
/** Ein brauchbarer Vorschlag, damit niemand „Passkey 1" tippen muss. */
function geraeteVorschlag(): string {
if (typeof navigator === "undefined") return "Mein Gerät";
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return "iPhone";
if (/iPad/.test(ua)) return "iPad";
if (/Android/.test(ua)) return "Android-Gerät";
if (/Mac OS X/.test(ua)) return "Mac";
if (/Windows/.test(ua)) return "Windows-PC";
if (/Linux/.test(ua)) return "Linux-Rechner";
return "Mein Gerät";
}
/** Knopf auf der Anmeldeseite. */
export function PasskeyLoginButton({ label }: { label: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function anmelden() {
setFehler(null);
setPending(true);
try {
const antwort = await fetch("/api/auth/passkey/anmeldung/start", { method: "POST" });
const optionen = await antwort.json();
if (!antwort.ok) {
setFehler(optionen.error ?? "Das hat nicht geklappt.");
return;
}
const ergebnis = await startAuthentication({ optionsJSON: optionen });
const fertig = await fetch("/api/auth/passkey/anmeldung/abschluss", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ergebnis),
});
const daten = await fertig.json();
if (!fertig.ok) {
setFehler(daten.error ?? "Das hat nicht geklappt.");
return;
}
router.replace("/");
router.refresh();
} catch (error) {
setFehler(passkeyFehlertext(error));
} finally {
setPending(false);
}
}
return (
<div className="space-y-2">
<button
type="button"
onClick={anmelden}
disabled={pending}
className="fl-btn fl-btn-ghost w-full justify-center gap-2"
>
<KeyIcon />
{pending ? "Einen Moment …" : label}
</button>
{fehler && (
<p className="text-sm text-[var(--fl-danger)]" role="alert">
{fehler}
</p>
)}
</div>
);
}
function KeyIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="8" cy="12" r="3.5" />
<path d="M11.5 12H21m-3 0v3m-3-3v2" strokeLinecap="round" />
</svg>
);
}