Files
sass-mohfarawati/lib/admin-auth.ts
T
MOH 0a5f77d8de REFACTORED - migrate the data layer from Prisma to Drizzle (unify the stack)
- Add lib/db (Drizzle schema: 7 tables + 6 enums + relations, postgres.js client),
  drizzle.config.ts, lib/db/enums.ts (Prisma-compatible enum objects)
- Rewrite all 14 app consumers + 4 admin components to Drizzle
- Move the test DB layer to Drizzle + in-process PGlite; convert all 8 integration
  test files + factories (371 tests green)
- Remove Prisma: deps, prisma/ schema+migrations, lib/prisma.ts, Dockerfile prisma
  generate; wire db:generate/migrate/push/studio to drizzle-kit; update Makefile
- Preserve the old seed as scripts/legacy-prisma-seed.cjs (needs a Drizzle rewrite)
2026-08-07 14:18:41 +02:00

332 lines
9.0 KiB
TypeScript

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<string> {
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<void> {
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<void> {
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<void> {
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<FailState>;
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<void> {
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<boolean> {
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<boolean> {
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<void> {
if (!(await isAdminAuthenticated())) {
await clearAdminSessionCookie();
redirect(getAdminAppPath("/"));
}
}