security: Rechte-Eskalation über Handler-Tokens schließen

Ergänzt die Absicherung aus 8384ad9/d9fecb6 um die Lücken, die dort offen
geblieben sind.

- authenticateToken prüft jetzt die Rolle. Der C-01-Fix vergleicht nur
  `decoded.app`, und genau den Claim tragen Handler-Tokens nicht
  ({ id, role: 'handler' }) – `if (decoded.app && ...)` lief bei ihnen
  also ins Leere. Ein eingeloggter Hundeführer konnte seinen Bearer-Token
  weiterhin gegen /api/users, /api/config und /api/audit-logs schicken und
  hatte volle Admin-Rechte. Admin-Tokens tragen zusätzlich role: 'admin';
  Tokens ohne role bleiben bis zum Ablauf gültig, niemand wird ausgeloggt.
- Logout wird als LOGOUT protokolliert statt als LOGIN, und die Route
  liest den Benutzernamen über attachUserIfPresent (neu). Vorher lag gar
  keine Auth-Middleware auf /logout, der Eintrag lautete immer "unknown".
- forgotPassword sucht case-insensitive wie der Login. Ein als "Thorsten"
  angelegtes Konto konnte sich als "thorsten" anmelden, aber kein Passwort
  zurücksetzen.
- CSV-Export escaped führende =, +, -, @ (neues utils/csv.js, von
  userController und auditController gemeinsam genutzt). Ein Name wie
  =HYPERLINK(...) wurde sonst von Excel als Formel ausgeführt.
- Massen-Operationen filtern soft-deleted Einträge; der pre(/^find/)-Hook
  des Modells greift bei updateMany nicht.
- Die öffentliche Liste liefert wieder phone, landline, address und photo.
  Die UI zeigt diese Felder seit jeher an, die API lieferte sie seit dem
  PII-Commit nicht mehr – Anrufen war öffentlich nicht mehr möglich.
  E-Mail, Hashes und Invite-Felder bleiben ausgeschlossen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
