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:
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;
|
||||
Reference in New Issue
Block a user