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
This commit is contained in:
147
lib/auth.ts
147
lib/auth.ts
@@ -1,4 +1,117 @@
|
||||
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 {
|
||||
@@ -11,14 +124,14 @@ export function verifyAdminAuth(request: NextRequest): boolean {
|
||||
|
||||
try {
|
||||
const base64Credentials = authHeader.split(' ')[1];
|
||||
const credentials = atob(base64Credentials);
|
||||
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8');
|
||||
const [username, password] = credentials.split(':');
|
||||
|
||||
// Get admin credentials from environment
|
||||
const adminAuth = process.env.ADMIN_BASIC_AUTH || 'admin:default_password_change_me';
|
||||
const [expectedUsername, expectedPassword] = adminAuth.split(':');
|
||||
const creds = getAdminCredentials();
|
||||
if (!creds) return false;
|
||||
|
||||
return username === expectedUsername && password === expectedPassword;
|
||||
return username === creds.username && password === creds.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -46,31 +159,7 @@ export function verifySessionAuth(request: NextRequest): boolean {
|
||||
if (!sessionToken) return false;
|
||||
|
||||
try {
|
||||
// Decode and validate session token
|
||||
const decodedJson = atob(sessionToken);
|
||||
const sessionData = JSON.parse(decodedJson);
|
||||
|
||||
// Validate session data structure
|
||||
if (!sessionData.timestamp || !sessionData.random || !sessionData.ip || !sessionData.userAgent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if session is still valid (2 hours)
|
||||
const sessionTime = sessionData.timestamp;
|
||||
const now = Date.now();
|
||||
const sessionDuration = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
if (now - sessionTime > sessionDuration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate IP address (optional, but good security practice)
|
||||
const currentIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (sessionData.ip !== currentIp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return verifySessionToken(request, sessionToken);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ export const apiCache = {
|
||||
if (page !== '1') keyParts.push(`page:${page}`);
|
||||
if (limit !== '50') keyParts.push(`limit:${limit}`);
|
||||
if (category) keyParts.push(`cat:${category}`);
|
||||
if (featured !== null) keyParts.push(`feat:${featured}`);
|
||||
if (published !== null) keyParts.push(`pub:${published}`);
|
||||
// Avoid cache fragmentation like `feat:undefined` when params omit the field
|
||||
if (featured != null) keyParts.push(`feat:${featured}`);
|
||||
if (published != null) keyParts.push(`pub:${published}`);
|
||||
if (difficulty) keyParts.push(`diff:${difficulty}`);
|
||||
if (search) keyParts.push(`search:${search}`);
|
||||
|
||||
|
||||
@@ -159,14 +159,16 @@ export const projectService = {
|
||||
prisma.userInteraction.groupBy({
|
||||
by: ['type'],
|
||||
where: { projectId },
|
||||
_count: { _all: true },
|
||||
})
|
||||
]);
|
||||
|
||||
const analytics: Record<string, number> = { views: pageViews, likes: 0, shares: 0 };
|
||||
|
||||
interactions.forEach(interaction => {
|
||||
if (interaction.type === 'LIKE') analytics.likes = 0;
|
||||
if (interaction.type === 'SHARE') analytics.shares = 0;
|
||||
const count = (interaction as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
||||
if (interaction.type === 'LIKE') analytics.likes = count;
|
||||
if (interaction.type === 'SHARE') analytics.shares = count;
|
||||
});
|
||||
|
||||
return analytics;
|
||||
|
||||
Reference in New Issue
Block a user