Files
workflow-app/backend/server.js

236 lines
8.7 KiB
JavaScript

/**
* Workflow Portal Backend - Modular Architecture
*
* Punkt 1: Modularized from monolithic server.js into route modules
* Punkt 2: Removed unused Prisma (no longer needed)
* Punkt 3: Proper migration tracking via _migrations table
* Punkt 4: Session tokens hashed with SHA-256
* Punkt 5: Register returns correct 'inaktiv' status
* Punkt 6: better-sqlite3 (synchronous, no callback hell)
* Punkt 7: Single aggregated stats query
* Punkt 8: Transactions for template updates and task creation
* Punkt 9: LDAP sync lock
* Punkt 10: express-async-errors for global error handling
* Punkt 19: Configurable CORS via env
* Punkt 22: Prisma removed (was unused)
* Punkt 23: User-level rate limiting
*/
const express = require('express');
require('express-async-errors');
const cors = require('cors');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const path = require('path');
// Initialize database (better-sqlite3, WAL mode, migrations)
const db = require('./db');
const { initDatabase } = require('./migrations');
// Auth middleware
const { authMiddleware, csrfMiddleware } = require('./middleware/auth');
// Rate limiters
const rateLimit = require('express-rate-limit');
const { apiLimiter } = require('./middleware/rateLimit');
// Route modules
const authRoutes = require('./routes/auth');
const usersRoutes = require('./routes/users');
const templatesRoutes = require('./routes/templates');
const tasksRoutes = require('./routes/tasks');
const adRoutes = require('./routes/ad');
const statsRoutes = require('./routes/stats');
const uploadRoutes = require('./routes/upload');
// LDAP sync
const { startLDAPSync, isLDAPConfigured } = require('./ldapSync');
const app = express();
const PORT = process.env.PORT || 5000;
// H3-Fix: Trust proxy konfigurierbar — hinter TLS-terminierendem Proxy (HAProxy)
// MUSS trust proxy aktiv sein, damit Express die echte Client-IP aus
// X-Forwarded-For liest (Rate-Limiting, Audit-Log). Bei direktem Port-Zugriff
// (ohne Proxy) muss es deaktiviert sein, sonst ist X-Forwarded-For spoofbar
// und das Rate-Limiting umgehbar.
// TRUST_PROXY=true → 1 Hop vertrauen (HAProxy/Nginx davor) ← Produktion
// TRUST_PROXY=false → keine Proxy-Header vertrauen ← direkter Zugriff
// unset → true in Produktion (Docker-Stack läuft hinter HAProxy)
const TRUST_PROXY = process.env.TRUST_PROXY !== undefined
? process.env.TRUST_PROXY === 'true'
: process.env.NODE_ENV === 'production';
app.set('trust proxy', TRUST_PROXY ? 1 : false);
// ============ Security Middleware ============
// P14: Validate CORS_ORIGIN - filter empty/invalid entries before using in CSP
const rawCorsOrigin = process.env.CORS_ORIGIN || '';
const validCorsOrigins = rawCorsOrigin
.split(',')
.map(o => o.trim())
.filter(o => o && /^https?:\/\/.+/.test(o));
const cspConnectSrc = ["'self'", ...validCorsOrigins];
// M1: HSTS only makes sense over HTTPS. When serving plain HTTP (e.g. without
// a TLS-terminating proxy), HSTS is ignored by browsers and can even cause
// issues. Allow disabling it via HSTS_ENABLED=false (default: enabled in prod
// when COOKIE_SECURE is true, i.e. when TLS is expected).
const { COOKIE_SECURE } = require('./middleware/auth');
const hstsEnabled = process.env.HSTS_ENABLED !== undefined
? process.env.HSTS_ENABLED === 'true'
: COOKIE_SECURE;
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
connectSrc: cspConnectSrc,
fontSrc: ["'self'", "data:"],
},
},
// P18: HSTS - enforce HTTPS in production (only effective over HTTPS)
hsts: hstsEnabled ? {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
} : false,
crossOriginEmbedderPolicy: false,
}));
// Punkt 19: Configurable CORS via env variable
// Single container: Frontend served from same origin, CORS only needed for external access
const allowedOrigins = validCorsOrigins.length > 0
? validCorsOrigins
: ['http://localhost:5000', 'http://localhost:5173', 'http://127.0.0.1:5000', 'http://127.0.0.1:5173'];
app.use(cors({ origin: allowedOrigins, credentials: true }));
// Punkt 14: Body-Size-Limit to prevent DoS via large payloads
// N4: 10MB to accommodate file uploads (matches UPLOAD_MAX_MB)
app.use(express.json({ limit: process.env.BODY_LIMIT || '10mb' }));
// Punkt 8: Cookie-Parser für HttpOnly-Cookie Auth
app.use(cookieParser());
// P4: CSRF protection for state-changing requests (Double-Submit-Cookie)
// Skip CSRF check for login/register (no session yet, no CSRF token available)
// and OIDC flow endpoints (GET-Redirects; der Flow selbst ist per State-Cookie geschützt)
app.use('/api', (req, res, next) => {
if (req.path.startsWith('/auth/login') || req.path.startsWith('/auth/register') || req.path.startsWith('/v1/auth/login') || req.path.startsWith('/v1/auth/register') || req.path.startsWith('/auth/oidc/') || req.path.startsWith('/v1/auth/oidc/')) {
return next();
}
csrfMiddleware(req, res, next);
});
// ============ Rate Limiting ============
app.use('/api', apiLimiter);
// ============ Health Check (Punkt 6) ============
// V5: Rate-limit /health to prevent DoS/amplification abuse
const healthLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Health-Check-Anfragen.' },
});
app.get('/health', healthLimiter, (req, res) => {
res.json({ status: 'ok', uptime: Math.floor(process.uptime()), timestamp: new Date().toISOString() });
});
// ============ Auth Middleware for all /api/ routes except /api/auth/ ============
app.use('/api', (req, res, next) => {
// Skip auth for login, register, and status endpoints
if (req.path.startsWith('/auth/') || req.path === '/ad/status') {
return next();
}
authMiddleware(req, res, next);
});
// ============ Routes (Punkt 13: API-Versionierung /api/v1) ============
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', usersRoutes);
app.use('/api/v1/templates', templatesRoutes);
app.use('/api/v1/tasks', tasksRoutes);
app.use('/api/v1/ad', adRoutes);
app.use('/api/v1', statsRoutes);
app.use('/api/v1/upload', uploadRoutes);
// ============ Backward Compatibility: /api/ → /api/v1/ ============
app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/templates', templatesRoutes);
app.use('/api/tasks', tasksRoutes);
app.use('/api/ad', adRoutes);
app.use('/api', statsRoutes);
app.use('/api/upload', uploadRoutes);
// ============ Serve Frontend (Single Container) ============
const frontendPath = path.join(__dirname, 'frontend', 'dist');
app.use(express.static(frontendPath));
// SPA fallback: serve index.html for all non-API routes
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/health')) return next();
res.sendFile(path.join(frontendPath, 'index.html'));
});
// ============ Global Error Handler (Punkt 10) ============
app.use((err, req, res, next) => {
console.error('[ERROR]', req.method, req.path, '-', err.message);
if (res.headersSent) return next(err);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
// ============ Initialize & Start ============
async function start() {
try {
await initDatabase();
startLDAPSync(db);
const server = app.listen(PORT, () => {
console.log(`Workflow Portal Backend gestartet auf Port ${PORT}`);
});
// ============ Graceful Shutdown (Punkt 4) ============
function gracefulShutdown(signal) {
console.log(`\n[SHUTDOWN] ${signal} empfangen, fahre herunter...`);
// Stop LDAP sync timer
const { stopLDAPSync } = require('./ldapSync');
stopLDAPSync();
// Stop accepting new connections
server.close(async () => {
console.log('[SHUTDOWN] HTTP-Server gestoppt.');
// Close database connection
try {
if (db._type === 'postgres') {
await db.close();
} else {
db.close();
}
console.log('[SHUTDOWN] Datenbankverbindung geschlossen.');
} catch (err) {
console.error('[SHUTDOWN] Fehler beim Schließen der Datenbank:', err.message);
}
console.log('[SHUTDOWN] Erfolgreich heruntergefahren.');
process.exit(0);
});
// Force shutdown after 10 seconds if connections don't close
setTimeout(() => {
console.error('[SHUTDOWN] Erzwinge Shutdown nach Timeout.');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
} catch (err) {
console.error('[FATAL] Start fehlgeschlagen:', err.message);
process.exit(1);
}
}
start();