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.
This commit is contained in:
2026-07-18 10:42:21 +02:00
parent 4c5376a8c7
commit 28c9ec15c8
28 changed files with 1618 additions and 153 deletions
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { id } = await params;
// user_id-Filter stellt sicher, dass nur eigene Credentials gelöscht
// werden können.
await query("delete from passkey_credentials where id = $1 and user_id = $2", [
id,
session.user.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { rpID } from "@/lib/auth/webauthn-config";
import { storeChallenge } from "@/lib/auth/webauthn-challenges";
// Passwortloser "usernameless"-Login: kein allowCredentials, der Browser
// fragt den Nutzer, welchen registrierten Passkey er verwenden möchte.
// user_id ist beim Speichern der Challenge noch unbekannt.
export async function POST() {
const options = await generateAuthenticationOptions({
rpID: rpID(),
userVerification: "preferred",
});
await storeChallenge(options.challenge, "authentication", null);
return NextResponse.json(options);
}
@@ -0,0 +1,75 @@
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;
}
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { rpID, rpName } from "@/lib/auth/webauthn-config";
import { storeChallenge } from "@/lib/auth/webauthn-challenges";
export async function POST() {
try {
const session = await requireSession();
const existing = await query<{
credential_id: string;
transports: AuthenticatorTransportFuture[];
}>("select credential_id, transports from passkey_credentials where user_id = $1", [
session.user.id,
]);
const options = await generateRegistrationOptions({
rpName: rpName(),
rpID: rpID(),
userID: new TextEncoder().encode(session.user.id),
userName: session.user.email,
userDisplayName: session.user.full_name || session.user.email,
attestationType: "none",
excludeCredentials: existing.map((c) => ({
id: c.credential_id,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "registration", session.user.id);
return NextResponse.json(options);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
@@ -0,0 +1,69 @@
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;
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
export async function GET() {
try {
const session = await requireSession();
const rows = await query<{
id: string;
name: string | null;
created_at: string;
last_used_at: string | null;
}>(
`select id, name, created_at, last_used_at from passkey_credentials
where user_id = $1 order by created_at`,
[session.user.id]
);
return NextResponse.json(rows);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}