first commit

This commit is contained in:
KIENTZ Alexandre 2025-12-03 21:26:14 +01:00
commit d15a40c33d
10 changed files with 884 additions and 0 deletions

37
package.json Normal file
View 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
View 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;

View 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 });
}
}

View 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,
});
}

View 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
View 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;
}
}

View 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('/');
}
});
}

View 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;

View 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();
}

View 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();