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
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Ändert die E-Mail-Adresse direkt nach Passwort-Bestätigung – ohne
// Bestätigungsmail, da kein SMTP-Server betrieben wird.
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { email, password } = (await request.json().catch(() => ({}))) as {
email?: string;
password?: string;
};
if (!email || !password) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const user = await queryOne<{ password_hash: string }>(
"select password_hash from users where id = $1",
[session.user.id]
);
if (!user || !verifyPassword(password, user.password_hash)) {
return NextResponse.json({ error: "wrong_password" }, { status: 401 });
}
try {
await query("update users set email = $1 where id = $2", [
email.trim().toLowerCase(),
session.user.id,
]);
} catch (err) {
if (err instanceof Error && /unique/i.test(err.message)) {
return NextResponse.json({ error: "email_used" }, { status: 409 });
}
throw err;
}
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { currentPassword, newPassword } = (await request.json().catch(() => ({}))) as {
currentPassword?: string;
newPassword?: string;
};
if (!currentPassword || !newPassword || newPassword.length < 8) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const user = await queryOne<{ password_hash: string }>(
"select password_hash from users where id = $1",
[session.user.id]
);
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
return NextResponse.json({ error: "wrong_password" }, { status: 401 });
}
await query("update users set password_hash = $1 where id = $2", [
hashPassword(newPassword),
session.user.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Nimmt bewusst NUR full_name/password_auth_enabled aus dem Body an –
// ersetzt den früheren Postgres-Column-Grant, der self-service-Updates
// auf "role"/"email" verhindert hat. Ein generisches "update the whole
// body"-Endpoint würde diese Absicherung wieder aufheben.
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const body = (await request.json().catch(() => ({}))) as {
full_name?: string;
password_auth_enabled?: boolean;
};
if (body.password_auth_enabled === false) {
const passkeys = await queryOne<{ count: string }>(
"select count(*)::int as count from passkey_credentials where user_id = $1",
[session.user.id]
);
if (!passkeys || Number(passkeys.count) === 0) {
return NextResponse.json({ error: "needs_passkey" }, { status: 400 });
}
}
if (body.full_name !== undefined) {
const name = body.full_name.trim();
if (!name) return NextResponse.json({ error: "name_required" }, { status: 400 });
await query("update users set full_name = $1 where id = $2", [name, session.user.id]);
}
if (body.password_auth_enabled !== undefined) {
await query("update users set password_auth_enabled = $1 where id = $2", [
body.password_auth_enabled,
session.user.id,
]);
}
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}