- Move admin login lockout from client cookie to AppConfig (DB), keyed by hashed client IP — clearing browser cookies no longer bypasses it - Replace rate-limit $transaction (TOCTOU) with atomic SQL INSERT...ON CONFLICT...RETURNING; add stale-entry cleanup on each submission to prevent table bloat - Add 5 s module-level cache for middleware runtime state fetch, reducing per-request DB roundtrips - Rename middleware.ts → proxy.ts to resolve Next.js 16 deprecation warning; update test import accordingly - Require ADMIN_PASSWORD, ADMIN_AUTH_SECRET, ADMIN_BASIC_AUTH_USER, and ADMIN_BASIC_AUTH_PASS in docker-compose.yml (:? syntax) — startup fails loudly instead of using placeholder defaults - Add set -e and informative echo lines to Dockerfile CMD for clearer startup failure attribution - Export requireAdminAuth() from lib/admin-auth for centralised use in admin pages - Add CLAUDE.md with architecture notes and working rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+102
-36
@@ -1,11 +1,15 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { prisma } from "./prisma";
|
||||
import { getAdminAppPath } from "./admin-routing";
|
||||
|
||||
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||
const ADMIN_FAIL_COOKIE = "moh_admin_fail";
|
||||
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 ?? "";
|
||||
@@ -114,6 +118,35 @@ function verifyToken(token: string): boolean {
|
||||
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 prisma.$executeRaw`
|
||||
DELETE FROM "AppConfig"
|
||||
WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`}
|
||||
AND "updatedAt" < ${cutoff}
|
||||
`;
|
||||
} catch {
|
||||
// Non-critical — ignore cleanup errors.
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdminAuthConfigured(): boolean {
|
||||
return getPassword().length > 0 && getSecret().length > 0;
|
||||
}
|
||||
@@ -180,51 +213,73 @@ function parseFailState(rawValue: string | undefined): FailState {
|
||||
}
|
||||
|
||||
export async function getAdminLockState(): Promise<{ locked: boolean; remainingSeconds: number }> {
|
||||
const store = await cookies();
|
||||
const state = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
||||
const now = Date.now();
|
||||
try {
|
||||
const ip = await getClientIp();
|
||||
const key = getLockoutKey(ip);
|
||||
const config = await prisma.appConfig.findUnique({
|
||||
where: { key },
|
||||
select: { value: true },
|
||||
});
|
||||
const state = parseFailState(config?.value);
|
||||
const now = Date.now();
|
||||
|
||||
if (state.lockUntil > now) {
|
||||
return {
|
||||
locked: true,
|
||||
remainingSeconds: Math.ceil((state.lockUntil - now) / 1000),
|
||||
};
|
||||
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 }> {
|
||||
const store = await cookies();
|
||||
const now = Date.now();
|
||||
const current = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
||||
const attempts = current.lockUntil > now ? current.attempts : current.attempts + 1;
|
||||
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
||||
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
||||
try {
|
||||
const ip = await getClientIp();
|
||||
const key = getLockoutKey(ip);
|
||||
const now = Date.now();
|
||||
|
||||
store.set(ADMIN_FAIL_COOKIE, JSON.stringify({ attempts, lockUntil }), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: LOCKOUT_SECONDS,
|
||||
});
|
||||
await cleanupExpiredLockouts();
|
||||
|
||||
return {
|
||||
locked,
|
||||
remainingSeconds: locked ? LOCKOUT_SECONDS : 0,
|
||||
};
|
||||
const config = await prisma.appConfig.findUnique({
|
||||
where: { key },
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
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 prisma.appConfig.upsert({
|
||||
where: { key },
|
||||
update: { value: JSON.stringify({ attempts, lockUntil }) },
|
||||
create: { key, 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> {
|
||||
const store = await cookies();
|
||||
store.set(ADMIN_FAIL_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
try {
|
||||
const ip = await getClientIp();
|
||||
const key = getLockoutKey(ip);
|
||||
await prisma.appConfig.deleteMany({ where: { key } });
|
||||
} catch {
|
||||
// Non-critical — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
export async function isAdminAuthenticated(): Promise<boolean> {
|
||||
@@ -241,3 +296,14 @@ export async function isAdminAuthenticated(): Promise<boolean> {
|
||||
|
||||
return verifyToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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("/"));
|
||||
}
|
||||
}
|
||||
|
||||
+21
-20
@@ -44,28 +44,29 @@ export async function enforceContactRateLimit(settings: ContactProtectionSetting
|
||||
const ip = await getClientIpFromHeaders();
|
||||
const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const current = await tx.appConfig.findUnique({
|
||||
where: { key },
|
||||
select: { value: true },
|
||||
});
|
||||
const nextCount = parseCount(current?.value) + 1;
|
||||
// Clean up stale rate limit entries (older than 2x the window) to prevent table bloat.
|
||||
const cutoffDate = new Date(Date.now() - settings.rateLimit.windowMinutes * 2 * 60 * 1000);
|
||||
await prisma.$executeRaw`
|
||||
DELETE FROM "AppConfig"
|
||||
WHERE key LIKE ${`${CONTACT_RATE_LIMIT_KEY_PREFIX}:%`}
|
||||
AND "updatedAt" < ${cutoffDate}
|
||||
`;
|
||||
|
||||
if (nextCount > settings.rateLimit.maxRequests) {
|
||||
throw new Error("Too many contact requests. Please try again later.");
|
||||
}
|
||||
// Atomically insert or increment the counter for this IP + window.
|
||||
const result = await prisma.$queryRaw<Array<{ count: number }>>`
|
||||
INSERT INTO "AppConfig" (id, key, value, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid()::text, ${key}, '1', NOW(), NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = (CAST("AppConfig".value AS INTEGER) + 1)::text,
|
||||
"updatedAt" = NOW()
|
||||
RETURNING CAST(value AS INTEGER) AS count
|
||||
`;
|
||||
|
||||
await tx.appConfig.upsert({
|
||||
where: { key },
|
||||
update: {
|
||||
value: String(nextCount),
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
value: "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
const count = result[0]?.count ?? 0;
|
||||
|
||||
if (count > settings.rateLimit.maxRequests) {
|
||||
throw new Error("Too many contact requests. Please try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyTurnstileToken(
|
||||
|
||||
Reference in New Issue
Block a user