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:
2
backend/.dockerignore
Normal file
2
backend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
data
|
||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal 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
18
backend/auditLog.js
Normal 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
154
backend/db.js
Normal 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
444
backend/ldapOperations.js
Normal 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
240
backend/ldapSync.js
Normal 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
170
backend/middleware/auth.js
Normal 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
|
||||
};
|
||||
46
backend/middleware/rateLimit.js
Normal file
46
backend/middleware/rateLimit.js
Normal 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 };
|
||||
209
backend/middleware/validation.js
Normal file
209
backend/middleware/validation.js
Normal 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
202
backend/migrations.js
Normal 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
1974
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
backend/package.json
Normal file
27
backend/package.json
Normal 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
124
backend/routes/ad.js
Normal 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
147
backend/routes/auth.js
Normal 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
83
backend/routes/stats.js
Normal 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
216
backend/routes/tasks.js
Normal 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
119
backend/routes/templates.js
Normal 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
65
backend/routes/upload.js
Normal 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
172
backend/routes/users.js
Normal 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
215
backend/server.js
Normal 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();
|
||||
Reference in New Issue
Block a user