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