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:
parent
d9fecb6914
commit
4b61db82fd
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
Loading…
Reference in New Issue