import { compareSync } from "bcryptjs"; import { randomUUID } from "node:crypto"; import { jwtVerify, SignJWT } from "jose"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { db, initDatabase } from "./db"; import { getAuthSecret } from "./env"; import type { SessionUser, UserRole } from "./types"; type SessionPayload = { id: number; sid: string; exp: number; }; type AdminMfaPayload = { uid: number; purpose: "admin-mfa"; exp: number; }; export const SESSION_COOKIE = "moped_session"; export const ADMIN_MFA_COOKIE = "moped_admin_mfa"; const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; const ADMIN_MFA_TTL_SECONDS = 60 * 5; function getSecretKey() { return new TextEncoder().encode(getAuthSecret()); } type SessionMetadata = { userAgent?: string | null; ipAddress?: string | null; lastPath?: string | null; }; function cleanMetadata(value: string | null | undefined, max: number) { const cleaned = value?.replace(/[\u0000-\u001f]/g, "").trim(); return cleaned ? cleaned.slice(0, max) : null; } export async function createSessionToken(user: SessionUser, metadata: SessionMetadata = {}) { initDatabase(); const sessionId = randomUUID(); const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000).toISOString(); db.prepare( `INSERT INTO user_sessions (id, user_id, user_agent, ip_address, last_path, expires_at) VALUES (?, ?, ?, ?, ?, ?)`, ).run( sessionId, user.id, cleanMetadata(metadata.userAgent, 500), cleanMetadata(metadata.ipAddress, 100), cleanMetadata(metadata.lastPath, 500), expiresAt, ); return new SignJWT({ id: user.id, sid: sessionId }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime(`${SESSION_TTL_SECONDS}s`) .sign(getSecretKey()); } export async function setSessionCookie(user: SessionUser) { const requestHeaders = await headers(); const token = await createSessionToken(user, { userAgent: requestHeaders.get("user-agent"), ipAddress: requestHeaders.get("x-forwarded-for")?.split(",")[0] ?? requestHeaders.get("x-real-ip"), lastPath: "/", }); const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: SESSION_TTL_SECONDS, path: "/", priority: "high", }); } export async function clearSessionCookie() { const cookieStore = await cookies(); const token = cookieStore.get(SESSION_COOKIE)?.value; if (token) { try { const result = await jwtVerify(token, getSecretKey()); const payload = result.payload as unknown as SessionPayload; if (typeof payload.sid === "string") { initDatabase(); db.prepare("UPDATE user_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?") .run(payload.sid); } } catch { // The local cookie is removed even if it is already invalid. } } cookieStore.delete(SESSION_COOKIE); } export async function setPendingAdminMfaCookie(userId: number) { const token = await new SignJWT({ uid: userId, purpose: "admin-mfa" }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime(`${ADMIN_MFA_TTL_SECONDS}s`) .sign(getSecretKey()); const cookieStore = await cookies(); cookieStore.set(ADMIN_MFA_COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: ADMIN_MFA_TTL_SECONDS, path: "/", priority: "high", }); } export async function getPendingAdminMfaUserId() { const token = (await cookies()).get(ADMIN_MFA_COOKIE)?.value; if (!token) return null; try { const result = await jwtVerify(token, getSecretKey()); const payload = result.payload as unknown as AdminMfaPayload; return Number.isSafeInteger(payload.uid) && payload.uid > 0 && payload.purpose === "admin-mfa" ? payload.uid : null; } catch { return null; } } export async function clearPendingAdminMfaCookie() { (await cookies()).delete(ADMIN_MFA_COOKIE); } export async function getSessionUser(): Promise { const token = (await cookies()).get(SESSION_COOKIE)?.value; if (!token) return null; try { const result = await jwtVerify(token, getSecretKey()); const payload = result.payload as unknown as SessionPayload; if (typeof payload.sid !== "string" || !payload.sid) return null; initDatabase(); const user = db .prepare( `SELECT u.id, u.name, u.email, u.role FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.user_id = ? AND s.revoked_at IS NULL AND datetime(s.expires_at) > CURRENT_TIMESTAMP AND u.active = 1`, ) .get(payload.sid, Number(payload.id)) as | { id: number; name: string; email: string; role: UserRole } | undefined; if (!user) return null; db.prepare( `UPDATE user_sessions SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ? AND last_seen_at < datetime('now', '-30 seconds')`, ).run(payload.sid); return user ?? null; } catch { return null; } } export async function getCurrentSessionId() { const token = (await cookies()).get(SESSION_COOKIE)?.value; if (!token) return null; try { const result = await jwtVerify(token, getSecretKey()); const payload = result.payload as unknown as SessionPayload; return typeof payload.sid === "string" && payload.sid ? payload.sid : null; } catch { return null; } } export async function requireSessionUser() { const user = await getSessionUser(); if (!user) redirect("/login"); return user; } export async function requireAdminUser() { const user = await requireSessionUser(); if (user.role !== "admin") redirect("/?error=Nur%20f%C3%BCr%20Administratoren."); return user; } export function authenticateUser(username: string, password: string): SessionUser | null { initDatabase(); const users = db .prepare( "SELECT id, name, email, role, password_hash FROM users WHERE name = ? COLLATE NOCASE AND active = 1 ORDER BY id ASC", ) .all(username.trim()) as (SessionUser & { password_hash: string })[]; if (users.length !== 1) return null; const user = users[0]; if (!user || !compareSync(password, user.password_hash)) return null; return { id: user.id, name: user.name, email: user.email, role: user.role }; }