Files
portfolio/lib/auth.ts
denshooter 0349c686fa feat(auth): implement session token creation and verification for enhanced security
feat(api): require session authentication for admin routes and improve error handling

fix(api): streamline project image generation by fetching data directly from the database

fix(api): optimize project import/export functionality with session validation and improved error handling

fix(api): enhance analytics dashboard and email manager with session token for admin requests

fix(components): improve loading states and dynamic imports for better user experience

chore(security): update Content Security Policy to avoid unsafe-eval in production

chore(deps): update package.json scripts for consistent environment handling in linting and testing
2026-01-12 00:27:03 +01:00

228 lines
7.0 KiB
TypeScript

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<string, { count: number; resetTime: number }>();
// 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<string, string> {
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()
};
}