import { createHash, createHmac, timingSafeEqual } from "crypto"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { and, eq, like, lt } from "drizzle-orm"; import { db } from "./db"; import { appConfig } from "./db/schema"; import { getAdminAppPath } from "./admin-routing"; export const ADMIN_SESSION_COOKIE = "moh_admin_session"; const ADMIN_SESSION_VALUE = "superadmin"; const MAX_FAILED_ATTEMPTS = 5; const LOCKOUT_SECONDS = 15 * 60; const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout"; function getSecret(): string { return process.env.ADMIN_AUTH_SECRET ?? ""; } function getPassword(): string { return process.env.ADMIN_PASSWORD ?? ""; } function parseHostname(value: string | undefined): string | undefined { if (!value) { return undefined; } const trimmed = value.trim().toLowerCase(); if (!trimmed) { return undefined; } try { return new URL(trimmed).hostname.toLowerCase(); } catch { return trimmed.replace(/^https?:\/\//, "").split("/")[0]?.replace(/:\d+$/, "") || undefined; } } function isCookieDomainCandidate(hostname: string | undefined): hostname is string { return Boolean( hostname && hostname !== "localhost" && !hostname.endsWith(".localhost") && !/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) && !hostname.includes(":"), ); } function getSharedCookieHostname(hostnames: string[]): string | undefined { const validHostnames = hostnames.filter(isCookieDomainCandidate); if (validHostnames.length === 0) { return undefined; } if (validHostnames.length === 1) { return validHostnames[0]; } const reversedSegments = validHostnames.map((hostname) => hostname.split(".").reverse()); const sharedSegments: string[] = []; for (let index = 0; index < reversedSegments[0].length; index += 1) { const segment = reversedSegments[0][index]; if (!segment || reversedSegments.some((parts) => parts[index] !== segment)) { break; } sharedSegments.push(segment); } if (sharedSegments.length < 2) { return validHostnames[0]; } return sharedSegments.reverse().join("."); } function getAdminCookieDomain(): string | undefined { const hostname = getSharedCookieHostname([ parseHostname(process.env.NEXT_PUBLIC_SITE_URL), parseHostname(process.env.NEXT_PUBLIC_ADMIN_URL), parseHostname(process.env.ADMIN_HOST), ].filter((value): value is string => Boolean(value))); return hostname ? `.${hostname}` : undefined; } function signValue(value: string): string { return createHmac("sha256", getSecret()).update(value).digest("hex"); } function buildToken(): string { return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`; } function verifyToken(token: string): boolean { const parts = token.split("."); if (parts.length !== 2) { return false; } const [value, signature] = parts; if (value !== ADMIN_SESSION_VALUE) { return false; } const expected = signValue(value); const left = Buffer.from(signature); const right = Buffer.from(expected); if (left.length !== right.length) { return false; } return timingSafeEqual(left, right); } async function getClientIp(): Promise { const requestHeaders = await headers(); const forwardedFor = requestHeaders.get("x-forwarded-for"); if (forwardedFor) { return forwardedFor.split(",")[0]?.trim() ?? "unknown"; } return requestHeaders.get("x-real-ip")?.trim() ?? "unknown"; } function getLockoutKey(ip: string): string { const hash = createHash("sha256").update(ip).digest("hex").slice(0, 16); return `${ADMIN_LOCKOUT_KEY_PREFIX}:${hash}`; } async function cleanupExpiredLockouts(): Promise { try { const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000); await db .delete(appConfig) .where(and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff))); } catch { // Non-critical — ignore cleanup errors. } } export function isAdminAuthConfigured(): boolean { return getPassword().length > 0 && getSecret().length > 0; } export function isPasswordValid(password: string): boolean { if (!isAdminAuthConfigured()) { return false; } const provided = Buffer.from(password); const expected = Buffer.from(getPassword()); if (provided.length !== expected.length) { return false; } return timingSafeEqual(provided, expected); } export async function setAdminSessionCookie(): Promise { const store = await cookies(); store.set(ADMIN_SESSION_COOKIE, buildToken(), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", domain: getAdminCookieDomain(), maxAge: 60 * 60 * 8, }); } export async function clearAdminSessionCookie(): Promise { const store = await cookies(); store.set(ADMIN_SESSION_COOKIE, "", { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", domain: getAdminCookieDomain(), maxAge: 0, }); } type FailState = { attempts: number; lockUntil: number; }; function parseFailState(rawValue: string | undefined): FailState { if (!rawValue) { return { attempts: 0, lockUntil: 0 }; } try { const parsed = JSON.parse(rawValue) as Partial; return { attempts: Number(parsed.attempts ?? 0), lockUntil: Number(parsed.lockUntil ?? 0), }; } catch { return { attempts: 0, lockUntil: 0 }; } } export async function getAdminLockState(): Promise<{ locked: boolean; remainingSeconds: number }> { try { const ip = await getClientIp(); const key = getLockoutKey(ip); const [config] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, key)) .limit(1); const state = parseFailState(config?.value); const now = Date.now(); if (state.lockUntil > now) { return { locked: true, remainingSeconds: Math.ceil((state.lockUntil - now) / 1000), }; } } catch { // DB unavailable — fail open to avoid blocking the login page. } return { locked: false, remainingSeconds: 0 }; } export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; remainingSeconds: number }> { try { const ip = await getClientIp(); const key = getLockoutKey(ip); const now = Date.now(); await cleanupExpiredLockouts(); const [config] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, key)) .limit(1); const current = parseFailState(config?.value); // If a previous lockout has expired, reset the counter. const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts; const attempts = baseAttempts + 1; const locked = attempts >= MAX_FAILED_ATTEMPTS; const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0; await db .insert(appConfig) .values({ key, value: JSON.stringify({ attempts, lockUntil }) }) .onConflictDoUpdate({ target: appConfig.key, set: { value: JSON.stringify({ attempts, lockUntil }) }, }); return { locked, remainingSeconds: locked ? LOCKOUT_SECONDS : 0, }; } catch { // DB error — don't lock out so admin can still log in. return { locked: false, remainingSeconds: 0 }; } } export async function resetAdminFailedAttempts(): Promise { try { const ip = await getClientIp(); const key = getLockoutKey(ip); await db.delete(appConfig).where(eq(appConfig.key, key)); } catch { // Non-critical — ignore. } } export async function isAdminAuthenticated(): Promise { if (!isAdminAuthConfigured()) { return false; } const store = await cookies(); const token = store.get(ADMIN_SESSION_COOKIE)?.value; if (!token) { return false; } return verifyToken(token); } /** * This project has no user accounts or roles table — there is a single * privileged session: the authenticated admin cookie checked above. There is * no separate "regular user" tier, so an authenticated admin session is by * definition the only "Super Admin". This is a readable alias only, kept as * a thin wrapper around `isAdminAuthenticated()` (not a new auth mechanism). * * Security note: this function (and any UI it gates, like the header's admin * shortcut button) is NOT the access-control boundary. Every admin page and * server action must independently guard itself with `requireAdminAuth()` or * an equivalent inline `isAdminAuthenticated()` check — never rely on a link * being hidden as the thing that keeps the admin area protected. */ export async function isSuperAdmin(): Promise { return isAdminAuthenticated(); } /** * Call at the top of every authenticated admin page or server action. * Clears the session cookie and redirects to the login page if not authenticated. */ export async function requireAdminAuth(): Promise { if (!(await isAdminAuthenticated())) { await clearAdminSessionCookie(); redirect(getAdminAppPath("/")); } }