DEV1.0: Initial commit - Workflow Portal with security fixes

- Backend: Express.js + PostgreSQL/SQLite with LDAP/AD integration
- Frontend: React 18 + Vite + TailwindCSS/DaisyUI
- Security fixes applied (2026-07 + 2026-08):
  - LDAP injection prevention, CSRF protection, HttpOnly cookies
  - Session hashing (SHA-256), account lockout, rate limiting
  - Input validation (zod), file upload security, CSP/HSTS headers
  - V3: express-rate-limit updated (ip-address SSRF fix)
  - V4: postcss updated (nanoid DoS fix)
  - V5: Rate-limit on /health endpoint
  - V6: Session rotation on login (session fixation prevention)
  - V9: Task values array limit (DoS prevention)
  - V10: Frontend XSS audit completed
- Docker: Multi-stage build, non-root user, PostgreSQL + backup service
This commit is contained in:
Kühn
2026-08-24 09:45:28 +02:00
commit 6be1791c62
103 changed files with 12253 additions and 0 deletions

2
backend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
data

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine
RUN apk add --no-cache python3 make g++ openssl
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5000
CMD ["node", "server.js"]

18
backend/auditLog.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* Audit logging module (async).
*
* Punkt 13: Now stores ip_address and user_agent.
* Fire-and-forget: errors are logged but don't block the caller.
*/
const db = require('./db');
function auditLog(userId, action, entityType, entityId, details, req) {
const ip = req?.ip || req?.headers?.['x-forwarded-for'] || null;
const userAgent = req?.headers?.['user-agent'] || null;
db.prepare('INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(userId || null, action, entityType || null, entityId || null, details || null, ip, userAgent)
.catch(err => console.error('Audit log error:', err.message));
}
module.exports = { auditLog };

154
backend/db.js Normal file
View File

@@ -0,0 +1,154 @@
/**
* Database abstraction layer (async).
*
* Punkt 4: PostgreSQL for production, SQLite fallback for development.
* Both modes expose the SAME async API: db.prepare(sql).run/get/all() return Promises.
*
* - If DATABASE_URL starts with 'postgresql://' → PostgreSQL (pg)
* - Otherwise → SQLite (better-sqlite3, wrapped in Promises for unified async API)
*/
const path = require('path');
const DATABASE_URL = process.env.DATABASE_URL || '';
const usePostgres = DATABASE_URL.startsWith('postgresql://') || DATABASE_URL.startsWith('postgres://');
// Helper: convert SQLite ? placeholders to PostgreSQL $1, $2, etc.
function convertPlaceholders(sql) {
let idx = 0;
return sql.replace(/\?/g, () => { idx++; return '$' + idx; });
}
let db;
if (usePostgres) {
// ============ PostgreSQL mode (production) ============
const { Pool } = require('pg');
const pool = new Pool({
connectionString: DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
pool.on('error', (err) => {
console.error('[DB] PostgreSQL Pool-Fehler:', err.message);
});
console.log('[DB] PostgreSQL-Verbindung hergestellt (Production-Modus).');
db = {
_pool: pool,
_type: 'postgres',
prepare(sql) {
const pgSql = convertPlaceholders(sql);
return {
run: (...params) => {
// For INSERT statements, append RETURNING id to get the generated ID
const isInsert = pgSql.trim().toUpperCase().startsWith('INSERT');
const finalSql = isInsert && !pgSql.toUpperCase().includes('RETURNING')
? pgSql.replace(/;?\s*$/, ' RETURNING id')
: pgSql;
return pool.query(finalSql, params).then(result => ({
changes: result.rowCount,
lastInsertRowid: result.rows[0]?.id || null,
}));
},
get: (...params) => pool.query(pgSql, params).then(result => result.rows[0] || null),
all: (...params) => pool.query(pgSql, params).then(result => result.rows),
};
},
exec(sql) {
return pool.query(sql);
},
pragma(_str) {
return Promise.resolve({});
},
transaction(fn) {
return async (...args) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const txDb = {
prepare(sql) {
const pgSql = convertPlaceholders(sql);
return {
run: (...params) => client.query(pgSql, params).then(result => ({
changes: result.rowCount,
lastInsertRowid: result.rows[0]?.id || null,
})),
get: (...params) => client.query(pgSql, params).then(result => result.rows[0] || null),
all: (...params) => client.query(pgSql, params).then(result => result.rows),
};
},
exec: (sql) => client.query(sql),
pragma: () => Promise.resolve({}),
};
const result = await fn.call(txDb, ...args);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
};
},
close() {
return pool.end();
},
};
} else {
// ============ SQLite mode (development) ============
// Wrapped in Promises so the API is identical to PostgreSQL (async)
const Database = require('better-sqlite3');
const dbPath = path.join(__dirname, 'data', 'workflow.db');
const sqliteDb = new Database(dbPath);
sqliteDb.pragma('journal_mode = WAL');
sqliteDb.pragma('foreign_keys = ON');
console.log('[DB] SQLite-Datenbank verbunden (better-sqlite3, WAL-Modus, async-Wrapper).');
db = {
_type: 'sqlite',
prepare(sql) {
const stmt = sqliteDb.prepare(sql);
return {
run: (...params) => Promise.resolve(stmt.run(...params)),
get: (...params) => Promise.resolve(stmt.get(...params)),
all: (...params) => Promise.resolve(stmt.all(...params)),
};
},
exec(sql) {
sqliteDb.exec(sql);
return Promise.resolve();
},
pragma(str) {
sqliteDb.pragma(str);
return Promise.resolve({});
},
transaction(fn) {
const tx = sqliteDb.transaction(fn);
return (...args) => Promise.resolve(tx(...args));
},
close() {
sqliteDb.close();
return Promise.resolve();
},
};
}
module.exports = db;

444
backend/ldapOperations.js Normal file
View File

@@ -0,0 +1,444 @@
const { Client, Attribute, Change } = require('ldapts');
/**
* LDAP Operations Module (ldapts)
*
* Provides functions for browsing the AD tree and creating users in Active Directory.
* Uses ldapts (maintained) instead of deprecated ldapjs.
* Punkt 1: Migrated from ldapjs to ldapts
* Punkt 7: Proper client cleanup with try/finally in all functions
*/
const LDAP_SERVER = process.env.LDAP_SERVER || '';
const LDAP_PORT = parseInt(process.env.LDAP_PORT) || 389;
const LDAP_SEARCH_BASE = process.env.LDAP_SEARCH_BASE || '';
const LDAP_DOMAIN = process.env.LDAP_DOMAIN || '';
const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false').toLowerCase() === 'true';
const LDAP_BIND_USER = process.env.LDAP_BIND_USER || '';
const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || '';
const LDAP_CREATE_OU = process.env.LDAP_CREATE_OU || '';
const LDAP_UPN_SUFFIX = process.env.LDAP_UPN_SUFFIX || '';
function isLDAPConfigured() {
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD);
}
/**
* Normalize ldapts attribute values.
* ldapts may return attributes as arrays; this helper extracts single values.
*/
function attr(entry, key) {
const val = entry[key];
if (Array.isArray(val)) return val[0] || '';
if (val !== undefined && val !== null) return val;
return '';
}
function attrArray(entry, key) {
const val = entry[key];
if (Array.isArray(val)) return val;
if (val !== undefined && val !== null) return [val];
return [];
}
/**
* Create and bind an LDAP client using ldapts.
* Punkt 7: Returns a bound client; caller must call client.unbind() in finally block.
*/
async function createClient() {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(LDAP_BIND_USER, LDAP_BIND_PASSWORD);
return client;
} catch (err) {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup on bind failure
throw new Error('LDAP Bind fehlgeschlagen: ' + (err.message || err));
}
}
/**
* Browse the AD tree and return OUs under the configured base or a given path.
* Returns a hierarchical tree structure.
*/
async function browseOUTree(searchBase) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
const base = searchBase || LDAP_SEARCH_BASE;
try {
const { searchEntries } = await client.search(base, {
filter: '(objectClass=organizationalUnit)',
scope: 'one',
attributes: ['distinguishedName', 'name'],
sizeLimit: 500,
});
const ous = searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
name: attr(entry, 'name') || '',
}));
// Recursively fetch children for each OU
const results = [];
for (const ou of ous) {
let children = [];
try {
children = await browseOUTree(ou.dn);
} catch (e) {
// Ignore errors for individual OU children
}
results.push({
dn: ou.dn,
name: ou.name,
children: children,
});
}
return results;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Escape special characters in LDAP distinguished names.
*/
function escapeLDAPDN(str) {
return str.replace(/[,+"\\<>;]/g, '\\$&');
}
/**
* Replace German umlauts and ß for sAMAccountName compatibility.
*/
function replaceUmlauts(str) {
return str
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss');
}
/**
* Create a user in Active Directory.
* Punkt 7: Proper client cleanup with try/finally
*/
async function createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c }) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
if (!ou || !username || !password) {
throw new Error('OU, Anmeldename und Passwort sind erforderlich.');
}
if (!vorname || !nachname) {
throw new Error('Vorname und Nachname sind erforderlich, um einen AD-Benutzer anzulegen.');
}
const client = await createClient();
try {
// CN format: Nachname, Vorname (as per AD convention)
const cnValue = nachname + ', ' + vorname;
const escapedCN = escapeLDAPDN(cnValue);
const dn = 'CN=' + escapedCN + ',' + ou;
// Build UPN
const upnSuffix = LDAP_UPN_SUFFIX || LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN.toLowerCase() + '.intra';
const userPrincipalName = username + '@' + upnSuffix;
// sAMAccountName: max 20 chars
let sAMAccountName = username;
if (vorname && nachname) {
sAMAccountName = replaceUmlauts(nachname + vorname.charAt(0)).replace(/[^a-zA-Z0-9]/g, '');
}
sAMAccountName = sAMAccountName.substring(0, 20);
// userAccountControl: 514 = NORMAL_ACCOUNT + ACCOUNTDISABLE
const userAccountControl = 514;
const effectiveDisplayName = displayName || (nachname + ', ' + vorname);
const entry = {
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
cn: cnValue,
sn: nachname,
givenName: vorname,
displayName: effectiveDisplayName,
sAMAccountName: sAMAccountName,
userPrincipalName: userPrincipalName,
userAccountControl: userAccountControl,
};
if (email) entry.mail = email;
if (department) entry.department = department;
if (telefon) entry.telephoneNumber = telefon;
if (titel) entry.title = titel;
if (physicalDeliveryOfficeName) entry.physicalDeliveryOfficeName = physicalDeliveryOfficeName;
if (company) entry.company = company;
if (description) entry.description = description;
if (wWWHomePage) entry.wWWHomePage = wWWHomePage;
if (streetAddress) entry.streetAddress = streetAddress;
if (postOfficeBox) entry.postOfficeBox = postOfficeBox;
if (l) entry.l = l;
if (st) entry.st = st;
if (postalCode) entry.postalCode = postalCode;
// c (country) must be a 2-letter ISO-3166 code
if (c) {
const countryCode = String(c).trim().toUpperCase().substring(0, 2);
if (countryCode.length === 2 && /^[A-Z]{2}$/.test(countryCode)) {
entry.c = countryCode;
}
}
// Step 1: Create user as DISABLED
try {
await client.add(dn, entry);
} catch (err) {
if (err.message && err.message.includes('ENTRY_ALREADY_EXISTS')) {
throw new Error('Ein Benutzer mit diesem Namen existiert bereits an dieser Stelle im AD.');
}
if (err.message && err.message.includes('Constraint Violation')) {
console.error('[LDAP] Constraint Violation:', err.message, 'Entry:', JSON.stringify(entry, null, 2));
throw new Error('Constraint Violation: Ein Pflichtfeld fehlt oder enthält einen ungültigen Wert. Bitte Vorname, Nachname und Anmeldename prüfen. Das Land-Feld (c) muss ein 2-Buchstaben-Code sein (z.B. DE).');
}
throw new Error('Fehler beim Erstellen: ' + (err.message || err));
}
console.log('[LDAP] Benutzer erstellt (deaktiviert):', dn);
// Step 2: Set the password
const unicodePwd = Buffer.from('"' + password + '"', 'utf16le');
try {
await client.modify(dn, [
new Change({
operation: 'replace',
modification: new Attribute({
type: 'unicodePwd',
values: [unicodePwd],
}),
}),
]);
} catch (pwdErr) {
console.warn('[LDAP] Passwort konnte nicht gesetzt werden (Benutzer wurde deaktiviert erstellt):', pwdErr.message);
return {
dn: dn,
username: username,
warning: 'Benutzer erstellt (deaktiviert), aber Passwort konnte nicht gesetzt werden: ' + pwdErr.message,
};
}
console.log('[LDAP] Passwort gesetzt für:', dn);
// Step 3: Enable the account (userAccountControl: 512 = NORMAL_ACCOUNT, enabled)
try {
await client.modify(dn, [
new Change({
operation: 'replace',
modification: new Attribute({
type: 'userAccountControl',
values: [512],
}),
}),
]);
} catch (enableErr) {
console.warn('[LDAP] Konto konnte nicht aktiviert werden (Benutzer wurde mit Passwort erstellt):', enableErr.message);
return {
dn: dn,
username: username,
warning: 'Benutzer erstellt und Passwort gesetzt, aber Konto konnte nicht aktiviert werden: ' + enableErr.message,
};
}
console.log('[LDAP] Konto aktiviert für:', dn);
// P2: Clear plaintext password from memory after use
password = null;
return { dn: dn, username: username };
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Check if a user exists in AD by sAMAccountName.
* Punkt 7: Proper client cleanup
*/
async function checkADUserExists(username, sAMAccountName) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
// P1: LDAP-Injection prevention - sanitize username and samName before building filter
const escapeLDAPFilter = (str) => String(str || '').replace(/[*()\\\x00]/g, '\\$&');
const safeSamName = escapeLDAPFilter(sAMAccountName || username);
const safeUsername = escapeLDAPFilter(username);
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: '(|(sAMAccountName=' + safeSamName + ')(userPrincipalName=' + safeUsername + '@*))',
scope: 'sub',
attributes: ['distinguishedName', 'sAMAccountName', 'displayName', 'userPrincipalName'],
sizeLimit: 100,
});
if (searchEntries.length > 0) {
const entry = searchEntries[0];
return {
distinguishedName: attr(entry, 'distinguishedName') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
displayName: attr(entry, 'displayName') || '',
userPrincipalName: attr(entry, 'userPrincipalName') || '',
};
}
return null;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Delete a user from Active Directory by DN.
* Punkt 7: Proper client cleanup
*/
async function deleteADUser(dn) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
await client.del(dn);
console.log('[LDAP] Benutzer gelöscht (Rollback):', dn);
} catch (err) {
console.error('[LDAP] Fehler beim Löschen des Benutzers (Rollback):', err.message);
throw err;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Search for AD groups/security principals matching a query.
* Returns all groups (no GRP_ filter - used for security group search).
* Punkt 7: Proper client cleanup
*/
async function searchADGroups(query) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
const escapedQuery = query.replace(/[()*\\]/g, '\\$&');
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: `(&(objectClass=group)(|(cn=*${escapedQuery}*)(displayName=*${escapedQuery}*)(sAMAccountName=*${escapedQuery}*)))`,
scope: 'sub',
attributes: ['distinguishedName', 'cn', 'displayName', 'sAMAccountName', 'description', 'groupType'],
sizeLimit: 100,
});
return searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
cn: attr(entry, 'cn') || '',
displayName: attr(entry, 'displayName') || attr(entry, 'cn') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
description: attr(entry, 'description') || '',
}));
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Add a user to one or more AD groups.
* Punkt 7: Proper client cleanup
*/
async function addUserToGroups(userDN, groupDNs) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
const results = [];
try {
for (const groupDN of groupDNs) {
try {
await client.modify(groupDN, [
new Change({
operation: 'add',
modification: new Attribute({
type: 'member',
values: [userDN],
}),
}),
]);
results.push({ dn: groupDN, status: 'added' });
} catch (err) {
if (err.message && err.message.includes('already exists')) {
results.push({ dn: groupDN, status: 'already_member' });
} else {
results.push({ dn: groupDN, status: 'error', error: err.message });
}
}
}
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
return results;
}
/**
* Browse all AD groups under the configured search base.
* Returns only GRP_ groups for static display.
* Punkt 7: Proper client cleanup
*/
async function browseADGroups() {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: '(&(objectClass=group)(cn=GRP_*))',
scope: 'sub',
attributes: ['distinguishedName', 'cn', 'displayName', 'sAMAccountName', 'description', 'memberOf'],
sizeLimit: 500,
});
const groups = searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
cn: attr(entry, 'cn') || '',
displayName: attr(entry, 'displayName') || attr(entry, 'cn') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
description: attr(entry, 'description') || '',
}));
// Sort groups by displayName/cn for easier browsing
groups.sort((a, b) => (a.displayName || a.cn).localeCompare(b.displayName || b.cn));
return groups;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
module.exports = { isLDAPConfigured, browseOUTree, createADUser, checkADUserExists, deleteADUser, searchADGroups, addUserToGroups, browseADGroups };

