diff --git a/drohnenfuehrer/backend/controllers/authController.js b/drohnenfuehrer/backend/controllers/authController.js index 71a7839..fea2aee 100644 --- a/drohnenfuehrer/backend/controllers/authController.js +++ b/drohnenfuehrer/backend/controllers/authController.js @@ -40,7 +40,7 @@ const login = async (req, res) => { // Generate token 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, { expiresIn: config.jwtExpiresIn } ); @@ -81,7 +81,7 @@ const logout = async (req, res) => { try { // Log logout (get username from token if available) const username = req.user?.username || 'unknown'; - await auditAuth(req, true, username, null); + await auditAuth(req, true, username, null, 'LOGOUT'); // Clear the token cookie res.clearCookie('token', { @@ -119,7 +119,9 @@ const forgotPassword = async (req, res) => { } // 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) if (!admin) { diff --git a/drohnenfuehrer/backend/controllers/userController.js b/drohnenfuehrer/backend/controllers/userController.js index 1f412a0..42f07c7 100644 --- a/drohnenfuehrer/backend/controllers/userController.js +++ b/drohnenfuehrer/backend/controllers/userController.js @@ -1,6 +1,7 @@ const User = require('../models/User'); const { geocodeAddress } = require('../utils/geocode'); const logger = require('../utils/logger'); +const { escapeCell } = require('../utils/csv'); const config = require('../config/env'); 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 }) .skip(skip) .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) ]); @@ -411,14 +414,6 @@ const exportUsers = async (req, res) => { .select('-__v -passwordHash -deleted -deletedAt -deletedBy'); 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 = [ ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ...users.map(user => [ @@ -590,8 +585,10 @@ const bulkUpdateUsers = async (req, res) => { } // 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( - { _id: { $in: ids } }, + { _id: { $in: ids }, deleted: { $ne: true } }, { $set: updateFields } ); @@ -629,8 +626,10 @@ const bulkDeleteUsers = async (req, res) => { } // Soft delete all users + // Bereits geloeschte Eintraege bleiben unangetastet, damit + // deletedAt/deletedBy nicht ueberschrieben werden. const result = await User.updateMany( - { _id: { $in: ids } }, + { _id: { $in: ids }, deleted: { $ne: true } }, { $set: { deleted: true, diff --git a/drohnenfuehrer/backend/middleware/auditLogger.js b/drohnenfuehrer/backend/middleware/auditLogger.js index 64a18af..413cfb8 100644 --- a/drohnenfuehrer/backend/middleware/auditLogger.js +++ b/drohnenfuehrer/backend/middleware/auditLogger.js @@ -141,10 +141,12 @@ const auditLog = (action, resource) => { /** * 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 { await AuditLog.log({ - action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED', + action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'), resource: 'Admin', adminUsername: username, ipAddress: req.ip || req.connection?.remoteAddress, diff --git a/drohnenfuehrer/backend/middleware/auth.js b/drohnenfuehrer/backend/middleware/auth.js index b7e2ab1..99c848f 100644 --- a/drohnenfuehrer/backend/middleware/auth.js +++ b/drohnenfuehrer/backend/middleware/auth.js @@ -1,10 +1,11 @@ const jwt = require('jsonwebtoken'); const config = require('../config/env'); +// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt. const authenticateToken = (req, res, next) => { // Try to get token from cookie first (new secure method) let token = req.cookies?.token; - + // Fallback: Check Authorization header for backward compatibility if (!token) { const authHeader = req.headers['authorization']; @@ -12,29 +13,70 @@ const authenticateToken = (req, res, next) => { } if (!token) { - return res.status(401).json({ - success: false, - message: 'Zugriff verweigert. Kein Token bereitgestellt.' + return res.status(401).json({ + success: false, + message: 'Zugriff verweigert. Kein Token bereitgestellt.' }); } try { const decoded = jwt.verify(token, config.jwtSecret); + // Reject tokens issued by a different app (C-01 cross-app auth fix) if (decoded.app && decoded.app !== config.appName) { - return res.status(403).json({ - success: false, - message: 'Ungültiger oder abgelaufener Token.' + return res.status(403).json({ + success: false, + 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; next(); } catch (error) { - return res.status(403).json({ - success: false, - message: 'Ungültiger oder abgelaufener Token.' + return res.status(403).json({ + success: false, + 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 }; diff --git a/drohnenfuehrer/backend/routes/authRoutes.js b/drohnenfuehrer/backend/routes/authRoutes.js index cbf2e28..7fac6ac 100644 --- a/drohnenfuehrer/backend/routes/authRoutes.js +++ b/drohnenfuehrer/backend/routes/authRoutes.js @@ -2,9 +2,12 @@ const express = require('express'); const router = express.Router(); const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController'); const { validateLogin } = require('../middleware/validator'); +const { attachUserIfPresent } = require('../middleware/auth'); 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('/reset-password', resetPassword); diff --git a/drohnenfuehrer/backend/utils/csv.js b/drohnenfuehrer/backend/utils/csv.js new file mode 100644 index 0000000..bdf165d --- /dev/null +++ b/drohnenfuehrer/backend/utils/csv.js @@ -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 }; diff --git a/nachsuche/backend/controllers/authController.js b/nachsuche/backend/controllers/authController.js index 8fe1d3b..3975e84 100644 --- a/nachsuche/backend/controllers/authController.js +++ b/nachsuche/backend/controllers/authController.js @@ -40,7 +40,7 @@ const login = async (req, res) => { // Generate token 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, { expiresIn: config.jwtExpiresIn } ); @@ -81,7 +81,7 @@ const logout = async (req, res) => { try { // Log logout (get username from token if available) const username = req.user?.username || 'unknown'; - await auditAuth(req, true, username, null); + await auditAuth(req, true, username, null, 'LOGOUT'); const secureCookie = config.nodeEnv === 'production' ? (req.secure || req.headers['x-forwarded-proto'] === 'https') @@ -123,7 +123,9 @@ const forgotPassword = async (req, res) => { } // 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) if (!admin) { diff --git a/nachsuche/backend/controllers/userController.js b/nachsuche/backend/controllers/userController.js index 36ff980..8d203ae 100644 --- a/nachsuche/backend/controllers/userController.js +++ b/nachsuche/backend/controllers/userController.js @@ -1,6 +1,7 @@ const User = require('../models/User'); const { geocodeAddress } = require('../utils/geocode'); const logger = require('../utils/logger'); +const { escapeCell } = require('../utils/csv'); const config = require('../config/env'); 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 }) .skip(skip) .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) ]); @@ -414,14 +417,6 @@ const exportUsers = async (req, res) => { .select('-__v -passwordHash -deleted -deletedAt -deletedBy'); 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 = [ ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ...users.map(user => [ @@ -593,8 +588,10 @@ const bulkUpdateUsers = async (req, res) => { } // 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( - { _id: { $in: ids } }, + { _id: { $in: ids }, deleted: { $ne: true } }, { $set: updateFields } ); @@ -632,8 +629,10 @@ const bulkDeleteUsers = async (req, res) => { } // Soft delete all users + // Bereits geloeschte Eintraege bleiben unangetastet, damit + // deletedAt/deletedBy nicht ueberschrieben werden. const result = await User.updateMany( - { _id: { $in: ids } }, + { _id: { $in: ids }, deleted: { $ne: true } }, { $set: { deleted: true, diff --git a/nachsuche/backend/middleware/auditLogger.js b/nachsuche/backend/middleware/auditLogger.js index 64a18af..413cfb8 100644 --- a/nachsuche/backend/middleware/auditLogger.js +++ b/nachsuche/backend/middleware/auditLogger.js @@ -141,10 +141,12 @@ const auditLog = (action, resource) => { /** * 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 { await AuditLog.log({ - action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED', + action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'), resource: 'Admin', adminUsername: username, ipAddress: req.ip || req.connection?.remoteAddress, diff --git a/nachsuche/backend/middleware/auth.js b/nachsuche/backend/middleware/auth.js index b7e2ab1..99c848f 100644 --- a/nachsuche/backend/middleware/auth.js +++ b/nachsuche/backend/middleware/auth.js @@ -1,10 +1,11 @@ const jwt = require('jsonwebtoken'); const config = require('../config/env'); +// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt. const authenticateToken = (req, res, next) => { // Try to get token from cookie first (new secure method) let token = req.cookies?.token; - + // Fallback: Check Authorization header for backward compatibility if (!token) { const authHeader = req.headers['authorization']; @@ -12,29 +13,70 @@ const authenticateToken = (req, res, next) => { } if (!token) { - return res.status(401).json({ - success: false, - message: 'Zugriff verweigert. Kein Token bereitgestellt.' + return res.status(401).json({ + success: false, + message: 'Zugriff verweigert. Kein Token bereitgestellt.' }); } try { const decoded = jwt.verify(token, config.jwtSecret); + // Reject tokens issued by a different app (C-01 cross-app auth fix) if (decoded.app && decoded.app !== config.appName) { - return res.status(403).json({ - success: false, - message: 'Ungültiger oder abgelaufener Token.' + return res.status(403).json({ + success: false, + 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; next(); } catch (error) { - return res.status(403).json({ - success: false, - message: 'Ungültiger oder abgelaufener Token.' + return res.status(403).json({ + success: false, + 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 }; diff --git a/nachsuche/backend/routes/authRoutes.js b/nachsuche/backend/routes/authRoutes.js index cbf2e28..7fac6ac 100644 --- a/nachsuche/backend/routes/authRoutes.js +++ b/nachsuche/backend/routes/authRoutes.js @@ -2,9 +2,12 @@ const express = require('express'); const router = express.Router(); const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController'); const { validateLogin } = require('../middleware/validator'); +const { attachUserIfPresent } = require('../middleware/auth'); 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('/reset-password', resetPassword); diff --git a/nachsuche/backend/utils/csv.js b/nachsuche/backend/utils/csv.js new file mode 100644 index 0000000..bdf165d --- /dev/null +++ b/nachsuche/backend/utils/csv.js @@ -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 }; diff --git a/stoeberhunde/backend/controllers/authController.js b/stoeberhunde/backend/controllers/authController.js index da500de..a0154f3 100644 --- a/stoeberhunde/backend/controllers/authController.js +++ b/stoeberhunde/backend/controllers/authController.js @@ -40,7 +40,7 @@ const login = async (req, res) => { // Generate token 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, { expiresIn: config.jwtExpiresIn } ); @@ -81,7 +81,7 @@ const logout = async (req, res) => { try { // Log logout (get username from token if available) const username = req.user?.username || 'unknown'; - await auditAuth(req, true, username, null); + await auditAuth(req, true, username, null, 'LOGOUT'); // Clear the token cookie res.clearCookie('token', { @@ -119,7 +119,9 @@ const forgotPassword = async (req, res) => { } // 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) if (!admin) { diff --git a/stoeberhunde/backend/controllers/userController.js b/stoeberhunde/backend/controllers/userController.js index ee3189f..e661ca6 100644 --- a/stoeberhunde/backend/controllers/userController.js +++ b/stoeberhunde/backend/controllers/userController.js @@ -1,6 +1,7 @@ const User = require('../models/User'); const { geocodeAddress } = require('../utils/geocode'); const logger = require('../utils/logger'); +const { escapeCell } = require('../utils/csv'); const config = require('../config/env'); 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 }) .skip(skip) .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) ]); @@ -411,14 +414,6 @@ const exportUsers = async (req, res) => { .select('-__v -passwordHash -deleted -deletedAt -deletedBy'); 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 = [ ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ...users.map(user => [ @@ -590,8 +585,10 @@ const bulkUpdateUsers = async (req, res) => { } // 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( - { _id: { $in: ids } }, + { _id: { $in: ids }, deleted: { $ne: true } }, { $set: updateFields } ); @@ -629,8 +626,10 @@ const bulkDeleteUsers = async (req, res) => { } // Soft delete all users + // Bereits geloeschte Eintraege bleiben unangetastet, damit + // deletedAt/deletedBy nicht ueberschrieben werden. const result = await User.updateMany( - { _id: { $in: ids } }, + { _id: { $in: ids }, deleted: { $ne: true } }, { $set: { deleted: true, diff --git a/stoeberhunde/backend/middleware/auditLogger.js b/stoeberhunde/backend/middleware/auditLogger.js index 64a18af..413cfb8 100644 --- a/stoeberhunde/backend/middleware/auditLogger.js +++ b/stoeberhunde/backend/middleware/auditLogger.js @@ -141,10 +141,12 @@ const auditLog = (action, resource) => { /** * 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 { await AuditLog.log({ - action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED', + action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'), resource: 'Admin', adminUsername: username, ipAddress: req.ip || req.connection?.remoteAddress, diff --git a/stoeberhunde/backend/middleware/auth.js b/stoeberhunde/backend/middleware/auth.js index b7e2ab1..99c848f 100644 --- a/stoeberhunde/backend/middleware/auth.js +++ b/stoeberhunde/backend/middleware/auth.js @@ -1,10 +1,11 @@ const jwt = require('jsonwebtoken'); const config = require('../config/env'); +// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt. const authenticateToken = (req, res, next) => { // Try to get token from cookie first (new secure method) let token = req.cookies?.token; - + // Fallback: Check Authorization header for backward compatibility if (!token) { const authHeader = req.headers['authorization']; @@ -12,29 +13,70 @@ const authenticateToken = (req, res, next) => { } if (!token) { - return res.status(401).json({ - success: false, - message: 'Zugriff verweigert. Kein Token bereitgestellt.' + return res.status(401).json({ + success: false, + message: 'Zugriff verweigert. Kein Token bereitgestellt.' }); } try { const decoded = jwt.verify(token, config.jwtSecret); + // Reject tokens issued by a different app (C-01 cross-app auth fix) if (decoded.app && decoded.app !== config.appName) { - return res.status(403).json({ - success: false, - message: 'Ungültiger oder abgelaufener Token.' + return res.status(403).json({ + success: false, + 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; next(); } catch (error) { - return res.status(403).json({ - success: false, - message: 'Ungültiger oder abgelaufener Token.' + return res.status(403).json({ + success: false, + 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 }; diff --git a/stoeberhunde/backend/routes/authRoutes.js b/stoeberhunde/backend/routes/authRoutes.js index cbf2e28..7fac6ac 100644 --- a/stoeberhunde/backend/routes/authRoutes.js +++ b/stoeberhunde/backend/routes/authRoutes.js @@ -2,9 +2,12 @@ const express = require('express'); const router = express.Router(); const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController'); const { validateLogin } = require('../middleware/validator'); +const { attachUserIfPresent } = require('../middleware/auth'); 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('/reset-password', resetPassword); diff --git a/stoeberhunde/backend/utils/csv.js b/stoeberhunde/backend/utils/csv.js new file mode 100644 index 0000000..bdf165d --- /dev/null +++ b/stoeberhunde/backend/utils/csv.js @@ -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 };