first commit
This commit is contained in:
commit
d15a40c33d
37
package.json
Normal file
37
package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "openidv2-secure-proxy",
|
||||
"version": "1.0.0",
|
||||
"description": "Reverse proxy sécurisé avec OIDC (Keycloak) et panel admin",
|
||||
"main": "src/server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js",
|
||||
"init-db": "node scripts/initDb.js",
|
||||
"seed-db": "node scripts/seedDb.js"
|
||||
},
|
||||
"keywords": ["reverse-proxy", "oidc", "keycloak", "admin-panel"],
|
||||
"author": "Alexandre",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"openid-client": "^5.5.0",
|
||||
"express-session": "^1.17.3",
|
||||
"session-file-store": "^1.5.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"sqlite": "^5.0.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"csurf": "^1.11.0",
|
||||
"validator": "^13.11.0",
|
||||
"axios": "^1.6.2",
|
||||
"cors": "^2.8.5",
|
||||
"body-parser": "^1.20.2",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
42
src/config.js
Normal file
42
src/config.js
Normal file
@ -0,0 +1,42 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: process.env.PORT || 3000,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
proxyUrl: process.env.PROXY_URL || 'https://secure.k2r.ovh',
|
||||
|
||||
// OIDC Configuration
|
||||
oidc: {
|
||||
issuer: process.env.OIDC_ISSUER,
|
||||
clientId: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
redirectUri: process.env.OIDC_CALLBACK_URL,
|
||||
},
|
||||
|
||||
// Admin Configuration
|
||||
admin: {
|
||||
username: process.env.ADMIN_USERNAME || 'admin',
|
||||
password: process.env.ADMIN_PASSWORD || 'changeme',
|
||||
},
|
||||
|
||||
// Database
|
||||
db: {
|
||||
path: process.env.DB_PATH || './db/services.db',
|
||||
},
|
||||
|
||||
// Session
|
||||
sessionSecret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
|
||||
|
||||
// Security
|
||||
rateLimit: {
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'),
|
||||
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'),
|
||||
},
|
||||
|
||||
// Logging
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
};
|
||||
|
||||
export default config;
|
||||
37
src/controllers/adminController.js
Normal file
37
src/controllers/adminController.js
Normal file
@ -0,0 +1,37 @@
|
||||
import serviceManager from '../services/serviceManager.js';
|
||||
|
||||
export async function getAuditLogs(req, res) {
|
||||
try {
|
||||
const { limit = 100, offset = 0 } = req.query;
|
||||
|
||||
const logs = await serviceManager.getAuditLogs(
|
||||
parseInt(limit),
|
||||
parseInt(offset)
|
||||
);
|
||||
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
console.error('Get audit logs error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDashboardStats(req, res) {
|
||||
try {
|
||||
const services = await serviceManager.getAllServices();
|
||||
const enabledServices = services.filter(s => s.enabled).length;
|
||||
|
||||
// Get recent logs
|
||||
const { logs: recentLogs } = await serviceManager.getAuditLogs(50, 0);
|
||||
|
||||
res.json({
|
||||
totalServices: services.length,
|
||||
enabledServices,
|
||||
disabledServices: services.length - enabledServices,
|
||||
recentActivity: recentLogs.slice(0, 10),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dashboard stats error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
110
src/controllers/authController.js
Normal file
110
src/controllers/authController.js
Normal file
@ -0,0 +1,110 @@
|
||||
import { getAuthorizationUrl, handleCallback, logout } from '../middleware/oidcMiddleware.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export async function loginPage(req, res) {
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Login - Secure Proxy</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
text-align: center;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.login-button:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<h1>Login Required</h1>
|
||||
<a href="/auth/login" class="login-button">Login with Keycloak</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
export async function authLogin(req, res) {
|
||||
try {
|
||||
const authUrl = getAuthorizationUrl(req);
|
||||
res.redirect(authUrl);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).send('Authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function authCallback(req, res) {
|
||||
try {
|
||||
const { tokenSet, userInfo } = await handleCallback(req);
|
||||
|
||||
req.session.tokenSet = tokenSet;
|
||||
req.session.user = {
|
||||
sub: userInfo.sub,
|
||||
name: userInfo.name,
|
||||
email: userInfo.email,
|
||||
isAdmin: userInfo.email && config.admin.username === userInfo.email,
|
||||
};
|
||||
|
||||
const redirectUrl = req.session.redirectUrl || '/';
|
||||
delete req.session.redirectUrl;
|
||||
|
||||
res.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error);
|
||||
res.status(401).send('Authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function authLogout(req, res, next) {
|
||||
logout(req, res, next);
|
||||
}
|
||||
|
||||
export function userProfile(req, res) {
|
||||
if (!req.session?.user) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: req.session.user,
|
||||
});
|
||||
}
|
||||
140
src/controllers/serviceController.js
Normal file
140
src/controllers/serviceController.js
Normal file
@ -0,0 +1,140 @@
|
||||
import serviceManager from '../services/serviceManager.js';
|
||||
|
||||
export async function createService(req, res) {
|
||||
try {
|
||||
const { name, path, targetUrl, requireAuth, description } = req.body;
|
||||
|
||||
if (!name || !path || !targetUrl) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: name, path, targetUrl',
|
||||
});
|
||||
}
|
||||
|
||||
const service = await serviceManager.createService({
|
||||
name,
|
||||
path,
|
||||
targetUrl,
|
||||
requireAuth: requireAuth !== false,
|
||||
description,
|
||||
});
|
||||
|
||||
const userId = req.session?.user?.sub;
|
||||
const ipAddress = req.ip;
|
||||
await serviceManager.logAudit('SERVICE_CREATED', userId, service.id, ipAddress, {
|
||||
name,
|
||||
path,
|
||||
targetUrl,
|
||||
});
|
||||
|
||||
res.status(201).json(service);
|
||||
} catch (error) {
|
||||
console.error('Create service error:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getService(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const service = await serviceManager.getService(id);
|
||||
if (!service) {
|
||||
return res.status(404).json({ error: 'Service not found' });
|
||||
}
|
||||
|
||||
res.json(service);
|
||||
} catch (error) {
|
||||
console.error('Get service error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function listServices(req, res) {
|
||||
try {
|
||||
const services = await serviceManager.getAllServices();
|
||||
res.json(services);
|
||||
} catch (error) {
|
||||
console.error('List services error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateService(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const service = await serviceManager.updateService(id, updates);
|
||||
|
||||
const userId = req.session?.user?.sub;
|
||||
const ipAddress = req.ip;
|
||||
await serviceManager.logAudit('SERVICE_UPDATED', userId, id, ipAddress, updates);
|
||||
|
||||
res.json(service);
|
||||
} catch (error) {
|
||||
console.error('Update service error:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteService(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await serviceManager.deleteService(id);
|
||||
|
||||
const userId = req.session?.user?.sub;
|
||||
const ipAddress = req.ip;
|
||||
await serviceManager.logAudit('SERVICE_DELETED', userId, id, ipAddress, {});
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Delete service error:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleService(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled field must be boolean' });
|
||||
}
|
||||
|
||||
const service = await serviceManager.toggleService(id, enabled);
|
||||
|
||||
const userId = req.session?.user?.sub;
|
||||
const ipAddress = req.ip;
|
||||
await serviceManager.logAudit('SERVICE_TOGGLED', userId, id, ipAddress, { enabled });
|
||||
|
||||
res.json(service);
|
||||
} catch (error) {
|
||||
console.error('Toggle service error:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServiceLogs(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { limit = 100, offset = 0 } = req.query;
|
||||
|
||||
const service = await serviceManager.getService(id);
|
||||
if (!service) {
|
||||
return res.status(404).json({ error: 'Service not found' });
|
||||
}
|
||||
|
||||
const logs = await serviceManager.getAccessLogs(
|
||||
id,
|
||||
parseInt(limit),
|
||||
parseInt(offset)
|
||||
);
|
||||
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
console.error('Get logs error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
89
src/db.js
Normal file
89
src/db.js
Normal file
@ -0,0 +1,89 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let db = null;
|
||||
|
||||
export async function initDatabase(dbPath) {
|
||||
if (db) return db;
|
||||
|
||||
db = await open({
|
||||
filename: dbPath,
|
||||
driver: sqlite3.Database,
|
||||
});
|
||||
|
||||
await db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create services table
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
target_url TEXT NOT NULL,
|
||||
require_auth BOOLEAN DEFAULT 1,
|
||||
description TEXT,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create audit logs table
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
service_id TEXT,
|
||||
ip_address TEXT,
|
||||
details TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Create access logs table
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS access_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
path TEXT,
|
||||
method TEXT,
|
||||
status_code INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
ip_address TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes for better query performance
|
||||
await db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_services_path ON services(path);
|
||||
CREATE INDEX IF NOT EXISTS idx_services_enabled ON services(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON audit_logs(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_logs_service ON access_logs(service_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_logs_timestamp ON access_logs(timestamp);
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function getDatabase() {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized. Call initDatabase first.');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeDatabase() {
|
||||
if (db) {
|
||||
await db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
101
src/middleware/oidcMiddleware.js
Normal file
101
src/middleware/oidcMiddleware.js
Normal file
@ -0,0 +1,101 @@
|
||||
import { Issuer } from 'openid-client';
|
||||
import config from '../config.js';
|
||||
|
||||
let client = null;
|
||||
|
||||
export async function initOIDC() {
|
||||
try {
|
||||
const issuer = await Issuer.discover(config.oidc.issuer);
|
||||
|
||||
client = new issuer.Client({
|
||||
client_id: config.oidc.clientId,
|
||||
client_secret: config.oidc.clientSecret,
|
||||
redirect_uris: [config.oidc.redirectUri],
|
||||
response_types: ['code'],
|
||||
});
|
||||
|
||||
console.log('✓ OIDC Client initialized successfully');
|
||||
return client;
|
||||
} catch (error) {
|
||||
console.error('✗ Failed to initialize OIDC client:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOIDCClient() {
|
||||
if (!client) {
|
||||
throw new Error('OIDC Client not initialized. Call initOIDC first.');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getAuthorizationUrl(req) {
|
||||
const client = getOIDCClient();
|
||||
const nonce = Math.random().toString(36).substring(7);
|
||||
req.session.nonce = nonce;
|
||||
|
||||
return client.authorizationUrl({
|
||||
scope: 'openid profile email',
|
||||
response_mode: 'form_post',
|
||||
nonce,
|
||||
state: Math.random().toString(36).substring(7),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCallback(req) {
|
||||
const client = getOIDCClient();
|
||||
const params = {
|
||||
...req.query,
|
||||
...req.body,
|
||||
};
|
||||
|
||||
const tokenSet = await client.callback(config.oidc.redirectUri, params, {
|
||||
nonce: req.session.nonce,
|
||||
});
|
||||
|
||||
const userInfo = await client.userinfo(tokenSet);
|
||||
|
||||
return {
|
||||
tokenSet,
|
||||
userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.user) {
|
||||
return next();
|
||||
}
|
||||
|
||||
req.session.redirectUrl = req.originalUrl;
|
||||
res.redirect('/login');
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.session && req.session.user && req.session.user.isAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
export function logout(req, res, next) {
|
||||
const client = getOIDCClient();
|
||||
const idToken = req.session.tokenSet?.id_token;
|
||||
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (idToken && client.issuer.metadata.end_session_endpoint) {
|
||||
const logoutUrl = client.issuer.metadata.end_session_endpoint;
|
||||
const postLogoutRedirectUri = `${config.proxyUrl}/`;
|
||||
|
||||
res.redirect(
|
||||
`${logoutUrl}?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}`
|
||||
);
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
});
|
||||
}
|
||||
70
src/middleware/proxyMiddleware.js
Normal file
70
src/middleware/proxyMiddleware.js
Normal file
@ -0,0 +1,70 @@
|
||||
import httpProxy from 'http-proxy';
|
||||
import serviceManager from '../services/serviceManager.js';
|
||||
|
||||
const proxy = httpProxy.createProxyServer({
|
||||
changeOrigin: true,
|
||||
followRedirects: true,
|
||||
timeout: 30000,
|
||||
proxyTimeout: 30000,
|
||||
});
|
||||
|
||||
// Proxy error handler
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.error('Proxy error:', err);
|
||||
res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'The target service is currently unreachable',
|
||||
});
|
||||
});
|
||||
|
||||
export async function reverseProxyMiddleware(req, res, next) {
|
||||
const path = req.path;
|
||||
|
||||
try {
|
||||
// Find matching service by path
|
||||
const service = await serviceManager.getServiceByPath(path);
|
||||
|
||||
if (!service) {
|
||||
return res.status(404).json({ error: 'Service not found' });
|
||||
}
|
||||
|
||||
// Check if service requires authentication
|
||||
if (service.requireAuth && !req.session?.user) {
|
||||
req.session.redirectUrl = req.originalUrl;
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
// Log access
|
||||
const startTime = Date.now();
|
||||
const userId = req.session?.user?.sub || null;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
|
||||
// Proxy the request
|
||||
req.url = req.originalUrl.replace(service.path, '');
|
||||
|
||||
proxy.web(req, res, { target: service.targetUrl }, (err) => {
|
||||
if (err) {
|
||||
console.error('Proxy error for service:', service.id, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Log response
|
||||
res.on('finish', async () => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
await serviceManager.logAccess(
|
||||
service.id,
|
||||
userId,
|
||||
req.originalUrl,
|
||||
req.method,
|
||||
res.statusCode,
|
||||
responseTime,
|
||||
ipAddress
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in reverse proxy middleware:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
export default reverseProxyMiddleware;
|
||||
66
src/middleware/security.js
Normal file
66
src/middleware/security.js
Normal file
@ -0,0 +1,66 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import csrf from 'csurf';
|
||||
import config from '../config.js';
|
||||
|
||||
// Rate limiter for general API requests
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: config.rateLimit.windowMs,
|
||||
max: config.rateLimit.maxRequests,
|
||||
message: 'Too many requests, please try again later',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Stricter rate limiter for auth endpoints
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5,
|
||||
message: 'Too many login attempts, please try again later',
|
||||
skipSuccessfulRequests: true,
|
||||
});
|
||||
|
||||
// Security headers middleware
|
||||
export function securityHeaders(req, res, next) {
|
||||
helmet()(req, res, next);
|
||||
}
|
||||
|
||||
// CSRF protection
|
||||
export const csrfProtection = csrf({ cookie: false });
|
||||
|
||||
// Request logging
|
||||
export function requestLogger(req, res, next) {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Error handler middleware
|
||||
export function errorHandler(err, req, res, next) {
|
||||
console.error('Error:', err);
|
||||
|
||||
if (err.code === 'EBADCSRFTOKEN') {
|
||||
return res.status(403).json({ error: 'Invalid CSRF token' });
|
||||
}
|
||||
|
||||
if (err.message && err.message.includes('OIDC')) {
|
||||
return res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal server error',
|
||||
});
|
||||
}
|
||||
|
||||
// Middleware to attach CSRF token to response
|
||||
export function attachCsrfToken(req, res, next) {
|
||||
res.locals.csrfToken = req.csrfToken();
|
||||
next();
|
||||
}
|
||||
192
src/services/serviceManager.js
Normal file
192
src/services/serviceManager.js
Normal file
@ -0,0 +1,192 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDatabase } from '../db.js';
|
||||
|
||||
export class ServiceManager {
|
||||
async createService(serviceData) {
|
||||
const db = await getDatabase();
|
||||
const id = uuidv4();
|
||||
|
||||
const { name, path, targetUrl, requireAuth = true, description = '' } = serviceData;
|
||||
|
||||
// Validation
|
||||
if (!name || !path || !targetUrl) {
|
||||
throw new Error('Missing required fields: name, path, targetUrl');
|
||||
}
|
||||
|
||||
if (!/^\/[a-zA-Z0-9\-_]*$/.test(path)) {
|
||||
throw new Error('Invalid path format. Must start with / and contain only alphanumeric, -, _');
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const existingPath = await db.get('SELECT id FROM services WHERE path = ?', [path]);
|
||||
if (existingPath) {
|
||||
throw new Error(`Service with path "${path}" already exists`);
|
||||
}
|
||||
|
||||
const existingName = await db.get('SELECT id FROM services WHERE name = ?', [name]);
|
||||
if (existingName) {
|
||||
throw new Error(`Service with name "${name}" already exists`);
|
||||
}
|
||||
|
||||
await db.run(
|
||||
`INSERT INTO services (id, name, path, target_url, require_auth, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[id, name, path, targetUrl, requireAuth ? 1 : 0, description]
|
||||
);
|
||||
|
||||
return { id, name, path, targetUrl, requireAuth, description };
|
||||
}
|
||||
|
||||
async getService(id) {
|
||||
const db = await getDatabase();
|
||||
const service = await db.get('SELECT * FROM services WHERE id = ?', [id]);
|
||||
return service ? this._formatService(service) : null;
|
||||
}
|
||||
|
||||
async getServiceByPath(path) {
|
||||
const db = await getDatabase();
|
||||
const service = await db.get('SELECT * FROM services WHERE path = ? AND enabled = 1', [path]);
|
||||
return service ? this._formatService(service) : null;
|
||||
}
|
||||
|
||||
async getAllServices(enabledOnly = false) {
|
||||
const db = await getDatabase();
|
||||
let query = 'SELECT * FROM services';
|
||||
if (enabledOnly) {
|
||||
query += ' WHERE enabled = 1';
|
||||
}
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
const services = await db.all(query);
|
||||
return services.map(s => this._formatService(s));
|
||||
}
|
||||
|
||||
async updateService(id, updates) {
|
||||
const db = await getDatabase();
|
||||
|
||||
const service = await this.getService(id);
|
||||
if (!service) {
|
||||
throw new Error(`Service with id "${id}" not found`);
|
||||
}
|
||||
|
||||
// Check if path is being changed and ensure it's unique
|
||||
if (updates.path && updates.path !== service.path) {
|
||||
const existing = await db.get('SELECT id FROM services WHERE path = ?', [updates.path]);
|
||||
if (existing) {
|
||||
throw new Error(`Service with path "${updates.path}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedFields = ['name', 'path', 'target_url', 'require_auth', 'description', 'enabled'];
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const dbKey = key === 'targetUrl' ? 'target_url' : key === 'requireAuth' ? 'require_auth' : key;
|
||||
if (allowedFields.includes(dbKey)) {
|
||||
setClauses.push(`${dbKey} = ?`);
|
||||
values.push(key === 'requireAuth' || key === 'require_auth' || key === 'enabled' ? (value ? 1 : 0) : value);
|
||||
}
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error('No valid fields to update');
|
||||
}
|
||||
|
||||
setClauses.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(id);
|
||||
|
||||
await db.run(
|
||||
`UPDATE services SET ${setClauses.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
return this.getService(id);
|
||||
}
|
||||
|
||||
async deleteService(id) {
|
||||
const db = await getDatabase();
|
||||
|
||||
const service = await this.getService(id);
|
||||
if (!service) {
|
||||
throw new Error(`Service with id "${id}" not found`);
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM services WHERE id = ?', [id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async toggleService(id, enabled) {
|
||||
return this.updateService(id, { enabled });
|
||||
}
|
||||
|
||||
async logAccess(serviceId, userId, path, method, statusCode, responseTime, ipAddress) {
|
||||
const db = await getDatabase();
|
||||
|
||||
await db.run(
|
||||
`INSERT INTO access_logs (service_id, user_id, path, method, status_code, response_time_ms, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[serviceId, userId || null, path, method, statusCode, responseTime, ipAddress]
|
||||
);
|
||||
}
|
||||
|
||||
async logAudit(action, userId, serviceId, ipAddress, details = {}) {
|
||||
const db = await getDatabase();
|
||||
|
||||
await db.run(
|
||||
`INSERT INTO audit_logs (action, user_id, service_id, ip_address, details)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[action, userId || null, serviceId || null, ipAddress, JSON.stringify(details)]
|
||||
);
|
||||
}
|
||||
|
||||
async getAccessLogs(serviceId, limit = 100, offset = 0) {
|
||||
const db = await getDatabase();
|
||||
|
||||
const logs = await db.all(
|
||||
`SELECT * FROM access_logs
|
||||
WHERE service_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[serviceId, limit, offset]
|
||||
);
|
||||
|
||||
const total = await db.get(
|
||||
'SELECT COUNT(*) as count FROM access_logs WHERE service_id = ?',
|
||||
[serviceId]
|
||||
);
|
||||
|
||||
return { logs, total: total.count };
|
||||
}
|
||||
|
||||
async getAuditLogs(limit = 100, offset = 0) {
|
||||
const db = await getDatabase();
|
||||
|
||||
const logs = await db.all(
|
||||
`SELECT * FROM audit_logs
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
const total = await db.get('SELECT COUNT(*) as count FROM audit_logs');
|
||||
|
||||
return { logs, total: total.count };
|
||||
}
|
||||
|
||||
_formatService(service) {
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
path: service.path,
|
||||
targetUrl: service.target_url,
|
||||
requireAuth: Boolean(service.require_auth),
|
||||
description: service.description,
|
||||
enabled: Boolean(service.enabled),
|
||||
createdAt: service.created_at,
|
||||
updatedAt: service.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServiceManager();
|
||||
Loading…
x
Reference in New Issue
Block a user