240
backend/ldapSync.js Normal file
View File

@@ -0,0 +1,240 @@
const { Client } = require('ldapts');
/**
* LDAP / Active Directory Sync Module (ldapts)
*
* Reads users from LDAP/AD and syncs them into the local SQLite database.
* AD users are identified by source='ad' and cannot be edited/deleted locally.
*
* Punkt 1: Migrated from ldapjs to ldapts
* Punkt 7: Proper client cleanup with try/finally
*
* ENV variables:
* LDAP_SERVER - e.g. pidc02.seatle.intra
* LDAP_PORT - e.g. 389 (LDAP) or 636 (LDAPS), default: 389
* LDAP_SEARCH_BASE - e.g. DC=SEATLE,DC=INTRA
* LDAP_DOMAIN - e.g. SEATLE (used for reference)
* LDAP_IGNORE_CERT_ERRORS- true/false (default: false)
* LDAP_BIND_USER - Service account in user@domain.fqdn format
* LDAP_BIND_PASSWORD - Password for the service account
* LDAP_SYNC_INTERVAL - Sync interval in ms (default: 300000 = 5 min)
* LDAP_FILTER - Custom LDAP filter (default: active users)
* LDAP_ATTRIBUTES - Comma-separated LDAP attributes
*/
const LDAP_SERVER = process.env.LDAP_SERVER || '';
const LDAP_PORT = parseInt(process.env.LDAP_PORT) || 389;
const LDAP_SEARCH_BASE = process.env.LDAP_SEARCH_BASE || '';
const LDAP_DOMAIN = process.env.LDAP_DOMAIN || '';
const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false').toLowerCase() === 'true';
const LDAP_BIND_USER = process.env.LDAP_BIND_USER || '';
const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || '';
const LDAP_SYNC_INTERVAL = parseInt(process.env.LDAP_SYNC_INTERVAL) || 300000;
const LDAP_FILTER = process.env.LDAP_FILTER || '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))';
const LDAP_ATTRIBUTES = (process.env.LDAP_ATTRIBUTES || 'mail,displayName,memberOf,distinguishedName,sAMAccountName').split(',').map(a => a.trim());
let syncTimer = null;
let isSyncing = false; // Punkt 9: Sync lock to prevent concurrent syncs
function isLDAPConfigured() {
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD);
}
function extractRole(memberOf) {
if (!memberOf) return 'user';
const groups = Array.isArray(memberOf) ? memberOf : [memberOf];
const groupStrings = groups.map(g => String(g).toLowerCase());
if (groupStrings.some(g => g.includes('admin') || g.includes('domain admins') || g.includes('domänen-admins'))) {
return 'admin';
}
return 'user';
}
async function syncLDAPUsers(db) {
if (!isLDAPConfigured()) {
console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.');
return;
}
// Punkt 9: Prevent concurrent sync runs
if (isSyncing) {
console.log('[LDAP] Sync bereits aktiv - übersprungen.');
return;
}
isSyncing = true;
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
console.log('[LDAP] Starte Synchronisation mit', url);
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(LDAP_BIND_USER, LDAP_BIND_PASSWORD);
console.log('[LDAP] Bind erfolgreich, suche Nutzer...');
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: LDAP_FILTER,
scope: 'sub',
attributes: LDAP_ATTRIBUTES,
});
const adUsers = [];
for (const entry of searchEntries) {
// ldapts may return attributes as arrays; normalize to single values
const rawMail = Array.isArray(entry.mail) ? entry.mail[0] : entry.mail;
const rawName = Array.isArray(entry.displayName) ? entry.displayName[0] : entry.displayName;
const rawCn = Array.isArray(entry.cn) ? entry.cn[0] : entry.cn;
const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName;
const rawSAM = Array.isArray(entry.sAMAccountName) ? entry.sAMAccountName[0] : entry.sAMAccountName;
const rawMemberOf = Array.isArray(entry.memberOf) ? entry.memberOf : (entry.memberOf ? [entry.memberOf] : []);
const email = (rawMail || '').toLowerCase().trim();
const name = rawName || rawCn || '';
const distinguishedName = rawDN || '';
const memberOf = rawMemberOf;
const username = (rawSAM || '').trim();
if (!email && !username) continue; // Skip users without email AND username
adUsers.push({
email: email || (username + '@ad.local'),
name,
role: extractRole(memberOf),
distinguishedName,
username,
});
}
console.log('[LDAP] Gefunden:', adUsers.length, 'Nutzer');
// Sync LDAP users into database (async for PostgreSQL compatibility)
const existingRows = await db.prepare('SELECT id, email, name, role, status FROM users WHERE source = \'ad\'').all();
const existingMap = {};
existingRows.forEach(row => { existingMap[row.email.toLowerCase()] = row; });
let inserted = 0;
let updated = 0;
const insertStmt = db.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, ?, \'inaktiv\', \'ad\', ?)');
const updateStmt = db.prepare('UPDATE users SET name = ?, username = ?, role = ? WHERE id = ?');
const syncTransaction = db.transaction(async () => {
for (const adUser of adUsers) {
const existing = existingMap[adUser.email];
if (existing) {
await updateStmt.run(adUser.name, adUser.username, adUser.role, existing.id);
updated++;
delete existingMap[adUser.email];
} else {
try {
await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.role, adUser.username);
inserted++;
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) {
console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email);
} else {
console.error('[LDAP] Insert-Fehler:', err.message);
}
}
}
}
});
await syncTransaction();
// Remove stale AD users
const adEmails = adUsers.map(u => u.email.toLowerCase());
const toRemove = existingRows.filter(r => !adEmails.includes(r.email.toLowerCase()));
let removed = 0;
if (toRemove.length > 0) {
const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id));
if (removeIds.length > 0) {
const placeholders = removeIds.map(() => '?').join(',');
await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds);
removed = removeIds.length;
}
}
console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' entfernt');
} catch (err) {
console.error('[LDAP] Sync-Fehler:', err.message);
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
isSyncing = false; // Punkt 9: Release sync lock
}
}
function startLDAPSync(db) {
if (!isLDAPConfigured()) {
console.log('[LDAP] LDAP-Sync nicht konfiguriert. Setze LDAP_SERVER, LDAP_SEARCH_BASE, LDAP_BIND_USER und LDAP_BIND_PASSWORD Umgebungsvariablen.');
return;
}
// Initial sync
syncLDAPUsers(db);
// Periodic sync
if (syncTimer) clearInterval(syncTimer);
syncTimer = setInterval(() => {
syncLDAPUsers(db);
}, LDAP_SYNC_INTERVAL);
console.log('[LDAP] Automatischer Sync alle ' + (LDAP_SYNC_INTERVAL / 1000) + ' Sekunden aktiviert.');
}
function stopLDAPSync() {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
console.log('[LDAP] Sync gestoppt.');
}
}
/**
* Authenticate a user against LDAP/Active Directory.
* Uses the sAMAccountName (username) to bind to the LDAP server.
* Punkt 7: Proper client cleanup with try/finally
*/
async function authenticateLDAP(username, password) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
// VULN-11: LDAP Injection prevention - validate username
const safeUsername = String(username || '').replace(/[*()\\\x00]/g, '').trim();
if (!safeUsername || !/^[a-zA-Z0-9._-]+$/.test(safeUsername)) {
throw new Error('Ungueltiger Anmeldename.');
}
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
// Build the bind DN: username@domain.fqdn (UPN format)
const bindDomain = LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN;
const bindDN = safeUsername + '@' + bindDomain;
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(bindDN, password);
console.log('[LDAP] Authentifizierung erfolgreich für', bindDN);
return { username: username, bindDN: bindDN };
} catch (err) {
console.log('[LDAP] Authentifizierung fehlgeschlagen für', bindDN, ':', err.message);
throw new Error('Ungueltige Anmeldedaten.');
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
module.exports = { isLDAPConfigured, syncLDAPUsers, startLDAPSync, stopLDAPSync, authenticateLDAP };

170
backend/middleware/auth.js Normal file
View File

@@ -0,0 +1,170 @@
/**
* Auth middleware module (async).
*
* Session tokens are hashed with SHA-256 for security (Punkt 4).
* All DB calls are async (Punkt 4: PostgreSQL compatibility).
*/
const crypto = require('crypto');
const db = require('../db');
const { auditLog } = require('../auditLog');
// P6: Cookie config - defined early for use in CSRF and auth cookies
const isProduction = process.env.NODE_ENV === 'production';
const COOKIE_NAME = 'workflow_token';
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
// P4: CSRF protection (Double-Submit-Cookie pattern)
const CSRF_COOKIE_NAME = 'workflow_csrf';
const CSRF_HEADER_NAME = 'x-csrf-token';
function setCSRFCookie(res) {
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // Must be readable by JS to send back in header
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24h
path: '/',
});
return csrfToken;
}
function csrfMiddleware(req, res, next) {
// Only check state-changing methods
const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (!stateChanging.includes(req.method)) return next();
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
const headerToken = req.headers[CSRF_HEADER_NAME];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF-Token ungültig oder fehlend.' });
}
next();
}
async function authMiddleware(req, res, next) {
// Punkt 8: Token from HttpOnly-Cookie OR Authorization header
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
if (!rawToken) return res.status(401).json({ error: 'Nicht authentifiziert.' });
const tokenHash = hashToken(rawToken);
const session = await db.prepare('SELECT s.id, s.user_id, s.expires_at, u.email, u.name, u.role, u.status, u.source, u.username FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token = ?').get(tokenHash);
if (!session) return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' });
if (session.status === 'inaktiv') {
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
return res.status(401).json({ error: 'Konto deaktiviert.' });
}
if (session.expires_at && new Date(session.expires_at) < new Date()) {
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' });
}
req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, status: session.status, source: session.source, username: session.username };
req.tokenHash = tokenHash;
next();
}
function adminMiddleware(req, res, next) {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin-Rechte erforderlich.' });
next();
}
async function createSession(userId, oldRawToken) {
// V6: Session-Rotation - invalidate old session on new login (prevents session fixation)
if (oldRawToken) {
const oldHash = hashToken(oldRawToken);
await db.prepare('DELETE FROM sessions WHERE token = ?').run(oldHash);
}
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(rawToken);
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString();
// Punkt 9: Session-Limitierung - max sessions per user
const maxSessions = parseInt(process.env.SESSION_MAX_PER_USER) || 5;
const existingSessions = await db.prepare('SELECT id FROM sessions WHERE user_id = ? ORDER BY created_at ASC').all(userId);
if (existingSessions.length >= maxSessions) {
const toDelete = existingSessions.slice(0, existingSessions.length - maxSessions + 1);
const deleteIds = toDelete.map(s => s.id).filter(id => Number.isInteger(id));
if (deleteIds.length > 0) {
const placeholders = deleteIds.map(() => '?').join(',');
await db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...deleteIds);
}
}
await db.prepare('INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)').run(userId, tokenHash, expiresAt);
return rawToken;
}
async function deleteSession(rawToken) {
if (!rawToken) return;
const tokenHash = hashToken(rawToken);
const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash);
if (session) {
auditLog(session.user_id, 'logout', 'user', session.user_id, null);
}
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
}
async function invalidateUserSessions(userId) {
await db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
}
// Punkt 12: Account-Lockout functions
const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS) || 5;
const LOCKOUT_MINUTES = parseInt(process.env.LOGIN_LOCKOUT_MINUTES) || 15;
async function isAccountLocked(userId) {
const user = await db.prepare('SELECT locked_until FROM users WHERE id = ?').get(userId);
if (!user || !user.locked_until) return false;
if (new Date(user.locked_until) > new Date()) return true;
await db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?').run(userId);
return false;
}
async function recordFailedLogin(userId) {
if (!userId) return;
const user = await db.prepare('SELECT failed_login_attempts FROM users WHERE id = ?').get(userId);
if (!user) return;
const attempts = (user.failed_login_attempts || 0) + 1;
if (attempts >= MAX_ATTEMPTS) {
const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString();
await db.prepare('UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE id = ?').run(attempts, lockedUntil, userId);
} else {
await db.prepare('UPDATE users SET failed_login_attempts = ? WHERE id = ?').run(attempts, userId);
}
}
async function recordSuccessfulLogin(userId) {
if (!userId) return;
await db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?').run(userId);
}
// Punkt 8: Cookie helpers
function setAuthCookie(res, token) {
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: ttlHours * 60 * 60 * 1000,
path: '/',
});
}
function clearAuthCookie(res) {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
module.exports = {
authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken,
isAccountLocked, recordFailedLogin, recordSuccessfulLogin,
setAuthCookie, clearAuthCookie, COOKIE_NAME,
setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME
};

View File

@@ -0,0 +1,46 @@
/**
* Rate limiting configuration module.
*
* Punkt 23: User-level rate limiting for critical endpoints.
*/
const rateLimit = require('express-rate-limit');
// General API rate limit: 100 requests per minute per IP
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' },
});
// Login rate limit: 5 attempts per minute per IP (brute-force protection)
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' },
});
// Punkt 23: Task creation rate limit: 20 per minute per user
const taskCreateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => !req.user,
message: { error: 'Zu viele Auftragsanfragen. Bitte später erneut versuchen.' },
});
// Punkt 23: File upload rate limit: 10 per minute per user
const uploadLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => !req.user,
message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' },
});
module.exports = { apiLimiter, loginLimiter, taskCreateLimiter, uploadLimiter };

