Files
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

80 lines
2.8 KiB
TypeScript
Raw Permalink 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 { createHash } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
import { Pool } from "pg";
// Session-Refresh + Zugriffsschutz für die internen Bereiche.
// Next 16 führt Proxy standardmäßig im Node.js-Runtime aus, direkter
// pg-Zugriff ist hier also möglich (kein Edge-Workaround nötig).
//
// Der Cookie-Name ist hier bewusst dupliziert statt aus lib/auth/session
// importiert: dieses Modul zieht next/headers (cookies()) nach, das
// außerhalb von Server Components/Route Handlern/Server Actions nicht
// nutzbar ist – Proxy läuft in einem eigenen Ausführungskontext.
const SESSION_COOKIE = "session";
declare global {
var __pgPoolProxy: Pool | undefined;
}
const pool =
globalThis.__pgPoolProxy ??
new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== "production") globalThis.__pgPoolProxy = pool;
const REFRESH_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function proxy(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE)?.value;
function redirectToLogin() {
// Absolute URL aus dem Host-Header des Browsers bauen: request.nextUrl
// trägt bei "-H 0.0.0.0" die Server-Bindung statt des aufgerufenen
// Hosts, und relative Location-Header lehnt die Middleware ab.
const host = request.headers.get("host") ?? request.nextUrl.host;
const proto =
request.headers.get("x-forwarded-proto") ??
request.nextUrl.protocol.replace(":", "");
return NextResponse.redirect(`${proto}://${host}/login`, 303);
}
if (!token) return redirectToLogin();
const tokenHash = hashToken(token);
const { rows } = await pool.query<{ expires_at: Date }>(
"select expires_at from sessions where token_hash = $1 and expires_at > now()",
[tokenHash]
);
const session = rows[0];
if (!session) return redirectToLogin();
const response = NextResponse.next();
// Sliding-Refresh: bei weniger als 7 Tagen Restlaufzeit Sitzung und
// Cookie verlängern, statt bei jedem Request zu schreiben.
const msRemaining = session.expires_at.getTime() - Date.now();
if (msRemaining < REFRESH_THRESHOLD_MS) {
const newExpiry = new Date(Date.now() + SESSION_LIFETIME_MS);
await pool.query("update sessions set expires_at = $1, last_seen_at = now() where token_hash = $2", [
newExpiry,
tokenHash,
]);
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: (process.env.APP_URL ?? "").startsWith("https://"),
sameSite: "lax",
path: "/",
expires: newExpiry,
});
}
return response;
}
export const config = {
matcher: ["/intern/:path*", "/admin/:path*", "/konto/:path*"],
};