thomas 2026-08-25 08:09:34 +02:00
parent d9fecb6914
commit 4b61db82fd
18 changed files with 285 additions and 84 deletions

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token // Generate token
const token = jwt.sign( const token = jwt.sign(
{ id: admin._id, username: admin.username, app: config.appName }, { id: admin._id, username: admin.username, app: config.appName, role: 'admin' },
config.jwtSecret, config.jwtSecret,
{ expiresIn: config.jwtExpiresIn } { expiresIn: config.jwtExpiresIn }
); );
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try { try {
// Log logout (get username from token if available) // Log logout (get username from token if available)
const username = req.user?.username || 'unknown'; const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null); await auditAuth(req, true, username, null, 'LOGOUT');
// Clear the token cookie // Clear the token cookie
res.clearCookie('token', { res.clearCookie('token', {
@ -119,7 +119,9 @@ const forgotPassword = async (req, res) => {
} }
// Find admin // Find admin
const admin = await Admin.findOne({ username }); // case-insensitive wie beim Login: ein als "Thorsten" angelegtes Konto
// konnte sich als "thorsten" anmelden, aber kein Passwort zuruecksetzen.
const admin = await Admin.findOne({ username: { $regex: new RegExp(`^${String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
// Don't reveal if user exists (security best practice) // Don't reveal if user exists (security best practice)
if (!admin) { if (!admin) {

View File

@ -1,6 +1,7 @@
const User = require('../models/User'); const User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode'); const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env'); const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes']; const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
@ -285,7 +286,9 @@ const getPublicUsers = async (req, res) => {
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 }) .sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.select('name type available gps'), // Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
User.countDocuments(filter) User.countDocuments(filter)
]); ]);
@ -411,14 +414,6 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy'); .select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') { if (format === 'csv') {
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str;
};
const csv = [ const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [ ...users.map(user => [
@ -590,8 +585,10 @@ const bulkUpdateUsers = async (req, res) => {
} }
// Perform bulk update // Perform bulk update
// Der pre(/^find/)-Hook des Modells greift bei updateMany nicht,
// der Soft-Delete-Filter muss hier explizit gesetzt werden.
const result = await User.updateMany( const result = await User.updateMany(
{ _id: { $in: ids } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ $set: updateFields } { $set: updateFields }
); );
@ -629,8 +626,10 @@ const bulkDeleteUsers = async (req, res) => {
} }
// Soft delete all users // Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany( const result = await User.updateMany(
{ _id: { $in: ids } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ {
$set: { $set: {
deleted: true, deleted: true,

View File

@ -141,10 +141,12 @@ const auditLog = (action, resource) => {
/** /**
* Log authentication attempts (success and failure) * Log authentication attempts (success and failure)
*/ */
const auditAuth = async (req, isSuccess, username, errorMessage = null) => { // `action` überschreibt die Vorbelegung z. B. 'LOGOUT' für die Abmeldung,
// die sonst fälschlich als LOGIN im Protokoll landen würde.
const auditAuth = async (req, isSuccess, username, errorMessage = null, action = null) => {
try { try {
await AuditLog.log({ await AuditLog.log({
action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED', action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'),
resource: 'Admin', resource: 'Admin',
adminUsername: username, adminUsername: username,
ipAddress: req.ip || req.connection?.remoteAddress, ipAddress: req.ip || req.connection?.remoteAddress,

View File

@ -1,10 +1,11 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config/env'); const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => { const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method) // Try to get token from cookie first (new secure method)
let token = req.cookies?.token; let token = req.cookies?.token;
// Fallback: Check Authorization header for backward compatibility // Fallback: Check Authorization header for backward compatibility
if (!token) { if (!token) {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
@ -12,29 +13,70 @@ const authenticateToken = (req, res, next) => {
} }
if (!token) { if (!token) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
message: 'Zugriff verweigert. Kein Token bereitgestellt.' message: 'Zugriff verweigert. Kein Token bereitgestellt.'
}); });
} }
try { try {
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix) // Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) { if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Ungültiger oder abgelaufener Token.' message: 'Ungültiger oder abgelaufener Token.'
}); });
} }
// Rollenprüfung. Handler-Tokens werden mit demselben Secret signiert; die
// app-Prüfung oben greift bei ihnen nicht, weil sie keinen app-Claim tragen.
// Ohne diese Zeilen kann ein eingeloggter Hundeführer seinen Bearer-Token
// gegen /api/users, /api/config und /api/audit-logs schicken und hat volle
// Admin-Rechte. Tokens ohne role stammen aus der Zeit davor und wurden
// ausschließlich für Admins ausgestellt.
if (decoded.role && decoded.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Zugriff verweigert. Keine Administratorrechte.'
});
}
req.user = decoded; req.user = decoded;
next(); next();
} catch (error) { } catch (error) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Ungültiger oder abgelaufener Token.' message: 'Ungültiger oder abgelaufener Token.'
}); });
} }
}; };
module.exports = { authenticateToken }; // Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges
// Admin-Token vorliegt und ruft ansonsten einfach next(). Für Endpunkte wie
// /logout, die auch mit abgelaufenem Token funktionieren müssen.
const attachUserIfPresent = (req, res, next) => {
let token = req.cookies?.token;
if (!token) {
const authHeader = req.headers['authorization'];
token = authHeader && authHeader.split(' ')[1];
}
if (token) {
try {
const decoded = jwt.verify(token, config.jwtSecret);
const appOk = !decoded.app || decoded.app === config.appName;
const roleOk = !decoded.role || decoded.role === 'admin';
if (appOk && roleOk) {
req.user = decoded;
}
} catch (error) {
// Ungültiges Token ist hier kein Fehler der Aufrufer wird ohne req.user bedient.
}
}
next();
};
module.exports = { authenticateToken, attachUserIfPresent };

View File

@ -2,9 +2,12 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController'); const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator'); const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login); router.post('/login', validateLogin, login);
router.post('/logout', logout); // attachUserIfPresent statt authenticateToken: der Logout muss auch mit
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/forgot-password', forgotPassword); router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword); router.post('/reset-password', resetPassword);

View File

@ -0,0 +1,19 @@
/**
* Escaped eine einzelne CSV-Zelle.
*
* Neben dem üblichen Quoting werden Werte neutralisiert, die mit =, +, - oder @
* beginnen: Excel und LibreOffice würden sie sonst als Formel auswerten
* (CSV-Injection über einen frei wählbaren Namen oder eine Adresse).
*/
const escapeCell = (val) => {
if (val == null) return '';
let str = String(val);
if (/^[=+\-@\t\r]/.test(str)) {
str = `'${str}`;
}
return /["\n\r,]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
};
module.exports = { escapeCell };

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token // Generate token
const token = jwt.sign( const token = jwt.sign(
{ id: admin._id, username: admin.username, app: config.appName }, { id: admin._id, username: admin.username, app: config.appName, role: 'admin' },
config.jwtSecret, config.jwtSecret,
{ expiresIn: config.jwtExpiresIn } { expiresIn: config.jwtExpiresIn }
); );
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try { try {
// Log logout (get username from token if available) // Log logout (get username from token if available)
const username = req.user?.username || 'unknown'; const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null); await auditAuth(req, true, username, null, 'LOGOUT');
const secureCookie = config.nodeEnv === 'production' const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https') ? (req.secure || req.headers['x-forwarded-proto'] === 'https')
@ -123,7 +123,9 @@ const forgotPassword = async (req, res) => {
} }
// Find admin // Find admin
const admin = await Admin.findOne({ username }); // case-insensitive wie beim Login: ein als "Thorsten" angelegtes Konto
// konnte sich als "thorsten" anmelden, aber kein Passwort zuruecksetzen.
const admin = await Admin.findOne({ username: { $regex: new RegExp(`^${String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
// Don't reveal if user exists (security best practice) // Don't reveal if user exists (security best practice)
if (!admin) { if (!admin) {

View File

@ -1,6 +1,7 @@
const User = require('../models/User'); const User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode'); const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env'); const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes']; const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
@ -288,7 +289,9 @@ const getPublicUsers = async (req, res) => {
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 }) .sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.select('name type available gps'), // Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
User.countDocuments(filter) User.countDocuments(filter)
]); ]);
@ -414,14 +417,6 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy'); .select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') { if (format === 'csv') {
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str;
};
const csv = [ const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [ ...users.map(user => [
@ -593,8 +588,10 @@ const bulkUpdateUsers = async (req, res) => {
} }
// Perform bulk update // Perform bulk update
// Der pre(/^find/)-Hook des Modells greift bei updateMany nicht,
// der Soft-Delete-Filter muss hier explizit gesetzt werden.
const result = await User.updateMany( const result = await User.updateMany(
{ _id: { $in: ids } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ $set: updateFields } { $set: updateFields }
); );
@ -632,8 +629,10 @@ const bulkDeleteUsers = async (req, res) => {
} }
// Soft delete all users // Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany( const result = await User.updateMany(
{ _id: { $in: ids } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ {
$set: { $set: {
deleted: true, deleted: true,

View File

@ -141,10 +141,12 @@ const auditLog = (action, resource) => {
/** /**
* Log authentication attempts (success and failure) * Log authentication attempts (success and failure)
*/ */
const auditAuth = async (req, isSuccess, username, errorMessage = null) => { // `action` überschreibt die Vorbelegung z. B. 'LOGOUT' für die Abmeldung,
// die sonst fälschlich als LOGIN im Protokoll landen würde.
const auditAuth = async (req, isSuccess, username, errorMessage = null, action = null) => {
try { try {
await AuditLog.log({ await AuditLog.log({
action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED', action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'),
resource: 'Admin', resource: 'Admin',
adminUsername: username, adminUsername: username,
ipAddress: req.ip || req.connection?.remoteAddress, ipAddress: req.ip || req.connection?.remoteAddress,

View File

@ -1,10 +1,11 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config/env'); const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => { const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method) // Try to get token from cookie first (new secure method)
let token = req.cookies?.token; let token = req.cookies?.token;
// Fallback: Check Authorization header for backward compatibility // Fallback: Check Authorization header for backward compatibility
if (!token) { if (!token) {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
@ -12,29 +13,70 @@ const authenticateToken = (req, res, next) => {
} }
if (!token) { if (!token) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
message: 'Zugriff verweigert. Kein Token bereitgestellt.' message: 'Zugriff verweigert. Kein Token bereitgestellt.'
}); });
} }
try { try {
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix) // Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) { if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Ungültiger oder abgelaufener Token.' message: 'Ungültiger oder abgelaufener Token.'
}); });
} }
// Rollenprüfung. Handler-Tokens werden mit demselben Secret signiert; die
// app-Prüfung oben greift bei ihnen nicht, weil sie keinen app-Claim tragen.
// Ohne diese Zeilen kann ein eingeloggter Hundeführer seinen Bearer-Token
// gegen /api/users, /api/config und /api/audit-logs schicken und hat volle
// Admin-Rechte. Tokens ohne role stammen aus der Zeit davor und wurden
// ausschließlich für Admins ausgestellt.
if (decoded.role && decoded.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Zugriff verweigert. Keine Administratorrechte.'
});
}
req.user = decoded; req.user = decoded;
next(); next();
} catch (error) { } catch (error) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Ungültiger oder abgelaufener Token.' message: 'Ungültiger oder abgelaufener Token.'
}); });
} }
}; };
module.exports = { authenticateToken }; // Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges
// Admin-Token vorliegt und ruft ansonsten einfach next(). Für Endpunkte wie
// /logout, die auch mit abgelaufenem Token funktionieren müssen.
const attachUserIfPresent = (req, res, next) => {
let token = req.cookies?.token;
if (!token) {
const authHeader = req.headers['authorization'];
token = authHeader && authHeader.split(' ')[1];
}
if (token) {
try {
const decoded = jwt.verify(token, config.jwtSecret);
const appOk = !decoded.app || decoded.app === config.appName;
const roleOk = !decoded.role || decoded.role === 'admin';
if (appOk && roleOk) {
req.user = decoded;
}
} catch (error) {
// Ungültiges Token ist hier kein Fehler der Aufrufer wird ohne req.user bedient.
}
}
next();
};
module.exports = { authenticateToken, attachUserIfPresent };

View File

@ -2,9 +2,12 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController'); const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator'); const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login); router.post('/login', validateLogin, login);
router.post('/logout', logout); // attachUserIfPresent statt authenticateToken: der Logout muss auch mit
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/forgot-password', forgotPassword); router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword); router.post('/reset-password', resetPassword);

View File

@ -0,0 +1,19 @@
/**
* Escaped eine einzelne CSV-Zelle.
*
* Neben dem üblichen Quoting werden Werte neutralisiert, die mit =, +, - oder @
* beginnen: Excel und LibreOffice würden sie sonst als Formel auswerten
* (CSV-Injection über einen frei wählbaren Namen oder eine Adresse).
*/
const escapeCell = (val) => {
if (val == null) return '';
let str = String(val);
if (/^[=+\-@\t\r]/.test(str)) {
str = `'${str}`;
}
return /["\n\r,]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
};
module.exports = { escapeCell };

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token // Generate token
const token = jwt.sign( const token = jwt.sign(
{ id: admin._id, username: admin.username, app: config.appName }, { id: admin._id, username: admin.username, app: config.appName, role: 'admin' },
config.jwtSecret, config.jwtSecret,
{ expiresIn: config.jwtExpiresIn } { expiresIn: config.jwtExpiresIn }
); );
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try { try {
// Log logout (get username from token if available) // Log logout (get username from token if available)
const username = req.user?.username || 'unknown'; const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null); await auditAuth(req, true, username, null, 'LOGOUT');
// Clear the token cookie // Clear the token cookie
res.clearCookie('token', { res.clearCookie('token', {
@ -119,7 +119,9 @@ const forgotPassword = async (req, res) => {
} }
// Find admin // Find admin
const admin = await Admin.findOne({ username }); // case-insensitive wie beim Login: ein als "Thorsten" angelegtes Konto
// konnte sich als "thorsten" anmelden, aber kein Passwort zuruecksetzen.
const admin = await Admin.findOne({ username: { $regex: new RegExp(`^${String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
// Don't reveal if user exists (security best practice) // Don't reveal if user exists (security best practice)
if (!admin) { if (!admin) {

View File

@ -1,6 +1,7 @@
const User = require('../models/User'); const User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode'); const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env'); const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes']; const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
@ -285,7 +286,9 @@ const getPublicUsers = async (req, res) => {
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 }) .sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.select('name type available gps'), // Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
User.countDocuments(filter) User.countDocuments(filter)
]); ]);
@ -411,14 +414,6 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy'); .select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') { if (format === 'csv') {
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str;
};
const csv = [ const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [ ...users.map(user => [
@ -590,8 +585,10 @@ const bulkUpdateUsers = async (req, res) => {
} }
// Perform bulk update // Perform bulk update
// Der pre(/^find/)-Hook des Modells greift bei updateMany nicht,
// der Soft-Delete-Filter muss hier explizit gesetzt werden.
const result = await User.updateMany( const result = await User.updateMany(
{ _id: { $in: ids } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ $set: updateFields } { $set: updateFields }
); );
@ -629,8 +626,10 @@ const bulkDeleteUsers = async (req, res) => {
} }
// Soft delete all users // Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany( const result = await User.updateMany(
{ _id: { $in: ids } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ {
$set: { $set: {
deleted: true, deleted: true,

View File

@ -141,10 +141,12 @@ const auditLog = (action, resource) => {
/** /**
* Log authentication attempts (success and failure) * Log authentication attempts (success and failure)
*/ */
const auditAuth = async (req, isSuccess, username, errorMessage = null) => { // `action` überschreibt die Vorbelegung z. B. 'LOGOUT' für die Abmeldung,
// die sonst fälschlich als LOGIN im Protokoll landen würde.
const auditAuth = async (req, isSuccess, username, errorMessage = null, action = null) => {
try { try {
await AuditLog.log({ await AuditLog.log({
action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED', action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'),
resource: 'Admin', resource: 'Admin',
adminUsername: username, adminUsername: username,
ipAddress: req.ip || req.connection?.remoteAddress, ipAddress: req.ip || req.connection?.remoteAddress,

View File

@ -1,10 +1,11 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config/env'); const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => { const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method) // Try to get token from cookie first (new secure method)
let token = req.cookies?.token; let token = req.cookies?.token;
// Fallback: Check Authorization header for backward compatibility // Fallback: Check Authorization header for backward compatibility
if (!token) { if (!token) {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
@ -12,29 +13,70 @@ const authenticateToken = (req, res, next) => {
} }
if (!token) { if (!token) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
message: 'Zugriff verweigert. Kein Token bereitgestellt.' message: 'Zugriff verweigert. Kein Token bereitgestellt.'
}); });
} }
try { try {
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix) // Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) { if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Ungültiger oder abgelaufener Token.' message: 'Ungültiger oder abgelaufener Token.'
}); });
} }
// Rollenprüfung. Handler-Tokens werden mit demselben Secret signiert; die
// app-Prüfung oben greift bei ihnen nicht, weil sie keinen app-Claim tragen.
// Ohne diese Zeilen kann ein eingeloggter Hundeführer seinen Bearer-Token
// gegen /api/users, /api/config und /api/audit-logs schicken und hat volle
// Admin-Rechte. Tokens ohne role stammen aus der Zeit davor und wurden
// ausschließlich für Admins ausgestellt.
if (decoded.role && decoded.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Zugriff verweigert. Keine Administratorrechte.'
});
}
req.user = decoded; req.user = decoded;
next(); next();
} catch (error) { } catch (error) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Ungültiger oder abgelaufener Token.' message: 'Ungültiger oder abgelaufener Token.'
}); });
} }
}; };
module.exports = { authenticateToken }; // Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges
// Admin-Token vorliegt und ruft ansonsten einfach next(). Für Endpunkte wie
// /logout, die auch mit abgelaufenem Token funktionieren müssen.
const attachUserIfPresent = (req, res, next) => {
let token = req.cookies?.token;
if (!token) {
const authHeader = req.headers['authorization'];
token = authHeader && authHeader.split(' ')[1];
}
if (token) {
try {
const decoded = jwt.verify(token, config.jwtSecret);
const appOk = !decoded.app || decoded.app === config.appName;
const roleOk = !decoded.role || decoded.role === 'admin';
if (appOk && roleOk) {
req.user = decoded;
}
} catch (error) {
// Ungültiges Token ist hier kein Fehler der Aufrufer wird ohne req.user bedient.
}
}
next();
};
module.exports = { authenticateToken, attachUserIfPresent };

View File

@ -2,9 +2,12 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController'); const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator'); const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login); router.post('/login', validateLogin, login);
router.post('/logout', logout); // attachUserIfPresent statt authenticateToken: der Logout muss auch mit
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/forgot-password', forgotPassword); router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword); router.post('/reset-password', resetPassword);

View File

@ -0,0 +1,19 @@
/**
* Escaped eine einzelne CSV-Zelle.
*
* Neben dem üblichen Quoting werden Werte neutralisiert, die mit =, +, - oder @
* beginnen: Excel und LibreOffice würden sie sonst als Formel auswerten
* (CSV-Injection über einen frei wählbaren Namen oder eine Adresse).
*/
const escapeCell = (val) => {
if (val == null) return '';
let str = String(val);
if (/^[=+\-@\t\r]/.test(str)) {
str = `'${str}`;
}
return /["\n\r,]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
};
module.exports = { escapeCell };