mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 16:16:52 +00:00
166 lines
4.3 KiB
TypeScript
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;
|
|
}
|