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
279 lines
9.1 KiB
TypeScript
279 lines
9.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|