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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user