Files
ccs/src/web-server/middleware/auth-middleware.ts
T

166 lines
4.3 KiB
TypeScript

/**
* Dashboard Authentication Middleware
* Session-based auth with httpOnly cookies for CCS dashboard.
*/
import type { Request, Response, NextFunction } from 'express';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { getCcsDir } from '../../utils/config-manager';
// Extend Express Request with session
declare module 'express-session' {
interface SessionData {
authenticated: boolean;
username: string;
}
}
/** Public paths that bypass auth (lowercase for case-insensitive matching) */
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/check', '/api/auth/setup', '/api/health'];
/** Path to persistent session secret file */
function getSessionSecretPath() {
return path.join(getCcsDir(), '.session-secret');
}
/**
* Generate or retrieve persistent session secret.
* Priority: ENV var > persisted file > generate new
*/
function getSessionSecret(): string {
// 1. Check ENV var first
if (process.env.CCS_SESSION_SECRET) {
return process.env.CCS_SESSION_SECRET;
}
const secretPath = getSessionSecretPath();
// 2. Try to read persisted secret
try {
if (fs.existsSync(secretPath)) {
const secret = fs.readFileSync(secretPath, 'utf-8').trim();
if (secret.length >= 32) {
return secret;
}
}
} catch {
// Ignore read errors, generate new secret
}
// 3. Generate and persist new random secret
const newSecret = crypto.randomBytes(32).toString('hex');
try {
const dir = path.dirname(secretPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(secretPath, newSecret, { mode: 0o600 });
} catch (err) {
// Log warning - sessions won't persist across restarts
console.warn('[!] Failed to persist session secret:', (err as Error).message);
}
return newSecret;
}
/**
* Rate limiter for login attempts.
* 5 attempts per 15 minutes per IP.
*/
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts. Please try again later.' },
standardHeaders: true,
legacyHeaders: false,
skip: () => !isDashboardAuthEnabled(),
});
/**
* Create session middleware configured for CCS dashboard.
*/
export function createSessionMiddleware(): (
req: Request,
res: Response,
next: NextFunction
) => void {
const authConfig = getDashboardAuthConfig();
const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000;
return session({
secret: getSessionSecret(),
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // Local CLI uses HTTP
httpOnly: true,
maxAge,
sameSite: 'strict',
},
});
}
/**
* Auth middleware that protects all routes except public paths.
* Only active when dashboard_auth.enabled = true.
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// Skip auth if disabled
if (!isDashboardAuthEnabled()) {
return next();
}
// Allow public paths (case-insensitive)
const pathLower = req.path.toLowerCase();
if (PUBLIC_PATHS.some((p) => pathLower.startsWith(p))) {
return next();
}
// Allow static assets and SPA routes (non-API)
if (!req.path.startsWith('/api/')) {
return next();
}
// Check session
if (req.session?.authenticated) {
return next();
}
// Unauthorized
res.status(401).json({ error: 'Authentication required' });
}
export function isLoopbackRemoteAddress(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().replace(/^\[|\]$/g, '');
return (
normalized === '::1' ||
normalized === '127.0.0.1' ||
normalized.startsWith('127.') ||
normalized === '::ffff:127.0.0.1' ||
normalized.startsWith('::ffff:127.')
);
}
export function requireLocalAccessWhenAuthDisabled(
req: Request,
res: Response,
error = 'This endpoint requires localhost access when dashboard auth is disabled.'
): boolean {
if (isDashboardAuthEnabled()) {
return true;
}
if (isLoopbackRemoteAddress(req.socket.remoteAddress)) {
return true;
}
res.status(403).json({ error });
return false;
}