Fundament: db/migrations/0001_init.sql (users/sessions/passkey_credentials/ webauthn_challenges + Inhaltstabellen ohne RLS), db/migrate.mjs (idempotenter Migrationsrunner), lib/db (pg-Pool + query-Wrapper), lib/auth/password.ts (Node-crypto.scrypt, keine neue Abhängigkeit), lib/auth/session.ts (opake DB-Session-Tokens, Sliding-Refresh in proxy.ts). Passkeys: @simplewebauthn/server + /browser ersetzen Supabase's experimentelles WebAuthn-API (dafür gab es bisher kein eigenes Credential-Schema); RP-Konfiguration kommt jetzt direkt aus APP_URL statt über scripts/configure-passkeys.mjs und die Supabase-Management-API. scripts/bootstrap-admin.mjs legt bei leerer users-Tabelle automatisch einen Admin mit zufälligem Passwort an und hängt die Zugangsdaten an ZUGANGSDATEN.md an (idempotent, via predev/prestart-Hooks aufgerufen). Bugfix im Zuge des Umbaus: "mind. 1 Passkey nötig, um Passwort-Login zu deaktivieren" wurde bisher nur clientseitig geprüft – jetzt zusätzlich serverseitig in PATCH /api/account. E-Mail-Änderung im eigenen Konto läuft jetzt direkt nach Passwort-Bestätigung statt über eine Supabase-Bestätigungsmail (kein SMTP-Server vorhanden). Rest der Anwendung (Gerichte/Essenszeiten/Kennzeichnung/Vorschläge/ Benutzerverwaltung/Branding) nutzt noch den alten Supabase-Client – folgt in weiteren Commits.
70 lines
2.5 KiB
TypeScript
70 lines
2.5 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
|
||
import { query } from "@/lib/db";
|
||
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
|
||
import { origin, rpID } from "@/lib/auth/webauthn-config";
|
||
import { consumeChallenge } from "@/lib/auth/webauthn-challenges";
|
||
import { isSameOrigin } from "@/lib/origin";
|
||
|
||
export async function POST(request: Request) {
|
||
if (!isSameOrigin(request)) {
|
||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||
}
|
||
try {
|
||
const session = await requireSession();
|
||
const body = (await request.json()) as {
|
||
response: RegistrationResponseJSON;
|
||
name?: string;
|
||
};
|
||
|
||
const challenge = extractChallengeFromClientData(body.response);
|
||
const pending = await consumeChallenge(challenge, "registration");
|
||
if (!pending || pending.userId !== session.user.id) {
|
||
return NextResponse.json({ error: "challenge_expired" }, { status: 400 });
|
||
}
|
||
|
||
const verification = await verifyRegistrationResponse({
|
||
response: body.response,
|
||
expectedChallenge: challenge,
|
||
expectedOrigin: origin(),
|
||
expectedRPID: rpID(),
|
||
});
|
||
|
||
if (!verification.verified || !verification.registrationInfo) {
|
||
return NextResponse.json({ error: "verification_failed" }, { status: 400 });
|
||
}
|
||
|
||
const { credential } = verification.registrationInfo;
|
||
await query(
|
||
`insert into passkey_credentials (user_id, credential_id, public_key, counter, transports, name)
|
||
values ($1, $2, $3, $4, $5, $6)`,
|
||
[
|
||
session.user.id,
|
||
credential.id,
|
||
Buffer.from(credential.publicKey),
|
||
credential.counter,
|
||
credential.transports ?? [],
|
||
body.name ?? null,
|
||
]
|
||
);
|
||
|
||
return NextResponse.json({ ok: true });
|
||
} catch (err) {
|
||
if (err instanceof UnauthorizedError) {
|
||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// clientDataJSON ist base64url-kodiertes JSON mit dem Feld "challenge" –
|
||
// darüber wird die zugehörige, serverseitig gespeicherte Challenge
|
||
// gefunden, ohne sie zusätzlich im Request-Body mitschicken zu müssen.
|
||
function extractChallengeFromClientData(response: RegistrationResponseJSON): string {
|
||
const json = JSON.parse(
|
||
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
|
||
) as { challenge: string };
|
||
return json.challenge;
|
||
}
|