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:
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