View File

@@ -0,0 +1,209 @@
/**
* Input Validation Module (zod)
*
* Punkt 2: Schema-based input validation for all API routes.
* Provides reusable validation schemas and a middleware helper.
*/
const { z } = require('zod');
// ============ Auth Schemas ============
// Punkt 11: Password-Policy - min 8 chars, uppercase, lowercase, number
const passwordSchema = z.string()
.min(8, 'Passwort muss mindestens 8 Zeichen lang sein.')
.regex(/[A-Z]/, 'Passwort muss mindestens einen Grossbuchstaben enthalten.')
.regex(/[a-z]/, 'Passwort muss mindestens einen Kleinbuchstaben enthalten.')
.regex(/[0-9]/, 'Passwort muss mindestens eine Zahl enthalten.');
const registerSchema = z.object({
email: z.string().email('Ungueltige E-Mail-Adresse.'),
password: passwordSchema,
name: z.string().max(100).optional().default(''),
// VULN-FIX: role removed - always 'user' on register, never trust client
});
const loginSchema = z.object({
email: z.string().min(1, 'E-Mail ist erforderlich.'),
password: z.string().min(1, 'Passwort ist erforderlich.'),
});
// ============ User Schemas ============
const createUserSchema = z.object({
email: z.string().email('Ungueltige E-Mail-Adresse.'),
password: passwordSchema,
name: z.string().max(100).optional().default(''),
role: z.enum(['admin', 'user']).optional().default('user'),
status: z.enum(['aktiv', 'inaktiv']).optional().default('aktiv'),
});
const updateUserSchema = z.object({
email: z.string().email('Ungueltige E-Mail-Adresse.').optional(),
name: z.string().max(100).optional(),
password: passwordSchema.optional(),
current_password: z.string().optional(),
role: z.enum(['admin', 'user']).optional(),
status: z.enum(['aktiv', 'inaktiv']).optional(),
});
// ============ Template Schemas ============
const templateStepSchema = z.object({
page_num: z.number().int().min(1).optional().default(1),
label: z.string().min(1, 'Label ist erforderlich.').max(200),
type: z.enum(['checkbox', 'text_input', 'file_upload', 'email', 'dropdown', 'ad_password', 'ad_displayname']),
step_order: z.number().int().min(0).optional(),
email_domain: z.string().optional(),
email_source_fields: z.string().optional(),
dropdown_options: z.string().optional(),
ad_field: z.string().optional(),
ad_prefix: z.string().optional(),
hidden: z.boolean().optional().default(false),
});
const createTemplateSchema = z.object({
name: z.string().min(1, 'Name ist erforderlich.').max(200),
description: z.string().max(1000).optional().default(''),
is_assignable: z.boolean().optional().default(false),
allows_file_upload: z.boolean().optional().default(false),
ad_create: z.boolean().optional().default(false),
steps: z.array(templateStepSchema).optional().default([]),
});
const updateTemplateSchema = z.object({
name: z.string().min(1, 'Name ist erforderlich.').max(200),
description: z.string().max(1000).optional().default(''),
is_assignable: z.boolean().optional().default(false),
allows_file_upload: z.boolean().optional().default(false),
ad_create: z.boolean().optional().default(false),
steps: z.array(templateStepSchema).optional().default([]),
});
// ============ Task Schemas ============
const createTaskSchema = z.object({
template_id: z.number().int().positive('Template-ID ist erforderlich.'),
title: z.string().min(1, 'Titel ist erforderlich.').max(500),
user_id: z.number().int().positive().optional(),
file_path: z.string().optional(),
// V9: Limit task values array to prevent DoS via huge payloads
values: z.array(z.object({
step_id: z.number().int().positive().optional(),
value: z.string().max(10000).optional(),
is_checked: z.boolean().optional(),
file_path: z.string().optional(),
})).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]),
});
const updateTaskStatusSchema = z.object({
status: z.enum(['offen', 'erledigt'], { message: 'Status muss "offen" oder "erledigt" sein.' }),
});
const updateTaskValuesSchema = z.object({
values: z.array(z.object({
id: z.number().int().positive(),
value: z.string().optional(),
is_checked: z.boolean().optional(),
})).min(1, 'Mindestens ein Wert ist erforderlich.'),
});
const addTaskFieldSchema = z.object({
label: z.string().min(1, 'Label ist erforderlich.').max(200),
type: z.enum(['text_input', 'checkbox', 'dropdown', 'email']).optional().default('text_input'),
value: z.string().optional().default(''),
page_num: z.number().int().min(1).optional().default(1),
dropdown_options: z.string().optional().default(''),
ad_field: z.string().optional().default(''),
hidden: z.boolean().optional().default(false),
email_source_fields: z.string().optional().default(''),
});
// ============ AD Schemas ============
const createADUserSchema = z.object({
ou: z.string().min(1, 'OU ist erforderlich.'),
vorname: z.string().min(1, 'Vorname ist erforderlich.').max(100),
nachname: z.string().min(1, 'Nachname ist erforderlich.').max(100),
username: z.string().min(1, 'Anmeldename ist erforderlich.').max(50),
password: z.string().min(1, 'Passwort ist erforderlich.').min(8, 'Passwort muss mindestens 8 Zeichen lang sein.'),
email: z.string().email().optional(),
department: z.string().max(100).optional(),
telefon: z.string().max(50).optional(),
titel: z.string().max(100).optional(),
displayName: z.string().max(200).optional(),
physicalDeliveryOfficeName: z.string().max(100).optional(),
company: z.string().max(100).optional(),
description: z.string().max(500).optional(),
wWWHomePage: z.string().max(200).optional(),
streetAddress: z.string().max(200).optional(),
postOfficeBox: z.string().max(50).optional(),
l: z.string().max(100).optional(),
st: z.string().max(100).optional(),
postalCode: z.string().max(20).optional(),
c: z.string().max(2).optional(),
groups: z.array(z.string()).optional(),
});
const deleteADUserSchema = z.object({
dn: z.string().min(1, 'DN ist erforderlich.'),
});
// ============ Search/Query Schemas ============
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
const searchSchema = z.object({
search: z.string().max(100).optional(),
});
// ============ Validation Middleware ============
function validate(schema) {
return (req, res, next) => {
try {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map(e => e.message).join(', ');
return res.status(400).json({ error: errors });
}
req.validatedBody = result.data;
next();
} catch (err) {
return res.status(400).json({ error: 'Ungueltige Eingabe.' });
}
};
}
function validateQuery(schema) {
return (req, res, next) => {
try {
const result = schema.safeParse(req.query);
if (!result.success) {
const errors = result.error.errors.map(e => e.message).join(', ');
return res.status(400).json({ error: errors });
}
req.validatedQuery = result.data;
next();
} catch (err) {
return res.status(400).json({ error: 'Ungueltige Abfrage.' });
}
};
}
module.exports = {
// Schemas
registerSchema,
loginSchema,
createUserSchema,
updateUserSchema,
createTemplateSchema,
updateTemplateSchema,
templateStepSchema,
createTaskSchema,
updateTaskStatusSchema,
updateTaskValuesSchema,
addTaskFieldSchema,
createADUserSchema,
deleteADUserSchema,
paginationSchema,
searchSchema,
// Middleware
validate,
validateQuery,
};

