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:
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/lib/auth";
|
||||
import { getSettings, isRegistrationOpen } from "@/lib/settings";
|
||||
import { env } from "@/lib/env";
|
||||
import { AuthSwitch, LoginForm } from "@/components/AuthForm";
|
||||
import { PasskeyLoginButton } from "@/components/settings/Passkeys";
|
||||
import { loginAction } from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Anmelden" };
|
||||
@@ -36,16 +37,22 @@ export default async function LoginPage({
|
||||
|
||||
<LoginForm action={loginAction} weiter={params.weiter} />
|
||||
|
||||
{env.oidc.isConfigured && (
|
||||
{(env.passkeys.isAvailable || env.oidc.isConfigured) && (
|
||||
<>
|
||||
<div className="my-5 flex items-center gap-3 text-xs text-[var(--fl-faint)]">
|
||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||
oder
|
||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||
</div>
|
||||
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
|
||||
{env.oidc.buttonLabel}
|
||||
</a>
|
||||
|
||||
<div className="space-y-2">
|
||||
{env.passkeys.isAvailable && <PasskeyLoginButton label="Mit Passkey anmelden" />}
|
||||
{env.oidc.isConfigured && (
|
||||
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full justify-center">
|
||||
{env.oidc.buttonLabel}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { finishLogin, PasskeyError } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const schema = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
rawId: z.string().min(1),
|
||||
type: z.literal("public-key"),
|
||||
clientExtensionResults: z.looseObject({}),
|
||||
response: z.looseObject({}),
|
||||
});
|
||||
|
||||
|
||||
/** Prüft die Antwort und legt bei Erfolg die Sitzung an. */
|
||||
export async function POST(request: Request) {
|
||||
const body = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Die äußere Form ist geprüft; alles Weitere prüft die Bibliothek.
|
||||
const { userId } = await finishLogin(body.data as unknown as Parameters<typeof finishLogin>[0]);
|
||||
// Dieselbe Sitzung wie nach einer Passwortanmeldung — gleiche Tabelle,
|
||||
// gleiches Cookie.
|
||||
await createSession(userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey-Anmeldung fehlgeschlagen:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PasskeyError, startLogin } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Optionen für die Anmeldung — bewusst ohne Anmeldung erreichbar und ohne
|
||||
* Angabe, welche Passkeys es gibt. Der Browser sucht selbst einen passenden.
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
return NextResponse.json(await startLogin());
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey-Anmeldung konnte nicht beginnen:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { finishRegistration, PasskeyError } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const antwortSchema = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
rawId: z.string().min(1),
|
||||
type: z.literal("public-key"),
|
||||
clientExtensionResults: z.looseObject({}),
|
||||
response: z.looseObject({}),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().trim().min(1).max(60),
|
||||
response: antwortSchema,
|
||||
});
|
||||
|
||||
/** Prüft die Antwort des Authenticators und legt den Passkey an. */
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
|
||||
const body = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Die äußere Form ist geprüft; die Feinheiten der eingebetteten
|
||||
// Base64-Felder prüft die Bibliothek beim Verifizieren.
|
||||
const passkey = await finishRegistration(
|
||||
user.id,
|
||||
body.data.response as unknown as Parameters<typeof finishRegistration>[1],
|
||||
body.data.name,
|
||||
);
|
||||
return NextResponse.json({ ok: true, passkey });
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey konnte nicht angelegt werden:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { PasskeyError, startRegistration } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Optionen zum Anlegen eines Passkeys — nur für angemeldete Nutzer. */
|
||||
export async function POST() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
|
||||
try {
|
||||
return NextResponse.json(await startRegistration(user.id));
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey-Registrierung konnte nicht beginnen:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { deletePasskey, PasskeyError, renamePasskey } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
async function withUser(context: Context, fn: (userId: string, id: string) => Promise<unknown>) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
|
||||
const { id } = await context.params;
|
||||
if (!id) return NextResponse.json({ error: "Es fehlt die Kennung." }, { status: 400 });
|
||||
|
||||
try {
|
||||
await fn(user.id, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey-Verwaltung fehlgeschlagen:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Entfernt einen Passkey — nie den letzten Anmeldeweg. */
|
||||
export async function DELETE(_request: Request, context: Context) {
|
||||
return withUser(context, (userId, id) => deletePasskey(userId, id));
|
||||
}
|
||||
|
||||
const umbenennen = z.object({ name: z.string().trim().min(1).max(60) });
|
||||
|
||||
/** Benennt einen Passkey um. */
|
||||
export async function PATCH(request: Request, context: Context) {
|
||||
const body = umbenennen.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ error: "Es fehlt ein Name." }, { status: 422 });
|
||||
}
|
||||
return withUser(context, (userId, id) => renamePasskey(userId, id, body.data.name));
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
|
||||
import { PasskeyList } from "@/components/settings/Passkeys";
|
||||
import { listPasskeys } from "@/lib/passkeys";
|
||||
import { env } from "@/lib/env";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
@@ -28,6 +31,8 @@ export default async function SettingsPage() {
|
||||
|
||||
const { passwordHash, ...profile } = user;
|
||||
|
||||
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
@@ -42,6 +47,29 @@ export default async function SettingsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{env.passkeys.enabled && (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-1 text-lg font-semibold">Passkeys</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Anmelden ohne Passwort — per Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
|
||||
</p>
|
||||
<PasskeyList
|
||||
passkeys={passkeys.map((passkey) => ({
|
||||
...passkey,
|
||||
createdAt: passkey.createdAt.toISOString(),
|
||||
lastUsedAt: passkey.lastUsedAt?.toISOString() ?? null,
|
||||
}))}
|
||||
available={env.passkeys.isAvailable}
|
||||
hinweis={
|
||||
env.passkeys.isAvailable
|
||||
? null
|
||||
: "Passkeys brauchen eine verschlüsselte Verbindung. Diese Instanz läuft über " +
|
||||
"einfaches HTTP — richte HTTPS ein, dann erscheint hier die Verwaltung."
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -81,6 +81,41 @@ export const env = {
|
||||
},
|
||||
},
|
||||
|
||||
passkeys: {
|
||||
/**
|
||||
* Passkeys lassen sich abschalten wie OIDC. Standardmäßig an — sie
|
||||
* kosten nichts, solange niemand einen anlegt.
|
||||
*/
|
||||
enabled: bool("ENABLE_PASSKEYS", true),
|
||||
/**
|
||||
* Die RP ID ist der Hostname, an den ein Passkey gebunden ist. Sie kommt
|
||||
* aus APP_URL und **nicht** aus dem Host-Header: hinter einem Reverse
|
||||
* Proxy hinge sie sonst am falschen Namen, und alle Passkeys wären
|
||||
* plötzlich ungültig.
|
||||
*/
|
||||
get rpId(): string {
|
||||
try {
|
||||
return new URL(env.appUrl).hostname;
|
||||
} catch {
|
||||
return "localhost";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Der Browser verlangt einen sicheren Kontext. Erlaubt sind HTTPS und
|
||||
* localhost; eine nackte IP-Adresse ist als RP ID ohnehin unzulässig.
|
||||
*/
|
||||
get isAvailable(): boolean {
|
||||
if (!this.enabled) return false;
|
||||
try {
|
||||
const url = new URL(env.appUrl);
|
||||
if (url.protocol === "https:") return true;
|
||||
return url.hostname === "localhost" || url.hostname.endsWith(".localhost");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
oidc: {
|
||||
issuer: optional("OIDC_ISSUER"),
|
||||
clientId: optional("OIDC_CLIENT_ID"),
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/server";
|
||||
|
||||
import { prisma } from "./db";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Anmeldung per Passkey (WebAuthn).
|
||||
*
|
||||
* Ein Passkey ist der dritte Weg in denselben Trichter: `finishLogin()` prüft
|
||||
* die Antwort und gibt die Kennung zurück, der Endpunkt legt damit über
|
||||
* `createSession()` dieselbe Sitzung an wie bei Passwort und OIDC. Die
|
||||
* Sitzungsschicht weiß nicht, wie jemand hereingekommen ist.
|
||||
*
|
||||
* Die Trennung ist Absicht: hier steht die WebAuthn-Prüfung, das Setzen von
|
||||
* Cookie und Sitzungszeile gehört zur HTTP-Schicht.
|
||||
*
|
||||
* Zwei Dinge sind bei einer selbst gehosteten Instanz wichtiger als sonst:
|
||||
*
|
||||
* - 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.
|
||||
* - Ein **Domainwechsel entwertet alle Passkeys.** Das steht in der README;
|
||||
* hier lässt sich nichts dagegen tun, es ist Teil des Verfahrens.
|
||||
*/
|
||||
|
||||
export class PasskeyError extends Error {}
|
||||
|
||||
const CHALLENGE_COOKIE = "flimmer_passkey_challenge";
|
||||
/** Kurz genug, dass eine liegengebliebene Challenge nicht ewig gilt. */
|
||||
const CHALLENGE_SECONDS = 300;
|
||||
|
||||
function rpId(): string {
|
||||
return env.passkeys.rpId;
|
||||
}
|
||||
|
||||
/** Der Ursprung, den der Browser in der Antwort mitschickt. */
|
||||
function expectedOrigin(): string {
|
||||
return new URL(env.appUrl).origin;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Challenge — signiertes Cookie statt Tabelle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Die Challenge muss den Weg vom ersten zum zweiten Schritt überstehen.
|
||||
*
|
||||
* Eine Tabelle bräuchte einen Aufräumjob für alles, was liegen bleibt, wenn
|
||||
* jemand den Dialog abbricht. Ein signiertes, kurzlebiges Cookie kommt ohne
|
||||
* beides aus: Fälschen geht nicht, und nach fünf Minuten ist es ohnehin weg.
|
||||
*/
|
||||
function sign(payload: string): string {
|
||||
return createHmac("sha256", env.authSecret).update(payload).digest("base64url");
|
||||
}
|
||||
|
||||
async function storeChallenge(challenge: string, scope: "register" | "login") {
|
||||
const payload = `${scope}.${challenge}.${Date.now() + CHALLENGE_SECONDS * 1000}`;
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(CHALLENGE_COOKIE, `${payload}.${sign(payload)}`, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: CHALLENGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
async function takeChallenge(scope: "register" | "login"): Promise<string> {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(CHALLENGE_COOKIE)?.value;
|
||||
// Einmalgebrauch: gleich löschen, egal ob die Prüfung gelingt.
|
||||
cookieStore.delete(CHALLENGE_COOKIE);
|
||||
|
||||
if (!raw) throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
|
||||
|
||||
const teile = raw.split(".");
|
||||
if (teile.length !== 4) throw new PasskeyError("Die Anfrage ist ungültig.");
|
||||
|
||||
const [gespeicherterScope, challenge, ablauf, signatur] = teile as [string, string, string, string];
|
||||
const payload = `${gespeicherterScope}.${challenge}.${ablauf}`;
|
||||
|
||||
const erwartet = Buffer.from(sign(payload));
|
||||
const gegeben = Buffer.from(signatur);
|
||||
if (erwartet.length !== gegeben.length || !timingSafeEqual(erwartet, gegeben)) {
|
||||
throw new PasskeyError("Die Anfrage ist ungültig.");
|
||||
}
|
||||
if (gespeicherterScope !== scope) throw new PasskeyError("Die Anfrage passt nicht.");
|
||||
if (Number(ablauf) < Date.now()) {
|
||||
throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
|
||||
}
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
function requireAvailable() {
|
||||
if (!env.passkeys.enabled) throw new PasskeyError("Passkeys sind auf dieser Instanz abgeschaltet.");
|
||||
if (!env.passkeys.isAvailable) {
|
||||
throw new PasskeyError(
|
||||
"Passkeys brauchen HTTPS (oder localhost). Diese Instanz läuft über eine unverschlüsselte Verbindung.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anlegen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startRegistration(userId: string) {
|
||||
requireAvailable();
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
const vorhandene = await prisma.passkey.findMany({
|
||||
where: { userId },
|
||||
select: { credentialId: true, transports: true },
|
||||
});
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: env.appName,
|
||||
rpID: rpId(),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name,
|
||||
// Die Kennung muss über Umbenennungen hinweg stabil bleiben — deshalb die
|
||||
// interne ID und nicht die E-Mail-Adresse.
|
||||
userID: new TextEncoder().encode(user.id),
|
||||
attestationType: "none",
|
||||
// Schon vorhandene Passkeys ausschließen, damit dasselbe Gerät nicht
|
||||
// zweimal registriert wird.
|
||||
excludeCredentials: vorhandene.map((eintrag) => ({
|
||||
id: eintrag.credentialId,
|
||||
transports: eintrag.transports as AuthenticatorTransportFuture[],
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
// Auffindbar, damit die Anmeldung ohne vorherige E-Mail-Eingabe geht.
|
||||
residentKey: "required",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge(options.challenge, "register");
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function finishRegistration(
|
||||
userId: string,
|
||||
response: RegistrationResponseJSON,
|
||||
name: string,
|
||||
) {
|
||||
requireAvailable();
|
||||
const challenge = await takeChallenge("register");
|
||||
|
||||
const geprueft = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: expectedOrigin(),
|
||||
expectedRPID: rpId(),
|
||||
requireUserVerification: false,
|
||||
});
|
||||
|
||||
if (!geprueft.verified || !geprueft.registrationInfo) {
|
||||
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = geprueft.registrationInfo;
|
||||
|
||||
const schonVergeben = await prisma.passkey.findUnique({
|
||||
where: { credentialId: credential.id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (schonVergeben) {
|
||||
throw new PasskeyError("Dieser Passkey ist bereits hinterlegt.");
|
||||
}
|
||||
|
||||
return prisma.passkey.create({
|
||||
data: {
|
||||
userId,
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey),
|
||||
counter: BigInt(credential.counter),
|
||||
transports: (credential.transports ?? []) as string[],
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
name: name.trim().slice(0, 60) || "Passkey",
|
||||
},
|
||||
select: { id: true, name: true, deviceType: true, backedUp: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anmelden
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startLogin() {
|
||||
requireAvailable();
|
||||
|
||||
// Ohne `allowCredentials`: der Browser sucht selbst einen passenden Passkey.
|
||||
// Genau das erlaubt die Anmeldung ohne vorherige E-Mail-Eingabe — und
|
||||
// verrät nebenbei nicht, welche Konten es auf dieser Instanz gibt.
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId(),
|
||||
userVerification: "preferred",
|
||||
});
|
||||
|
||||
await storeChallenge(options.challenge, "login");
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function finishLogin(response: AuthenticationResponseJSON) {
|
||||
requireAvailable();
|
||||
const challenge = await takeChallenge("login");
|
||||
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { credentialId: response.id },
|
||||
include: { user: { select: { id: true, isActive: true } } },
|
||||
});
|
||||
if (!passkey) throw new PasskeyError("Dieser Passkey gehört zu keinem Konto hier.");
|
||||
if (!passkey.user.isActive) throw new PasskeyError("Dieses Konto ist gesperrt.");
|
||||
|
||||
// Die Bibliothek prüft Signatur, Ursprung, RP ID **und** den Zähler. Sie
|
||||
// wirft dabei gewöhnliche Error — hier übersetzt, damit eine erwartete
|
||||
// Ablehnung nicht als unerwarteter Serverfehler im Log landet.
|
||||
let geprueft;
|
||||
try {
|
||||
geprueft = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: expectedOrigin(),
|
||||
expectedRPID: rpId(),
|
||||
requireUserVerification: false,
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey: new Uint8Array(passkey.publicKey),
|
||||
counter: Number(passkey.counter),
|
||||
transports: passkey.transports as AuthenticatorTransportFuture[],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : "";
|
||||
if (/counter/i.test(text)) {
|
||||
throw new PasskeyError(
|
||||
"Dieser Passkey wurde abgelehnt — der Zähler passt nicht. Melde dich anders an und lege ihn neu an.",
|
||||
);
|
||||
}
|
||||
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
|
||||
}
|
||||
|
||||
if (!geprueft.verified) throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
|
||||
|
||||
// Zusätzliche Sicherung. Die Bibliothek prüft den Zähler nur, wenn beide
|
||||
// Werte über null liegen; ein Authenticator, der von einem gezählten auf
|
||||
// einen ungezählten Stand zurückfällt, käme dort durch.
|
||||
const neuerZaehler = geprueft.authenticationInfo.newCounter;
|
||||
const alterZaehler = Number(passkey.counter);
|
||||
if (alterZaehler > 0 && neuerZaehler <= alterZaehler) {
|
||||
throw new PasskeyError("Dieser Passkey wurde abgelehnt. Bitte melde dich anders an.");
|
||||
}
|
||||
|
||||
await prisma.passkey.update({
|
||||
where: { id: passkey.id },
|
||||
data: { counter: BigInt(neuerZaehler), lastUsedAt: new Date() },
|
||||
});
|
||||
|
||||
return { userId: passkey.user.id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verwalten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listPasskeys(userId: string) {
|
||||
return prisma.passkey.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
deviceType: true,
|
||||
backedUp: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function renamePasskey(userId: string, passkeyId: string, name: string) {
|
||||
const treffer = await prisma.passkey.updateMany({
|
||||
where: { id: passkeyId, userId },
|
||||
data: { name: name.trim().slice(0, 60) || "Passkey" },
|
||||
});
|
||||
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Passkey — aber nie den letzten Anmeldeweg.
|
||||
*
|
||||
* Wer ein Konto ohne Passwort hat (etwa über OIDC angelegt) und seinen einzigen
|
||||
* Passkey löscht, käme sonst nicht mehr herein.
|
||||
*/
|
||||
export async function deletePasskey(userId: string, passkeyId: string) {
|
||||
const [user, anzahl] = await Promise.all([
|
||||
prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { passwordHash: true, _count: { select: { oidcAccounts: true } } },
|
||||
}),
|
||||
prisma.passkey.count({ where: { userId } }),
|
||||
]);
|
||||
|
||||
const andereWege = Boolean(user.passwordHash) || user._count.oidcAccounts > 0;
|
||||
if (!andereWege && anzahl <= 1) {
|
||||
throw new PasskeyError(
|
||||
"Das ist dein letzter Anmeldeweg. Lege zuerst ein Passwort oder einen weiteren Passkey an.",
|
||||
);
|
||||
}
|
||||
|
||||
const treffer = await prisma.passkey.deleteMany({ where: { id: passkeyId, userId } });
|
||||
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
|
||||
}
|
||||
Reference in New Issue
Block a user