121 lines
3.1 KiB
TypeScript
121 lines
3.1 KiB
TypeScript
import { cookies } from "next/headers";
|
|
import { NextRequest } from "next/server";
|
|
|
|
const COOKIE_NAME = "admin_session";
|
|
const encoder = new TextEncoder();
|
|
let cachedKey: CryptoKey | null = null;
|
|
let cachedSecret: string | null = null;
|
|
|
|
export const USER_ROLE_VALUES = ["user", "sponsor", "admin"] as const;
|
|
export type UserRole = (typeof USER_ROLE_VALUES)[number];
|
|
|
|
function getSecret() {
|
|
const secret = process.env.SESSION_SECRET;
|
|
if (!secret) {
|
|
throw new Error("SESSION_SECRET is required");
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
export type SessionPayload = {
|
|
role: UserRole;
|
|
iat: number;
|
|
exp?: number;
|
|
uid?: string;
|
|
name?: string;
|
|
username?: string;
|
|
};
|
|
|
|
export function getAdminName() {
|
|
return process.env.ADMIN_NAME?.trim() || "Admin";
|
|
}
|
|
|
|
export function isAdminName(name?: string | null) {
|
|
const adminName = getAdminName().trim().toLowerCase();
|
|
const value = name?.trim().toLowerCase();
|
|
return Boolean(adminName && value && adminName === value);
|
|
}
|
|
|
|
export function resolveUserRole(value?: unknown): UserRole | null {
|
|
return USER_ROLE_VALUES.includes(value as UserRole) ? (value as UserRole) : null;
|
|
}
|
|
|
|
export function isAdminSession(session?: SessionPayload | null) {
|
|
return session?.role === "admin";
|
|
}
|
|
|
|
async function getHmacKey(secret: string) {
|
|
if (cachedKey && cachedSecret === secret) {
|
|
return cachedKey;
|
|
}
|
|
|
|
cachedSecret = secret;
|
|
cachedKey = await crypto.subtle.importKey(
|
|
"raw",
|
|
encoder.encode(secret),
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"]
|
|
);
|
|
return cachedKey;
|
|
}
|
|
|
|
async function hmacSha256(data: string, secret: string): Promise<string> {
|
|
const key = await getHmacKey(secret);
|
|
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
return Buffer.from(sig).toString("base64url");
|
|
}
|
|
|
|
export async function signSession(payload: SessionPayload): Promise<string> {
|
|
const secret = getSecret();
|
|
const base = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
const sig = await hmacSha256(base, secret);
|
|
return `${base}.${sig}`;
|
|
}
|
|
|
|
export async function verifySession(token?: string): Promise<SessionPayload | null> {
|
|
if (!token) return null;
|
|
|
|
const secret = getSecret();
|
|
const [base, sig] = token.split(".");
|
|
if (!base || !sig) return null;
|
|
|
|
const check = await hmacSha256(base, secret);
|
|
if (check !== sig) return null;
|
|
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(base, "base64url").toString());
|
|
if (!resolveUserRole(payload?.role)) {
|
|
return null;
|
|
}
|
|
if (typeof payload?.exp !== "number") {
|
|
return null;
|
|
}
|
|
if (Date.now() > payload.exp) {
|
|
return null;
|
|
}
|
|
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function requireAdminFromRequest(req: NextRequest): Promise<boolean> {
|
|
const token = req.cookies.get(COOKIE_NAME)?.value;
|
|
const session = await verifySession(token);
|
|
return isAdminSession(session);
|
|
}
|
|
|
|
export function setAdminCookie(token: string) {
|
|
const isProd = process.env.NODE_ENV === "production";
|
|
cookies().set(COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: isProd,
|
|
maxAge: 60 * 60 * 24
|
|
});
|
|
}
|
|
|
|
export const cookieName = COOKIE_NAME;
|