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.
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
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*"],
|
||
};
|