Files
Speisekarte/app/api/auth/passkeys/login-verify/route.ts
T
florian.wilde 28c9ec15c8 v1.5: Selbst gehostete Auth (Passwort + Passkeys), Postgres statt Supabase
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.
2026-07-18 10:42:21 +02:00

76 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from "@simplewebauthn/server";
import { query, queryOne } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
import { origin, rpID } from "@/lib/auth/webauthn-config";
import { consumeChallenge } from "@/lib/auth/webauthn-challenges";
import { isSameOrigin } from "@/lib/origin";
import type { Role } from "@/lib/types";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const body = (await request.json()) as { response: AuthenticationResponseJSON };
const challenge = extractChallengeFromClientData(body.response);
const pending = await consumeChallenge(challenge, "authentication");
if (!pending) {
return NextResponse.json({ error: "challenge_expired" }, { status: 400 });
}
// Passkey-Login ist unabhängig von password_auth_enabled – dieses Flag
// betrifft ausschließlich den Passwort-Login.
const stored = await queryOne<{
user_id: string;
public_key: Buffer;
counter: string;
transports: AuthenticatorTransportFuture[];
role: Role;
}>(
`select pc.user_id, pc.public_key, pc.counter, pc.transports, u.role
from passkey_credentials pc join users u on u.id = pc.user_id
where pc.credential_id = $1`,
[body.response.id]
);
if (!stored) {
return NextResponse.json({ error: "unknown_credential" }, { status: 400 });
}
const verification = await verifyAuthenticationResponse({
response: body.response,
expectedChallenge: challenge,
expectedOrigin: origin(),
expectedRPID: rpID(),
credential: {
id: body.response.id,
publicKey: new Uint8Array(stored.public_key),
counter: Number(stored.counter),
transports: stored.transports,
},
});
if (!verification.verified) {
return NextResponse.json({ error: "verification_failed" }, { status: 400 });
}
await query(
"update passkey_credentials set counter = $1, last_used_at = now() where credential_id = $2",
[verification.authenticationInfo.newCounter, body.response.id]
);
await createSession(stored.user_id);
return NextResponse.json({ role: stored.role });
}
function extractChallengeFromClientData(response: AuthenticationResponseJSON): string {
const json = JSON.parse(
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
) as { challenge: string };
return json.challenge;
}