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:
@@ -1,36 +1,36 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
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) {
|
||||
let response = NextResponse.next({ request });
|
||||
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value)
|
||||
);
|
||||
response = NextResponse.next({ request });
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options)
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
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.
|
||||
@@ -41,6 +41,36 @@ export async function proxy(request: NextRequest) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user