import { NextRequest } from 'next/server'; import crypto from 'crypto'; const DEFAULT_INSECURE_ADMIN = 'admin:default_password_change_me'; const SESSION_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours function base64UrlEncode(input: string | Buffer): string { const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input; return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function base64UrlDecodeToString(input: string): string { const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); return Buffer.from(normalized + pad, 'base64').toString('utf8'); } function base64UrlDecodeToBuffer(input: string): Buffer { const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); return Buffer.from(normalized + pad, 'base64'); } export function getClientIp(request: NextRequest): string { const xff = request.headers.get('x-forwarded-for'); if (xff) { // x-forwarded-for can be a list: client, proxy1, proxy2 return xff.split(',')[0]?.trim() || 'unknown'; } return request.headers.get('x-real-ip') || 'unknown'; } function getAdminCredentials(): { username: string; password: string } | null { const raw = process.env.ADMIN_BASIC_AUTH; if (!raw || raw.trim() === '' || raw === DEFAULT_INSECURE_ADMIN) return null; const idx = raw.indexOf(':'); if (idx <= 0 || idx === raw.length - 1) return null; return { username: raw.slice(0, idx), password: raw.slice(idx + 1) }; } function getSessionSecret(): string | null { const secret = process.env.ADMIN_SESSION_SECRET; if (!secret || secret.trim().length < 32) return null; // require a reasonably strong secret return secret; } type SessionPayload = { v: 1; iat: number; rnd: string; ip: string; ua: string; }; export function createSessionToken(request: NextRequest): string | null { const secret = getSessionSecret(); if (!secret) return null; const payload: SessionPayload = { v: 1, iat: Date.now(), rnd: crypto.randomBytes(32).toString('hex'), ip: getClientIp(request), ua: request.headers.get('user-agent') || 'unknown', }; const payloadB64 = base64UrlEncode(JSON.stringify(payload)); const sig = crypto.createHmac('sha256', secret).update(payloadB64).digest(); const sigB64 = base64UrlEncode(sig); return `${payloadB64}.${sigB64}`; } export function verifySessionToken(request: NextRequest, token: string): boolean { const secret = getSessionSecret(); if (!secret) return false; const parts = token.split('.'); if (parts.length !== 2) return false; const [payloadB64, sigB64] = parts; if (!payloadB64 || !sigB64) return false; let providedSigBytes: Buffer; try { providedSigBytes = base64UrlDecodeToBuffer(sigB64); } catch { return false; } const expectedSigBytes = crypto.createHmac('sha256', secret).update(payloadB64).digest(); if (providedSigBytes.length !== expectedSigBytes.length) return false; if (!crypto.timingSafeEqual(providedSigBytes, expectedSigBytes)) return false; let payload: SessionPayload; try { payload = JSON.parse(base64UrlDecodeToString(payloadB64)) as SessionPayload; } catch { return false; } if (!payload || payload.v !== 1 || typeof payload.iat !== 'number' || typeof payload.rnd !== 'string') { return false; } const now = Date.now(); if (now - payload.iat > SESSION_DURATION_MS) return false; // Bind token to client IP + UA (best-effort; "unknown" should not hard-fail) const currentIp = getClientIp(request); const currentUa = request.headers.get('user-agent') || 'unknown'; if (payload.ip !== 'unknown' && currentIp !== 'unknown' && payload.ip !== currentIp) return false; if (payload.ua !== 'unknown' && currentUa !== 'unknown' && payload.ua !== currentUa) return false; return true; } // Server-side authentication utilities export function verifyAdminAuth(request: NextRequest): boolean { // Check for basic auth header const authHeader = request.headers.get('authorization'); if (!authHeader || !authHeader.startsWith('Basic ')) { return false; } try { const base64Credentials = authHeader.split(' ')[1]; const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8'); const [username, password] = credentials.split(':'); // Get admin credentials from environment const creds = getAdminCredentials(); if (!creds) return false; return username === creds.username && password === creds.password; } catch { return false; } } export function requireAdminAuth(request: NextRequest): Response | null { if (!verifyAdminAuth(request)) { return new Response( JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } return null; } // Session-based authentication (no browser popup) export function verifySessionAuth(request: NextRequest): boolean { // Check for session token in headers const sessionToken = request.headers.get('x-session-token'); if (!sessionToken) return false; try { return verifySessionToken(request, sessionToken); } catch { return false; } } export function requireSessionAuth(request: NextRequest): Response | null { if (!verifySessionAuth(request)) { return new Response( JSON.stringify({ error: 'Session expired or invalid' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } return null; } // Rate limiting for admin endpoints const rateLimitMap = new Map(); // Clear rate limit cache on startup if (typeof window === 'undefined') { // Server-side: clear cache periodically setInterval(() => { const now = Date.now(); for (const [key, value] of rateLimitMap.entries()) { if (now > value.resetTime) { rateLimitMap.delete(key); } } }, 60000); // Clear every minute } export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean { const now = Date.now(); const key = `admin_${ip}`; const current = rateLimitMap.get(key); if (!current || now > current.resetTime) { rateLimitMap.set(key, { count: 1, resetTime: now + windowMs }); return true; } if (current.count >= maxRequests) { return false; } current.count++; return true; } export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record { const current = rateLimitMap.get(`admin_${ip}`); const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests; return { 'X-RateLimit-Limit': maxRequests.toString(), 'X-RateLimit-Remaining': remaining.toString(), 'X-RateLimit-Reset': current ? Math.ceil(current.resetTime / 1000).toString() : Math.ceil((Date.now() + windowMs) / 1000).toString() }; }