202
backend/migrations.js Normal file
View File

@@ -0,0 +1,202 @@
/**
* Database initialization and migrations module.
*
* Punkt 4: Supports both SQLite and PostgreSQL.
* Migrations are tracked in a _migrations table to avoid re-running.
*
* Note: SQLite mode is synchronous, PostgreSQL mode is async.
* The initDatabase function handles both cases.
*/
const db = require('./db');
const isPostgres = db._type === 'postgres';
// Helper: convert SQLite SQL to PostgreSQL-compatible SQL
function toPg(sql) {
return sql
.replace(/INTEGER PRIMARY KEY AUTOINCREMENT/g, 'SERIAL PRIMARY KEY')
.replace(/TIMESTAMP DEFAULT CURRENT_TIMESTAMP/g, 'TIMESTAMP DEFAULT NOW()')
.replace(/`/g, '"');
}
function execSql(sql) {
if (isPostgres) {
return db.exec(toPg(sql));
}
return db.exec(sql);
}
async function initDatabase() {
// Create migrations tracking table
const migrationsTableSql = isPostgres
? `CREATE TABLE IF NOT EXISTS _migrations (id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT NOW())`
: `CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`;
await execSql(migrationsTableSql);
const appliedRows = await db.prepare('SELECT name FROM _migrations').all();
const applied = new Set(appliedRows.map(r => r.name));
async function migrate(name, sql) {
if (applied.has(name)) return;
console.log(`[Migration] ${name}...`);
await execSql(sql);
await db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(name);
console.log(`[Migration] ${name} done.`);
}
// Base schema
const baseSchema = isPostgres ? `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user',
status TEXT CHECK(status IN ('aktiv', 'inaktiv')) DEFAULT 'inaktiv',
source TEXT CHECK(source IN ('local', 'ad')) DEFAULT 'local',
username TEXT,
failed_login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS templates (
id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT,
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS template_steps (
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
file_path TEXT, created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_values (
id SERIAL PRIMARY KEY, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
` : `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
role TEXT CHECK(role IN ('admin','user')) DEFAULT 'user',
status TEXT CHECK(status IN ('aktiv','inaktiv')) DEFAULT 'inaktiv',
source TEXT CHECK(source IN ('local','ad')) DEFAULT 'local',
username TEXT, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS templates (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT,
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS template_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
file_path TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_values (
id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
`;
await execSql(baseSchema);
// Indexes
const indexes = [
'idx_sessions_token', 'idx_sessions_user_id', 'idx_sessions_expires',
'idx_tasks_user_id', 'idx_tasks_template_id', 'idx_tasks_status',
'idx_task_values_task_id', 'idx_task_values_step_id',
'idx_audit_log_created', 'idx_audit_log_user', 'idx_users_email', 'idx_users_source',
];
for (const idx of indexes) {
const table = idx.replace('idx_', '').replace('_id', '').replace('_at', '_created').replace('_token', '_token');
// Build CREATE INDEX statement
let col;
if (idx === 'idx_sessions_token') col = 'sessions(token)';
else if (idx === 'idx_sessions_user_id') col = 'sessions(user_id)';
else if (idx === 'idx_sessions_expires') col = 'sessions(expires_at)';
else if (idx === 'idx_tasks_user_id') col = 'tasks(user_id)';
else if (idx === 'idx_tasks_template_id') col = 'tasks(template_id)';
else if (idx === 'idx_tasks_status') col = 'tasks(status)';
else if (idx === 'idx_task_values_task_id') col = 'task_values(task_id)';
else if (idx === 'idx_task_values_step_id') col = 'task_values(step_id)';
else if (idx === 'idx_audit_log_created') col = 'audit_log(created_at)';
else if (idx === 'idx_audit_log_user') col = 'audit_log(user_id)';
else if (idx === 'idx_users_email') col = 'users(email)';
else if (idx === 'idx_users_source') col = 'users(source)';
await migrate(idx, `CREATE INDEX IF NOT EXISTS ${idx} ON ${col}`);
}
// Seed admin user
const bcrypt = require('bcryptjs');
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@workflow.local';
const ADMIN_INIT_PASSWORD = process.env.ADMIN_INIT_PASSWORD || '';
const existingAdmin = await db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL);
if (!existingAdmin) {
if (!ADMIN_INIT_PASSWORD) {
console.warn('WARNUNG: Kein ADMIN_INIT_PASSWORD gesetzt - kein Admin-Account erstellt.');
} else {
const hash = bcrypt.hashSync(ADMIN_INIT_PASSWORD, 12);
await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'admin\', \'aktiv\', \'local\')').run(ADMIN_EMAIL, hash, 'Superadmin');
console.log('Superadmin erstellt: ' + ADMIN_EMAIL);
}
}
// Periodic session cleanup
setInterval(async () => {
try {
const cleanupSql = isPostgres ? "DELETE FROM sessions WHERE expires_at < NOW()" : "DELETE FROM sessions WHERE expires_at < datetime('now')";
await db.prepare(cleanupSql).run();
} catch (err) {
console.error('Session cleanup error:', err.message);
}
}, 60 * 60 * 1000);
console.log('Datenbanktabellen initialisiert.');
}
module.exports = { initDatabase };

1974
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
backend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "workflow-backend",
"version": "1.0.0",
"description": "Workflow Portal Backend",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^8.6.2",
"helmet": "^8.2.0",
"ldapts": "^8.2.0",
"multer": "^2.2.0",
"pg": "^8.13.0",
"zod": "^3.24.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

124
backend/routes/ad.js Normal file
View File

@@ -0,0 +1,124 @@
/**
* AD/LDAP routes module.
*/
const express = require('express');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { auditLog } = require('../auditLog');
const { isLDAPConfigured } = require('../ldapSync');
const { browseOUTree, createADUser, checkADUserExists, deleteADUser } = require('../ldapOperations');
const { searchADGroups, addUserToGroups, browseADGroups } = require('../ldapOperations');
const { validate, createADUserSchema, deleteADUserSchema } = require('../middleware/validation');
const router = express.Router();
// LDAP Status Endpoint (public)
router.get('/status', (req, res) => {
res.json({ configured: isLDAPConfigured() });
});
// All other AD routes require auth
router.use(authMiddleware);
// Browse OU tree
router.get('/ou-tree', (req, res) => {
const { base } = req.query;
browseOUTree(base || undefined).then(ous => {
res.json(ous);
}).catch(err => {
console.error('[ERROR] GET /ad/ou-tree -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
});
// Search AD groups (all security groups)
router.get('/groups', (req, res) => {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.json([]);
}
searchADGroups(q.trim()).then(groups => {
res.json(groups);
}).catch(err => {
console.error('[ERROR] GET /ad/groups -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
});
// Browse all AD groups (for tree display)
router.get('/groups-tree', (req, res) => {
browseADGroups().then(groups => {
res.json(groups);
}).catch(err => {
console.error('[ERROR] GET /ad/groups-tree -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
});
// Get AD create config
router.get('/create-config', (req, res) => {
res.json({
configured: isLDAPConfigured(),
createOU: process.env.LDAP_CREATE_OU || '',
upnSuffix: process.env.LDAP_UPN_SUFFIX || process.env.LDAP_BIND_USER?.split('@')[1] || '',
});
});
// Create AD user (admin only)
router.post('/create-user', adminMiddleware, validate(createADUserSchema), async (req, res) => {
const { ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c, groups } = req.validatedBody;
try {
let sAMAccountName = username;
if (vorname && nachname) {
sAMAccountName = nachname.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue').replace(/ß/g, 'ss')
+ vorname.charAt(0).replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue').replace(/ß/g, 'ss');
sAMAccountName = sAMAccountName.replace(/[^a-zA-Z0-9]/g, '').substring(0, 20);
}
const existing = await checkADUserExists(username, sAMAccountName);
if (existing) {
return res.status(409).json({ error: 'Benutzername "' + username + '" existiert bereits im Active Directory.', dn: existing.distinguishedName });
}
const result = await createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c });
if (result.warning && result.dn) {
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `AD user created with warning: ${username} - ${result.warning}`);
} else {
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Created AD user: ${username}`);
}
// Add user to groups if specified
let groupResults = [];
if (groups && Array.isArray(groups) && groups.length > 0 && result.dn) {
try {
groupResults = await addUserToGroups(result.dn, groups);
const addedCount = groupResults.filter(r => r.status === 'added').length;
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Added ${username} to ${addedCount} group(s)`);
} catch (groupErr) {
console.error('[WARN] Gruppenzuweisung fehlgeschlagen:', groupErr.message);
groupResults = groups.map(dn => ({ dn, status: 'error', error: groupErr.message }));
}
}
res.status(201).json({ ...result, groupResults });
} catch (err) {
auditLog(req.user?.id, 'ad.create-user-failed', 'ad_user', null, `Failed to create AD user: ${username} - ${err.message}`);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Delete AD user (admin only)
router.delete('/delete-user', adminMiddleware, validate(deleteADUserSchema), async (req, res) => {
const { dn } = req.validatedBody;
try {
await deleteADUser(dn);
auditLog(req.user?.id, 'ad.delete-user', 'ad_user', null, `Deleted AD user: ${dn}`);
res.json({ success: true, message: 'Benutzer erfolgreich gelöscht.' });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Löschen des AD-Benutzers: ' + err.message });
}
});
module.exports = router;

147
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,147 @@
/**
* Auth routes module.
*
* Punkt 5: Register returns correct status ('inaktiv').
* Punkt 4: Session tokens are hashed (SHA-256) before storage.
* Punkt 6: Uses better-sqlite3 synchronous API.
*/
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie } = require('../middleware/auth');
const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync');
const { loginLimiter } = require('../middleware/rateLimit');
const { validate, registerSchema, loginSchema } = require('../middleware/validation');
const router = express.Router();
// Register
router.post('/register', validate(registerSchema), async (req, res) => {
const { email, password, name } = req.validatedBody;
try {
const hash = bcrypt.hashSync(password, 10);
// VULN-FIX: Force role to 'user' - never trust client-supplied role on register
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'local\')').run(email, hash, name);
const userId = info.lastInsertRowid;
auditLog(null, 'register', 'user', userId, `New registration: ${email}`);
// P4: Set CSRF cookie for the new session
const csrfToken = setCSRFCookie(res);
// Return correct status 'inaktiv' (Punkt 5 fix)
res.status(201).json({ id: userId, email, name, role: 'user', status: 'inaktiv', source: 'local', csrfToken, message: 'Registrierung erfolgreich. Ein Administrator muss dein Konto freischalten.' });
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'E-Mail bereits vergeben.' });
}
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Login
router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
const { email, password } = req.validatedBody;
const row = await db.prepare('SELECT id, email, name, role, status, source, username, password FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, email);
// Punkt 12: Account-Lockout check
if (row && await isAccountLocked(row.id)) {
return res.status(423).json({ error: 'Konto gesperrt wegen zu vieler fehlgeschlagener Anmeldeversuche. Bitte später erneut versuchen.' });
}
// If user not found locally, try LDAP auth
if (!row) {
if (isLDAPConfigured()) {
try {
const ldapResult = await authenticateLDAP(email, password);
const adRow = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE LOWER(username) = LOWER(?)').get(ldapResult.username);
if (!adRow) return res.status(404).json({ error: 'Nutzer im System nicht gefunden. Bitte warte auf die naechste Synchronisation.' });
if (adRow.status === 'inaktiv') return res.status(403).json({ error: 'Dein Konto ist deaktiviert.' });
await recordSuccessfulLogin(adRow.id);
// V6: Pass old token for session rotation (prevents session fixation)
const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(adRow.id, oldToken);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login');
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...adRow, csrfToken });
} catch (ldapErr) {
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
} else {
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
return;
}
if (row.status === 'inaktiv') {
return res.status(403).json({ error: 'Dein Konto ist deaktiviert. Bitte wende dich an einen Administrator.' });
}
if (row.source === 'ad') {
if (!isLDAPConfigured()) {
return res.status(403).json({ error: 'AD-Anmeldung nicht konfiguriert.' });
}
try {
await authenticateLDAP(row.username || row.email.split('@')[0], password);
await recordSuccessfulLogin(row.id);
// V6: Pass old token for session rotation (prevents session fixation)
const oldTokenAD = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(row.id, oldTokenAD);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
auditLog(row.id, 'login', 'user', row.id, 'AD login');
const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken });
} catch (ldapErr) {
await recordFailedLogin(row.id);
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
} else {
// Local user - check password with bcrypt (auto-upgrade from plaintext)
let passwordMatch = false;
if (row.password.startsWith('$2a$') || row.password.startsWith('$2b$')) {
passwordMatch = bcrypt.compareSync(password, row.password);
} else {
// Legacy plaintext comparison - auto-upgrade to bcrypt
passwordMatch = row.password === password;
if (passwordMatch) {
// P8: Log plaintext login for security monitoring (auto-upgrade follows)
auditLog(row.id, 'plaintext_login_upgraded', 'user', row.id, 'Legacy plaintext password upgraded to bcrypt');
console.warn('[SECURITY] User', row.email, 'logged in with plaintext password - upgrading to bcrypt.');
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
await db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, row.id);
}
}
if (!passwordMatch) {
await recordFailedLogin(row.id);
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
await recordSuccessfulLogin(row.id);
// V6: Pass old token for session rotation (prevents session fixation)
const oldTokenLocal = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(row.id, oldTokenLocal);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
auditLog(row.id, 'login', 'user', row.id, 'Local login');
const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken });
}
});
// Logout
router.post('/logout', async (req, res) => {
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
await deleteSession(rawToken);
clearAuthCookie(res); // Punkt 8: Clear HttpOnly-Cookie
res.json({ message: 'Abgemeldet.' });
});
// Check session
router.get('/me', authMiddleware, (req, res) => {
res.json(req.user);
});
module.exports = router;

83
backend/routes/stats.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* Stats and audit-log routes module.
*
* Punkt 7: Single aggregated query for stats instead of 9 nested callbacks.
*/
const express = require('express');
const db = require('../db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { validateQuery, paginationSchema } = require('../middleware/validation');
const router = express.Router();
router.use(authMiddleware);
router.use(adminMiddleware);
// Punkt 7: Single aggregated stats query
router.get('/stats', async (req, res) => {
try {
const rawStats = await db.prepare(`
SELECT
(SELECT COUNT(*) FROM users WHERE status = 'aktiv') as activeUsers,
(SELECT COUNT(*) FROM users) as totalUsers,
(SELECT COUNT(*) FROM tasks WHERE status = 'offen') as openTasks,
(SELECT COUNT(*) FROM tasks WHERE status = 'erledigt') as completedTasks,
(SELECT COUNT(*) FROM tasks) as totalTasks,
(SELECT COUNT(*) FROM templates) as totalTemplates,
(SELECT COUNT(*) FROM templates WHERE is_assignable = 1) as assignableTemplates,
(SELECT COUNT(*) FROM users WHERE source = 'ad') as adUsers,
(SELECT COUNT(*) FROM users WHERE source = 'local') as localUsers
`).get();
// PostgreSQL lowercases aliases; normalize keys and coerce counts to numbers.
const normalizeKey = (key) => key.toLowerCase();
const keyMap = {
activeusers: 'activeUsers',
totalusers: 'totalUsers',
opentasks: 'openTasks',
completedtasks: 'completedTasks',
totaltasks: 'totalTasks',
totaltemplates: 'totalTemplates',
assignabletemplates: 'assignableTemplates',
adusers: 'adUsers',
localusers: 'localUsers'
};
const stats = {};
for (const [key, value] of Object.entries(rawStats)) {
const normalized = normalizeKey(key);
const newKey = keyMap[normalized] || normalized;
stats[newKey] = typeof value === 'string' ? Number(value) : value;
}
const topTemplates = await db.prepare(
'SELECT t.id, t.name, COUNT(tk.id) as task_count FROM templates t LEFT JOIN tasks tk ON t.id = tk.template_id GROUP BY t.id ORDER BY task_count DESC LIMIT 5'
).all();
const recentActivity = await db.prepare(
'SELECT al.*, u.name as user_name, u.email as user_email FROM audit_log al LEFT JOIN users u ON al.user_id = u.id ORDER BY al.created_at DESC LIMIT 10'
).all();
res.json({ ...stats, topTemplates, recentActivity });
} catch (err) {
console.error('[ERROR] GET /stats -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Audit log with pagination (Punkt 16: bounded limits)
router.get('/audit-log', validateQuery(paginationSchema), async (req, res) => {
const { page, limit } = req.validatedQuery;
const offset = (page - 1) * limit;
try {
const rows = await db.prepare('SELECT al.*, u.name as user_name, u.email as user_email FROM audit_log al LEFT JOIN users u ON al.user_id = u.id ORDER BY al.created_at DESC LIMIT ? OFFSET ?').all(limit, offset);
const countRow = await db.prepare('SELECT COUNT(*) as total FROM audit_log').get();
const total = countRow?.total || 0;
res.json({ entries: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
} catch (err) {
console.error('[ERROR] GET /audit-log -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
module.exports = router;

216
backend/routes/tasks.js Normal file
View File

@@ -0,0 +1,216 @@
/**
* Tasks routes module.
*
* Punkt 8: Uses transactions for task creation with values.
* Punkt 6: Uses better-sqlite3 synchronous API.
* Punkt 23: Rate limiting on task creation.
*/
const express = require('express');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { taskCreateLimiter } = require('../middleware/rateLimit');
const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema } = require('../middleware/validation');
const router = express.Router();
router.use(authMiddleware);
// Create task - Punkt 8: Transaction
router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => {
const { template_id, title, values, file_path, user_id } = req.validatedBody;
// VULN-02: Mass Assignment prevention
const targetUserId = (req.user.role === 'admin' && req.body.user_id)
? parseInt(req.body.user_id)
: req.user.id;
if (!template_id || !title) {
return res.status(400).json({ error: 'template_id und title sind erforderlich.' });
}
const insertTask = db.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)');
const insertValue = db.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const createTask = db.transaction(async () => {
const info = await insertTask.run(template_id, targetUserId, title, file_path);
const taskId = info.lastInsertRowid;
if (values.length > 0) {
// Fetch step metadata for snapshot
const stepIds = values.map(v => v.step_id).filter(Boolean);
const stepMetaMap = {};
if (stepIds.length > 0) {
const validStepIds = stepIds.filter(id => Number.isInteger(id));
if (validStepIds.length > 0) {
const placeholders = validStepIds.map(() => '?').join(',');
const steps = await db.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds);
steps.forEach(s => { stepMetaMap[s.id] = s; });
}
}
for (const v of values) {
const meta = v.step_id ? stepMetaMap[v.step_id] : null;
await insertValue.run(
taskId, v.step_id, v.value || '', v.is_checked ? 1 : 0, v.file_path || null,
meta ? meta.label : null,
meta ? meta.type : null,
meta ? meta.page_num : null,
meta ? meta.ad_field : null,
meta ? meta.ad_prefix : null,
meta ? meta.dropdown_options : null,
meta ? meta.email_source_fields : null,
meta ? (meta.hidden ? 1 : 0) : 0
);
}
}
return taskId;
});
try {
const taskId = await createTask();
auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`);
res.status(201).json({ id: taskId, template_id, user_id: targetUserId, title, status: 'offen', file_path, values });
} catch (err) {
console.error('[ERROR] POST /tasks -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Update task status
router.patch('/:id/status', validate(updateTaskStatusSchema), async (req, res) => {
const taskId = parseInt(req.params.id);
const { status } = req.validatedBody;
// VULN-05: BOLA protection
const task = await db.prepare('SELECT user_id FROM tasks WHERE id = ?').get(taskId);
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
if (task.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Keine Berechtigung, diese Aufgabe zu aendern.' });
}
const info = await db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(status, taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`);
res.json({ id: taskId, status });
});
// Update task values (admin only)
router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), async (req, res) => {
const taskId = parseInt(req.params.id);
const { values } = req.validatedBody;
const updateValue = db.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?');
const updateTransaction = db.transaction(async (vals) => {
let updated = 0;
for (const v of vals) {
const info = await updateValue.run(v.value || '', v.is_checked ? 1 : 0, v.id, taskId);
updated += info.changes;
}
return updated;
});
try {
const updated = await updateTransaction(values);
auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`);
res.json({ updated, taskId });
} catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Add custom field to task (admin only)
router.post('/:id/add-field', adminMiddleware, validate(addTaskFieldSchema), async (req, res) => {
const taskId = parseInt(req.params.id);
const { label, type, value, page_num, dropdown_options, ad_field, hidden, email_source_fields } = req.validatedBody;
const fieldType = type || 'text_input';
const fieldValue = value || '';
const customDropdownOptions = dropdown_options || '';
const customAdField = ad_field || '';
const customHidden = hidden ? 1 : 0;
const customEmailSourceFields = email_source_fields || '';
try {
const info = await db.prepare(
'INSERT INTO task_values (task_id, step_id, value, is_checked, custom_label, custom_type, custom_dropdown_options, custom_ad_field, custom_hidden, custom_email_source_fields) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(taskId, fieldValue, fieldType === 'checkbox' ? 0 : 0, label.trim(), fieldType, customDropdownOptions, customAdField, customHidden, customEmailSourceFields);
auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`);
res.status(201).json({
id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType,
value: fieldValue, page_num: page_num || 1,
dropdown_options: customDropdownOptions, ad_field: customAdField,
hidden: customHidden, email_source_fields: customEmailSourceFields
});
} catch (err) {
console.error('Add field error:', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Delete custom field from task (admin only)
router.delete('/:id/fields/:fieldId', adminMiddleware, async (req, res) => {
const taskId = parseInt(req.params.id);
const fieldId = parseInt(req.params.fieldId);
const info = await db.prepare('DELETE FROM task_values WHERE id = ? AND task_id = ? AND custom_label IS NOT NULL').run(fieldId, taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Feld nicht gefunden oder kein benutzerdefiniertes Feld.' });
auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`);
res.json({ message: 'Feld gelöscht.' });
});
// Delete task (admin only)
router.delete('/:id', adminMiddleware, async (req, res) => {
const taskId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
auditLog(req.user?.id, 'delete_task', 'task', taskId, null);
res.json({ message: 'Aufgabe gelöscht.' });
});
// Single task endpoint
router.get('/:id', async (req, res) => {
const taskId = parseInt(req.params.id);
const task = await db.prepare('SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id WHERE t.id = ?').get(taskId);
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
const values = await db.prepare(
`SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id = ? ORDER BY ts.step_order ASC, tv.id ASC`
).all(taskId);
auditLog(req.user?.id, 'view_task', 'task', taskId, null);
res.json({ ...task, values: values || [] });
});
// List tasks with pagination (Punkt 16: bounded limits)
router.get('/', validateQuery(paginationSchema), async (req, res) => {
const { page, limit } = req.validatedQuery;
const offset = (page - 1) * limit;
const status = req.query.status;
let whereClause = '';
const params = [];
if (status && ['offen', 'erledigt'].includes(status)) {
whereClause = ' WHERE t.status = ?';
params.push(status);
}
const countSql = 'SELECT COUNT(*) as total FROM tasks t' + whereClause;
const dataSql = 'SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id' + whereClause + ' ORDER BY t.created_at DESC LIMIT ? OFFSET ?';
const countRow = await db.prepare(countSql).get(...params);
const tasks = await db.prepare(dataSql).all(...params, limit, offset);
const total = countRow?.total || 0;
if (tasks.length === 0) return res.json({ tasks: [], total: 0, page, limit, totalPages: 0 });
const taskIds = tasks.map(t => t.id).filter(id => Number.isInteger(id));
if (taskIds.length === 0) return res.json({ tasks: tasks.map(t => ({ ...t, values: [] })), total, page, limit, totalPages: Math.ceil(total / limit) });
const placeholders = taskIds.map(() => '?').join(',');
const values = await db.prepare(
`SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id IN (${placeholders})`
).all(...taskIds);
const result = tasks.map(t => ({
...t,
values: values.filter(v => v.task_id === t.id)
}));
res.json({ tasks: result, total, page, limit, totalPages: Math.ceil(total / limit) });
});
module.exports = router;

119
backend/routes/templates.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* Templates routes module.
*
* Punkt 8: Uses transactions for template updates (delete+insert steps).
* Punkt 6: Uses better-sqlite3 synchronous API.
*/
const express = require('express');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { validate, createTemplateSchema, updateTemplateSchema } = require('../middleware/validation');
const router = express.Router();
router.use(authMiddleware);
// List templates
router.get('/', async (req, res) => {
const templates = await db.prepare('SELECT * FROM templates ORDER BY id DESC').all();
if (templates.length === 0) return res.json([]);
const templateIds = templates.map(t => t.id).filter(id => Number.isInteger(id));
if (templateIds.length === 0) return res.json(templates.map(t => ({ ...t, steps: [] })));
const placeholders = templateIds.map(() => '?').join(',');
const steps = await db.prepare(`SELECT * FROM template_steps WHERE template_id IN (${placeholders}) ORDER BY step_order ASC`).all(...templateIds);
const result = templates.map(t => ({
...t,
steps: steps.filter(s => s.template_id === t.id)
}));
res.json(result);
});
// Create template (admin only) - Punkt 8: Transaction
router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, res) => {
const { name, description, is_assignable, allows_file_upload, ad_create, steps } = req.validatedBody;
const assignable = is_assignable ? 1 : 0;
const fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 1 : 0;
const insertTemplate = db.prepare('INSERT INTO templates (name, description, is_assignable, allows_file_upload, ad_create) VALUES (?, ?, ?, ?, ?)');
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// Punkt 8: Transaction for template + steps
const createTemplate = db.transaction(async () => {
const info = await insertTemplate.run(name, description, assignable, fileUpload, adCreate);
const templateId = info.lastInsertRowid;
for (const [idx, step] of steps.entries()) {
await insertStep.run(
templateId, step.page_num || 1, step.label, step.type, idx + 1,
step.email_domain || null, step.email_source_fields || null,
step.dropdown_options || null, step.ad_field || null,
step.hidden ? 1 : 0, step.ad_prefix || null
);
}
return templateId;
});
try {
const templateId = await createTemplate();
auditLog(req.user?.id, 'create_template', 'template', templateId, `Created template: ${name}`);
res.status(201).json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Update template (admin only) - Punkt 8: Transaction
router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req, res) => {
const templateId = parseInt(req.params.id);
const { name, description, is_assignable, allows_file_upload, ad_create, steps } = req.validatedBody;
const assignable = is_assignable ? 1 : 0;
const fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 1 : 0;
const updateTemplate = db.prepare('UPDATE templates SET name = ?, description = ?, is_assignable = ?, allows_file_upload = ?, ad_create = ? WHERE id = ?');
const deleteSteps = db.prepare('DELETE FROM template_steps WHERE template_id = ?');
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// Punkt 8: Transaction for update + delete old steps + insert new steps
const updateTemplateTransaction = db.transaction(async () => {
const info = await updateTemplate.run(name, description, assignable, fileUpload, adCreate, templateId);
if (info.changes === 0) throw new Error('NOT_FOUND');
await deleteSteps.run(templateId);
for (const [idx, step] of steps.entries()) {
await insertStep.run(
templateId, step.page_num || 1, step.label, step.type, idx + 1,
step.email_domain || null, step.email_source_fields || null,
step.dropdown_options || null, step.ad_field || null,
step.hidden ? 1 : 0, step.ad_prefix || null
);
}
});
try {
await updateTemplateTransaction();
auditLog(req.user?.id, 'update_template', 'template', templateId, `Updated template: ${name}`);
res.json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) {
if (err.message === 'NOT_FOUND') return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Delete template (admin only)
router.delete('/:id', adminMiddleware, async (req, res) => {
const templateId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM templates WHERE id = ?').run(templateId);
if (info.changes === 0) return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
auditLog(req.user?.id, 'delete_template', 'template', templateId, null);
res.json({ message: 'Vorlage gelöscht.' });
});
module.exports = router;

65
backend/routes/upload.js Normal file
View File

@@ -0,0 +1,65 @@
/**
* File upload routes module.
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { authMiddleware } = require('../middleware/auth');
const { uploadLimiter } = require('../middleware/rateLimit');
const { auditLog } = require('../auditLog');
const router = express.Router();
// File upload setup
const uploadDir = path.join(__dirname, '..', 'data', 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// VULN-08/09: Secure file upload
const ALLOWED_MIMES = ['application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
const ALLOWED_EXTS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt', '.doc', '.docx'];
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
const ext = path.extname(safeName).toLowerCase();
const safeExt = ALLOWED_EXTS.includes(ext) ? ext : '.bin';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + safeName.replace(/\.[^.]+$/, '') + safeExt);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (ALLOWED_MIMES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Dateityp nicht erlaubt. Erlaubt: PDF, PNG, JPG, GIF, TXT, DOC, DOCX.'));
}
},
});
// P12: Serve uploads as attachments (prevent XSS) - requires authentication
router.use('/uploads', authMiddleware, express.static(uploadDir, {
setHeaders: (res) => {
res.setHeader('Content-Disposition', 'attachment');
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}));
// Upload endpoint - Punkt 23: Rate limited per user
router.post('/', authMiddleware, uploadLimiter, upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen.' });
}
const fileUrl = '/uploads/' + req.file.filename;
auditLog(req.user?.id, 'file_upload', null, null, `Uploaded: ${req.file.filename} (${req.file.size} bytes)`);
res.json({ filename: req.file.filename, originalname: req.file.originalname, url: fileUrl, size: req.file.size });
});
module.exports = router;

172
backend/routes/users.js Normal file
View File

@@ -0,0 +1,172 @@
/**
* Users routes module.
*
* Uses better-sqlite3 synchronous API (Punkt 6).
* Proper authorization checks (Punkt 4).
*/
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware, invalidateUserSessions } = require('../middleware/auth');
const { validate, validateQuery, createUserSchema, updateUserSchema, paginationSchema } = require('../middleware/validation');
const router = express.Router();
// Apply auth to all user routes
router.use(authMiddleware);
// List users (admin only) - with server-side pagination (Punkt 16: bounded limits)
router.get('/', adminMiddleware, validateQuery(paginationSchema), async (req, res) => {
const { page, limit } = req.validatedQuery;
const offset = (page - 1) * limit;
const search = req.query.search;
let whereClause = '';
const params = [];
if (search) {
whereClause = ' WHERE LOWER(email) LIKE LOWER(?) OR LOWER(name) LIKE LOWER(?) OR LOWER(role) LIKE LOWER(?) OR LOWER(COALESCE(username, \'\')) LIKE LOWER(?)';
// P10: Escape LIKE wildcards in search pattern to prevent unintended matching
const escapedSearch = String(search).replace(/[%_\\]/g, '\\$&');
const searchPattern = `%${escapedSearch}%`;
params.push(searchPattern, searchPattern, searchPattern, searchPattern);
} else {
whereClause = ' WHERE status = \'aktiv\'';
}
const countSql = 'SELECT COUNT(*) as total FROM users' + whereClause;
const dataSql = 'SELECT id, email, name, role, status, source, username FROM users' + whereClause + ' ORDER BY id ASC LIMIT ? OFFSET ?';
const countRow = await db.prepare(countSql).get(...params);
const rows = await db.prepare(dataSql).all(...params, limit, offset);
const total = countRow?.total || 0;
res.json({ users: rows || [], total, page, limit, totalPages: Math.ceil(total / limit) });
});
// Create user (admin only)
router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) => {
const { email, password, name, role, status } = req.validatedBody;
try {
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, ?, ?, \'local\')').run(email, hash, name, role, status);
auditLog(req.user?.id, 'create_user', 'user', info.lastInsertRowid, `Created user: ${email}`);
res.status(201).json({ id: info.lastInsertRowid, email, name, role, status, source: 'local' });
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'E-Mail bereits vergeben.' });
}
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Update user
router.put('/:id', validate(updateUserSchema), async (req, res) => {
const userId = parseInt(req.params.id);
const { email, name, password, role, status, current_password } = req.validatedBody;
// VULN-04: Authorization check - only admin or self (with restrictions)
const isSelf = req.user.id === userId;
const isAdmin = req.user.role === 'admin';
if (!isAdmin && !isSelf) {
return res.status(403).json({ error: 'Keine Berechtigung, diesen Nutzer zu bearbeiten.' });
}
// Non-admins may NOT change role or status (privilege escalation prevention)
if (!isAdmin) {
delete req.validatedBody.role;
delete req.validatedBody.status;
}
// P7: Non-admins changing their own password must verify the current password
if (!isAdmin && isSelf && password && password.trim()) {
if (!current_password) {
return res.status(400).json({ error: 'Aktuelles Passwort ist erforderlich, um das Passwort zu ändern.' });
}
const userRow = await db.prepare('SELECT password FROM users WHERE id = ?').get(userId);
if (!userRow) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
let currentMatch = false;
if (userRow.password.startsWith('$2a$') || userRow.password.startsWith('$2b$')) {
currentMatch = bcrypt.compareSync(current_password, userRow.password);
} else {
currentMatch = userRow.password === current_password;
}
if (!currentMatch) {
return res.status(403).json({ error: 'Aktuelles Passwort ist falsch.' });
}
}
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
// AD users: only role and status can be changed
if (user.source === 'ad') {
const finalRole = role || user.role;
const finalStatus = status || user.status;
if (role && !['admin', 'user'].includes(role)) {
return res.status(400).json({ error: 'Rolle muss "admin" oder "user" sein.' });
}
if (status && !['aktiv', 'inaktiv'].includes(status)) {
return res.status(400).json({ error: 'Status muss "aktiv" oder "inaktiv" sein.' });
}
await db.prepare('UPDATE users SET role = ?, status = ? WHERE id = ?').run(finalRole, finalStatus, userId);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated AD user role/status`);
return res.json({ id: userId, email: user.email, name: user.name, role: finalRole, status: finalStatus, source: user.source, username: user.username });
}
// Local users: full edit
if (!email) {
return res.status(400).json({ error: 'E-Mail ist erforderlich.' });
}
if (role && !['admin', 'user'].includes(role)) {
return res.status(400).json({ error: 'Rolle muss "admin" oder "user" sein.' });
}
if (status && !['aktiv', 'inaktiv'].includes(status)) {
return res.status(400).json({ error: 'Status muss "aktiv" oder "inaktiv" sein.' });
}
const finalName = name !== undefined ? name : (user.name || '');
const finalRole = role || user.role;
const finalStatus = status || user.status;
if (password && password.trim()) {
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
try {
await db.prepare('UPDATE users SET email = ?, name = ?, password = ?, role = ?, status = ? WHERE id = ?').run(email, finalName, hash, finalRole, finalStatus, userId);
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'E-Mail bereits vergeben.' });
}
throw err;
}
// VULN-13: Invalidate all sessions for this user after password change
await invalidateUserSessions(userId);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user with new password (sessions invalidated)`);
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
} else {
try {
await db.prepare('UPDATE users SET email = ?, name = ?, role = ?, status = ? WHERE id = ?').run(email, finalName, finalRole, finalStatus, userId);
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'E-Mail bereits vergeben.' });
}
throw err;
}
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user`);
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
}
});
// Delete user (admin only)
router.delete('/:id', adminMiddleware, async (req, res) => {
const userId = parseInt(req.params.id);
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
if (user.source === 'ad') {
return res.status(403).json({ error: 'AD-Nutzer koennen nicht geloescht werden. Bitte im Active Directory entfernen.' });
}
const info = await db.prepare('DELETE FROM users WHERE id = ?').run(userId);
if (info.changes === 0) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
auditLog(req.user?.id, 'delete_user', 'user', userId, `Deleted user: ${user.email}`);
res.json({ message: 'Nutzer geloescht.' });
});
module.exports = router;

215
backend/server.js Normal file
View File

@@ -0,0 +1,215 @@
/**
* 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;
// Trust proxy for correct IP in rate limiting (Docker/Reverse Proxy)
app.set('trust proxy', 1);
// ============ 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];
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
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
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
app.use(express.json({ limit: process.env.BODY_LIMIT || '1mb' }));
// 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)
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')) {
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();