/** * 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}`, req); 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); // 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.' }); } // K1: Strip role/status FIRST for non-admins (BEFORE reading values) to prevent // privilege escalation via mass assignment. Earlier code destructured first // and deleted afterwards, which left the local copies intact. if (!isAdmin) { delete req.validatedBody.role; delete req.validatedBody.status; } // Now safe to read — non-admins can never see role/status here const { email, name, password, role, status, current_password } = req.validatedBody; // 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`, req); 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)`, req); 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`, req); 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}`, req); res.json({ message: 'Nutzer geloescht.' }); }); module.exports = router;