From d15a40c33dc1aeaa38345f49df966ce150f8a209 Mon Sep 17 00:00:00 2001 From: Alexandre KIENTZ Date: Wed, 3 Dec 2025 21:26:14 +0100 Subject: [PATCH] first commit --- package.json | 37 ++++++ src/config.js | 42 ++++++ src/controllers/adminController.js | 37 ++++++ src/controllers/authController.js | 110 +++++++++++++++ src/controllers/serviceController.js | 140 +++++++++++++++++++ src/db.js | 89 +++++++++++++ src/middleware/oidcMiddleware.js | 101 ++++++++++++++ src/middleware/proxyMiddleware.js | 70 ++++++++++ src/middleware/security.js | 66 +++++++++ src/services/serviceManager.js | 192 +++++++++++++++++++++++++++ 10 files changed, 884 insertions(+) create mode 100644 package.json create mode 100644 src/config.js create mode 100644 src/controllers/adminController.js create mode 100644 src/controllers/authController.js create mode 100644 src/controllers/serviceController.js create mode 100644 src/db.js create mode 100644 src/middleware/oidcMiddleware.js create mode 100644 src/middleware/proxyMiddleware.js create mode 100644 src/middleware/security.js create mode 100644 src/services/serviceManager.js diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f334a7 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..ddaf985 --- /dev/null +++ b/src/config.js @@ -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; diff --git a/src/controllers/adminController.js b/src/controllers/adminController.js new file mode 100644 index 0000000..ed6c397 --- /dev/null +++ b/src/controllers/adminController.js @@ -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 }); + } +} diff --git a/src/controllers/authController.js b/src/controllers/authController.js new file mode 100644 index 0000000..643c1ca --- /dev/null +++ b/src/controllers/authController.js @@ -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(` + + + + Login - Secure Proxy + + + +
+

Login Required

+ +
+ + + `); +} + +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, + }); +} diff --git a/src/controllers/serviceController.js b/src/controllers/serviceController.js new file mode 100644 index 0000000..eccd914 --- /dev/null +++ b/src/controllers/serviceController.js @@ -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 }); + } +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..803a184 --- /dev/null +++ b/src/db.js @@ -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; + } +} diff --git a/src/middleware/oidcMiddleware.js b/src/middleware/oidcMiddleware.js new file mode 100644 index 0000000..9535199 --- /dev/null +++ b/src/middleware/oidcMiddleware.js @@ -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('/'); + } + }); +} diff --git a/src/middleware/proxyMiddleware.js b/src/middleware/proxyMiddleware.js new file mode 100644 index 0000000..da43003 --- /dev/null +++ b/src/middleware/proxyMiddleware.js @@ -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; diff --git a/src/middleware/security.js b/src/middleware/security.js new file mode 100644 index 0000000..0f32cb5 --- /dev/null +++ b/src/middleware/security.js @@ -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(); +} diff --git a/src/services/serviceManager.js b/src/services/serviceManager.js new file mode 100644 index 0000000..f02ba44 --- /dev/null +++ b/src/services/serviceManager.js @@ -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();