This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
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;
|
||||
|
||||
function getSecret(): string {
|
||||
return process.env.ADMIN_AUTH_SECRET ?? "";
|
||||
}
|
||||
|
||||
function getPassword(): string {
|
||||
return process.env.ADMIN_PASSWORD ?? "";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 function setAdminSessionCookie(): void {
|
||||
const store = cookies();
|
||||
store.set(ADMIN_SESSION_COOKIE, buildToken(), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAdminSessionCookie(): void {
|
||||
const store = cookies();
|
||||
store.set(ADMIN_SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
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 function getAdminLockState(): { locked: boolean; remainingSeconds: number } {
|
||||
const store = cookies();
|
||||
const state = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
||||
const now = Date.now();
|
||||
|
||||
if (state.lockUntil > now) {
|
||||
return {
|
||||
locked: true,
|
||||
remainingSeconds: Math.ceil((state.lockUntil - now) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
return { locked: false, remainingSeconds: 0 };
|
||||
}
|
||||
|
||||
export function registerFailedAdminAttempt(): { locked: boolean; remainingSeconds: number } {
|
||||
const store = 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;
|
||||
|
||||
store.set(ADMIN_FAIL_COOKIE, JSON.stringify({ attempts, lockUntil }), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: LOCKOUT_SECONDS,
|
||||
});
|
||||
|
||||
return {
|
||||
locked,
|
||||
remainingSeconds: locked ? LOCKOUT_SECONDS : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAdminFailedAttempts(): void {
|
||||
const store = cookies();
|
||||
store.set(ADMIN_FAIL_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function isAdminAuthenticated(): boolean {
|
||||
if (!isAdminAuthConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = cookies();
|
||||
const token = store.get(ADMIN_SESSION_COOKIE)?.value;
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return verifyToken(token);
|
||||
}
|
||||
Reference in New Issue
Block a user