diff --git a/drohnenfuehrer/backend/controllers/authController.js b/drohnenfuehrer/backend/controllers/authController.js index d7e24ac..c0898ab 100644 --- a/drohnenfuehrer/backend/controllers/authController.js +++ b/drohnenfuehrer/backend/controllers/authController.js @@ -45,9 +45,13 @@ const login = async (req, res) => { ); // Set token in httpOnly cookie (XSS protection) + const secureCookie = config.nodeEnv === 'production' + ? (req.secure || req.headers['x-forwarded-proto'] === 'https') + : false; + res.cookie('token', token, { httpOnly: true, // Not accessible via JavaScript (XSS protection) - secure: false, // Allow over HTTP in development (localhost) + secure: secureCookie, sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost) path: '/', // Ensure cookie is sent for all app routes, including /drohnenfuehrer/api maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds @@ -81,7 +85,7 @@ const logout = async (req, res) => { // Clear the token cookie res.clearCookie('token', { httpOnly: true, - secure: false, + secure: config.nodeEnv === 'production', sameSite: 'lax', path: '/' }); @@ -141,7 +145,11 @@ const forgotPassword = async (req, res) => { // In production, send email here // For development, log the token - logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`); + if (config.nodeEnv !== 'production') { + logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`); + } else { + logger.info(`Password reset token generiert für Benutzer: ${username}`); + } res.json({ success: true, diff --git a/drohnenfuehrer/backend/controllers/drohnenfuehrerController.js b/drohnenfuehrer/backend/controllers/drohnenfuehrerController.js new file mode 100644 index 0000000..51d1af5 --- /dev/null +++ b/drohnenfuehrer/backend/controllers/drohnenfuehrerController.js @@ -0,0 +1,166 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const User = require('../models/User'); +const config = require('../config/env'); +const logger = require('../utils/logger'); + +// POST /api/drohnenfuehrer/login — email + password +const drohnenfuehrerLogin = async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ success: false, message: 'E-Mail und Passwort erforderlich' }); + } + + const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }); + if (!user || !user.passwordHash) { + return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten oder kein Passwort hinterlegt' }); + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' }); + } + + const token = jwt.sign( + { id: user._id.toString(), role: 'drohnenfuehrer' }, + config.jwtSecret, + { expiresIn: '12h' } + ); + + res.json({ + success: true, + token, + user: { + id: user._id, + name: user.name, + email: user.email, + available: user.available, + type: user.type, + phone: user.phone, + landline: user.landline, + address: user.address + } + }); + } catch (error) { + logger.error('Drohnenführer-Login Fehler:', error); + res.status(500).json({ success: false, message: 'Serverfehler beim Login' }); + } +}; + +// POST /api/drohnenfuehrer/set-password — set first password using a one-time invite token +// Admin must first generate an invite token; the Drohnenführer then uses it here. +const setDrohnenfuehrerPassword = async (req, res) => { + try { + const { email, inviteToken, newPassword } = req.body; + + if (!email || !inviteToken || !newPassword || newPassword.length < 8) { + return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' }); + } + + const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }) + .select('+inviteToken +inviteTokenExpiry'); + + // Always return the same error to prevent user enumeration + const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token'; + if (!user || !user.inviteToken || !user.inviteTokenExpiry) { + return res.status(401).json({ success: false, message: invalidMsg }); + } + + if (user.inviteTokenExpiry < new Date()) { + user.inviteToken = null; + user.inviteTokenExpiry = null; + await user.save(); + return res.status(401).json({ success: false, message: invalidMsg }); + } + + // Timing-safe comparison to prevent timing attacks + const incoming = Buffer.from(inviteToken.trim()); + const stored = Buffer.from(user.inviteToken); + if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) { + return res.status(401).json({ success: false, message: invalidMsg }); + } + + user.passwordHash = await bcrypt.hash(newPassword, 12); + user.inviteToken = null; + user.inviteTokenExpiry = null; + await user.save(); + + res.json({ success: true, message: 'Passwort erfolgreich gesetzt' }); + } catch (error) { + logger.error('Passwort setzen Fehler:', error); + res.status(500).json({ success: false, message: 'Serverfehler' }); + } +}; + +// PUT /api/drohnenfuehrer/me — Drohnenführer updates own availability + contact data +const updateDrohnenfuehrerSelf = async (req, res) => { + try { + const userId = req.drohnenfuehrerUser.id; + const { available, phone, landline, address } = req.body; + + const update = {}; + if (typeof available === 'boolean') update.available = available; + if (phone && phone.trim()) update.phone = phone.trim(); + if (landline !== undefined) update.landline = landline ? landline.trim() : null; + if (address && address.trim()) update.address = address.trim(); + + const user = await User.findByIdAndUpdate( + userId, + update, + { new: true, select: '-passwordHash' } + ); + + if (!user) { + return res.status(404).json({ success: false, message: 'Drohnenführer nicht gefunden' }); + } + + res.json({ success: true, data: user }); + } catch (error) { + logger.error('Drohnenführer-Update Fehler:', error); + res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' }); + } +}; + +// GET /api/drohnenfuehrer/me — get own profile +const getDrohnenfuehrerSelf = async (req, res) => { + try { + const user = await User.findById(req.drohnenfuehrerUser.id).select('-passwordHash'); + if (!user) return res.status(404).json({ success: false, message: 'Nicht gefunden' }); + res.json({ success: true, data: user }); + } catch (error) { + res.status(500).json({ success: false, message: 'Serverfehler' }); + } +}; + +// POST /api/drohnenfuehrer/:id/invite-token — Admin generates one-time invite token +const generateInviteToken = async (req, res) => { + try { + const user = await User.findOne({ _id: req.params.id, deleted: false }) + .select('+inviteToken +inviteTokenExpiry'); + if (!user) { + return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); + } + if (!user.email) { + return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' }); + } + + const token = crypto.randomBytes(32).toString('hex'); + user.inviteToken = token; + user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + await user.save(); + + res.json({ + success: true, + message: 'Einladungs-Token generiert (gültig 7 Tage)', + data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email } + }); + } catch (error) { + logger.error('Fehler beim Generieren des Einladungs-Tokens:', error); + res.status(500).json({ success: false, message: 'Serverfehler' }); + } +}; + +module.exports = { drohnenfuehrerLogin, setDrohnenfuehrerPassword, generateInviteToken, updateDrohnenfuehrerSelf, getDrohnenfuehrerSelf }; diff --git a/drohnenfuehrer/backend/controllers/handlerController.js b/drohnenfuehrer/backend/controllers/handlerController.js deleted file mode 100644 index e4b88b8..0000000 --- a/drohnenfuehrer/backend/controllers/handlerController.js +++ /dev/null @@ -1,117 +0,0 @@ -const jwt = require('jsonwebtoken'); -const bcrypt = require('bcryptjs'); -const User = require('../models/User'); -const config = require('../config/env'); -const logger = require('../utils/logger'); - -// POST /api/handler/login — email + password -const handlerLogin = async (req, res) => { - try { - const { email, password } = req.body; - - if (!email || !password) { - return res.status(400).json({ success: false, message: 'E-Mail und Passwort erforderlich' }); - } - - const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }); - if (!user || !user.passwordHash) { - return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten oder kein Passwort hinterlegt' }); - } - - const valid = await bcrypt.compare(password, user.passwordHash); - if (!valid) { - return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' }); - } - - const token = jwt.sign( - { id: user._id.toString(), role: 'handler' }, - config.jwtSecret, - { expiresIn: '12h' } - ); - - res.json({ - success: true, - token, - user: { - id: user._id, - name: user.name, - email: user.email, - available: user.available, - type: user.type, - phone: user.phone, - landline: user.landline, - address: user.address - } - }); - } catch (error) { - logger.error('Handler-Login Fehler:', error); - res.status(500).json({ success: false, message: 'Serverfehler beim Login' }); - } -}; - -// POST /api/handler/set-password — set first password (handler provides token + new password) -// Admin first sets email, then the handler can set their own password -const setHandlerPassword = async (req, res) => { - try { - const { email, newPassword } = req.body; - - if (!email || !newPassword || newPassword.length < 6) { - return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' }); - } - - const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }); - if (!user) { - return res.status(404).json({ success: false, message: 'Kein Drohnenführer mit dieser E-Mail gefunden' }); - } - - user.passwordHash = await bcrypt.hash(newPassword, 12); - await user.save(); - - res.json({ success: true, message: 'Passwort erfolgreich gesetzt' }); - } catch (error) { - logger.error('Passwort setzen Fehler:', error); - res.status(500).json({ success: false, message: 'Serverfehler' }); - } -}; - -// PUT /api/handler/me — handler updates own availability + contact data -const updateHandlerSelf = async (req, res) => { - try { - const userId = req.handlerUser.id; - const { available, phone, landline, address } = req.body; - - const update = {}; - if (typeof available === 'boolean') update.available = available; - if (phone && phone.trim()) update.phone = phone.trim(); - if (landline !== undefined) update.landline = landline ? landline.trim() : null; - if (address && address.trim()) update.address = address.trim(); - - const user = await User.findByIdAndUpdate( - userId, - update, - { new: true, select: '-passwordHash' } - ); - - if (!user) { - return res.status(404).json({ success: false, message: 'Drohnenführer nicht gefunden' }); - } - - res.json({ success: true, data: user }); - } catch (error) { - logger.error('Handler-Update Fehler:', error); - res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' }); - } -}; - -// GET /api/handler/me — get own profile -const getHandlerSelf = async (req, res) => { - try { - const user = await User.findById(req.handlerUser.id).select('-passwordHash'); - if (!user) return res.status(404).json({ success: false, message: 'Nicht gefunden' }); - res.json({ success: true, data: user }); - } catch (error) { - res.status(500).json({ success: false, message: 'Serverfehler' }); - } -}; - -module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf }; diff --git a/drohnenfuehrer/backend/controllers/userController.js b/drohnenfuehrer/backend/controllers/userController.js index 5db9505..c6305b4 100644 --- a/drohnenfuehrer/backend/controllers/userController.js +++ b/drohnenfuehrer/backend/controllers/userController.js @@ -473,7 +473,12 @@ const importUsers = async (req, res) => { }); } - const overwrite = req.query.overwrite === 'true'; + if (importList.length > 500) { + return res.status(400).json({ + success: false, + message: 'Zu viele Einträge. Maximal 500 Benutzer pro Import erlaubt.' + }); + } const results = { imported: 0, skipped: 0, errors: [] }; diff --git a/drohnenfuehrer/backend/middleware/handlerAuth.js b/drohnenfuehrer/backend/middleware/drohnenfuehrerAuth.js similarity index 75% rename from drohnenfuehrer/backend/middleware/handlerAuth.js rename to drohnenfuehrer/backend/middleware/drohnenfuehrerAuth.js index 350d118..9def758 100644 --- a/drohnenfuehrer/backend/middleware/handlerAuth.js +++ b/drohnenfuehrer/backend/middleware/drohnenfuehrerAuth.js @@ -1,7 +1,7 @@ const jwt = require('jsonwebtoken'); const config = require('../config/env'); -const authenticateHandler = (req, res, next) => { +const authenticateDrohnenfuehrer = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; @@ -11,14 +11,14 @@ const authenticateHandler = (req, res, next) => { try { const decoded = jwt.verify(token, config.jwtSecret); - if (decoded.role !== 'handler') { + if (decoded.role !== 'drohnenfuehrer') { return res.status(403).json({ success: false, message: 'Zugriff verweigert' }); } - req.handlerUser = decoded; + req.drohnenfuehrerUser = decoded; next(); } catch (error) { return res.status(403).json({ success: false, message: 'Ungültiger oder abgelaufener Token' }); } }; -module.exports = { authenticateHandler }; +module.exports = { authenticateDrohnenfuehrer }; diff --git a/drohnenfuehrer/backend/middleware/rateLimiter.js b/drohnenfuehrer/backend/middleware/rateLimiter.js index 9ae17cc..52901f5 100644 --- a/drohnenfuehrer/backend/middleware/rateLimiter.js +++ b/drohnenfuehrer/backend/middleware/rateLimiter.js @@ -40,7 +40,28 @@ const authLimiter = rateLimit({ } }); +// Strict rate limiter for invite / set-password endpoints +const inviteLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // Max 10 attempts per windowMs + skipSuccessfulRequests: true, + message: { + success: false, + message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.' + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + logger.warn(`Invite rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + success: false, + message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.' + }); + } +}); + module.exports = { apiLimiter, - authLimiter + authLimiter, + inviteLimiter }; diff --git a/drohnenfuehrer/backend/models/AuditLog.js b/drohnenfuehrer/backend/models/AuditLog.js index cc88c74..f58ce7a 100644 --- a/drohnenfuehrer/backend/models/AuditLog.js +++ b/drohnenfuehrer/backend/models/AuditLog.js @@ -15,7 +15,7 @@ const auditLogSchema = new mongoose.Schema({ resource: { type: String, required: true, - enum: ['User', 'Config', 'Admin', 'Handler'], + enum: ['User', 'Config', 'Admin', 'Drohnenfuehrer'], index: true }, resourceId: { diff --git a/drohnenfuehrer/backend/models/User.js b/drohnenfuehrer/backend/models/User.js index a6f8b79..fa0981e 100644 --- a/drohnenfuehrer/backend/models/User.js +++ b/drohnenfuehrer/backend/models/User.js @@ -70,7 +70,7 @@ const userSchema = new mongoose.Schema({ ref: 'Admin', default: null }, - // Handler invite token for initial password setup + // Drohnenführer invite token for initial password setup inviteToken: { type: String, default: null, diff --git a/drohnenfuehrer/backend/routes/drohnenfuehrerRoutes.js b/drohnenfuehrer/backend/routes/drohnenfuehrerRoutes.js new file mode 100644 index 0000000..1127909 --- /dev/null +++ b/drohnenfuehrer/backend/routes/drohnenfuehrerRoutes.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); +const { drohnenfuehrerLogin, setDrohnenfuehrerPassword, generateInviteToken, updateDrohnenfuehrerSelf, getDrohnenfuehrerSelf } = require('../controllers/drohnenfuehrerController'); +const { authenticateToken } = require('../middleware/auth'); +const { authenticateDrohnenfuehrer } = require('../middleware/drohnenfuehrerAuth'); +const { inviteLimiter } = require('../middleware/rateLimiter'); + +// Public +router.post('/login', drohnenfuehrerLogin); +router.post('/set-password', inviteLimiter, setDrohnenfuehrerPassword); + +// Admin only — generate a one-time invite token so a Drohnenführer can set their password +router.post('/:id/invite-token', authenticateToken, generateInviteToken); + +// Authenticated Drohnenführer only +router.get('/me', authenticateDrohnenfuehrer, getDrohnenfuehrerSelf); +router.put('/me', authenticateDrohnenfuehrer, updateDrohnenfuehrerSelf); + +module.exports = router; diff --git a/drohnenfuehrer/backend/routes/handlerRoutes.js b/drohnenfuehrer/backend/routes/handlerRoutes.js deleted file mode 100644 index 27b12b4..0000000 --- a/drohnenfuehrer/backend/routes/handlerRoutes.js +++ /dev/null @@ -1,18 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController'); -const { authenticateToken } = require('../middleware/auth'); -const { authenticateHandler } = require('../middleware/handlerAuth'); - -// Public -router.post('/login', handlerLogin); -router.post('/set-password', setHandlerPassword); - -// Admin only — generate a one-time invite token so a handler can set their password -router.post('/:id/invite-token', authenticateToken, generateInviteToken); - -// Authenticated handler only -router.get('/me', authenticateHandler, getHandlerSelf); -router.put('/me', authenticateHandler, updateHandlerSelf); - -module.exports = router; diff --git a/drohnenfuehrer/backend/seed.js b/drohnenfuehrer/backend/seed.js index 324282d..7bab5d7 100644 --- a/drohnenfuehrer/backend/seed.js +++ b/drohnenfuehrer/backend/seed.js @@ -24,7 +24,7 @@ const seedDatabase = async () => { // Seed admins (only if not exists) const adminAccounts = [ - { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' }, + { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || null }, { username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null } ]; diff --git a/drohnenfuehrer/backend/server.js b/drohnenfuehrer/backend/server.js index a98c08a..b296ecf 100644 --- a/drohnenfuehrer/backend/server.js +++ b/drohnenfuehrer/backend/server.js @@ -68,7 +68,7 @@ app.use('/api/auth', authLimiter, require('./routes/authRoutes')); // Strict lim app.use('/api', require('./routes/userRoutes')); app.use('/api', require('./routes/auditRoutes')); app.use('/api/config', require('./routes/configRoutes')); -app.use('/api/handler', require('./routes/handlerRoutes')); +app.use('/api/drohnenfuehrer', require('./routes/drohnenfuehrerRoutes')); // Health check with basic system info app.get('/health', async (req, res) => { diff --git a/drohnenfuehrer/docker-compose.yml b/drohnenfuehrer/docker-compose.yml index 4bb2958..99f16e0 100644 --- a/drohnenfuehrer/docker-compose.yml +++ b/drohnenfuehrer/docker-compose.yml @@ -20,9 +20,13 @@ services: build: ./backend container_name: drohnenfuehrer-backend restart: unless-stopped - # Port 5011 only bound to localhost + # Port 5011 bound to localhost only – NOT accessible from external IPs. + # Portal nginx reaches the backend via the shared jagd-network (container name). ports: - - "0.0.0.0:5011:5000" + - "127.0.0.1:5011:5000" + networks: + - default + - jagd-network environment: - NODE_ENV=production - MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin @@ -52,7 +56,10 @@ services: container_name: drohnenfuehrer-frontend restart: unless-stopped ports: - - "8081:80" + - "127.0.0.1:8081:80" + networks: + - default + - jagd-network depends_on: - backend healthcheck: @@ -64,3 +71,9 @@ services: volumes: mongo-data: name: drohnenfuehrer-mongo-data + +networks: + default: {} + jagd-network: + external: true + name: jagd-network diff --git a/drohnenfuehrer/frontend/src/App.js b/drohnenfuehrer/frontend/src/App.js index 7f8619c..a9961ae 100644 --- a/drohnenfuehrer/frontend/src/App.js +++ b/drohnenfuehrer/frontend/src/App.js @@ -8,8 +8,8 @@ import PasswordReset from './components/auth/PasswordReset'; import Home from './pages/Home'; import Rules from './pages/Rules'; import Admin from './pages/Admin'; -import HandlerLogin from './components/handler/HandlerLogin'; -import HandlerDashboard from './components/handler/HandlerDashboard'; +import DrohnenfuehrerLogin from './components/drohnenfuehrer/DrohnenfuehrerLogin'; +import DrohnenfuehrerDashboard from './components/drohnenfuehrer/DrohnenfuehrerDashboard'; import InstallBanner from './components/common/InstallBanner'; import './App.css'; @@ -19,7 +19,7 @@ function App() { const isAdminRoute = window.location.pathname === adminPath; const isResetPasswordRoute = window.location.pathname === resetPasswordPath; const [view, setView] = useState('public'); - const [handlerUser, setHandlerUser] = useState(null); + const [drohnenfuehrerUser, setDrohnenfuehrerUser] = useState(null); const { isAuthenticated, loading: authLoading, login, logout } = useAuth(); const { users, loading: usersLoading, error, refetch } = useUsers(isAuthenticated); @@ -37,13 +37,13 @@ function App() { setView('public'); }; - const handleHandlerLogin = (userData) => { - setHandlerUser(userData); - setView('handler-dashboard'); + const handleDrohnenfuehrerLogin = (userData) => { + setDrohnenfuehrerUser(userData); + setView('drohnenfuehrer-dashboard'); }; - const handleHandlerLogout = () => { - setHandlerUser(null); + const handleDrohnenfuehrerLogout = () => { + setDrohnenfuehrerUser(null); setView('public'); }; @@ -97,15 +97,15 @@ function App() { isAdmin={false} currentView={view} onViewChange={handleViewChange} - onHandlerLogin={handleHandlerLogout} + onDrohnenfuehrerLogin={handleDrohnenfuehrerLogout} />
{isAdminRoute || view === 'login' ? ( - ) : view === 'handler-login' ? ( - - ) : view === 'handler-dashboard' && handlerUser ? ( - + ) : view === 'drohnenfuehrer-login' ? ( + + ) : view === 'drohnenfuehrer-dashboard' && drohnenfuehrerUser ? ( + ) : ( <> {view === 'rules' && } diff --git a/drohnenfuehrer/frontend/src/components/admin/AuditLogs.js b/drohnenfuehrer/frontend/src/components/admin/AuditLogs.js index dc85948..aacd4ec 100644 --- a/drohnenfuehrer/frontend/src/components/admin/AuditLogs.js +++ b/drohnenfuehrer/frontend/src/components/admin/AuditLogs.js @@ -348,7 +348,7 @@ function AuditLogs() { - + { setFormData({ ...formData, landline: e.target.value })} /> -
- -
@@ -145,4 +145,4 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => { ); }; -export default HandlerDashboard; +export default DrohnenfuehrerDashboard; diff --git a/drohnenfuehrer/frontend/src/components/handler/HandlerLogin.css b/drohnenfuehrer/frontend/src/components/drohnenfuehrer/DrohnenfuehrerLogin.css similarity index 75% rename from drohnenfuehrer/frontend/src/components/handler/HandlerLogin.css rename to drohnenfuehrer/frontend/src/components/drohnenfuehrer/DrohnenfuehrerLogin.css index 6715730..abca037 100644 --- a/drohnenfuehrer/frontend/src/components/handler/HandlerLogin.css +++ b/drohnenfuehrer/frontend/src/components/drohnenfuehrer/DrohnenfuehrerLogin.css @@ -1,11 +1,11 @@ -.handler-login-container { +.drohnenfuehrer-login-container { display: flex; justify-content: center; align-items: flex-start; padding: 2rem 1rem; } -.handler-login-card { +.drohnenfuehrer-login-card { background: white; border-radius: 10px; padding: 2rem; @@ -15,18 +15,18 @@ border-top: 4px solid var(--color-primary); } -.handler-login-card h2 { +.drohnenfuehrer-login-card h2 { margin: 0 0 0.5rem; color: var(--color-primary); } -.handler-login-subtitle { +.drohnenfuehrer-login-subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-bottom: 1.5rem; } -.handler-mode-toggle { +.drohnenfuehrer-mode-toggle { display: flex; gap: 0; margin-bottom: 1.5rem; @@ -35,7 +35,7 @@ overflow: hidden; } -.handler-mode-toggle button { +.drohnenfuehrer-mode-toggle button { flex: 1; padding: 0.5rem; border: none; @@ -46,24 +46,24 @@ transition: background 0.2s; } -.handler-mode-toggle button.active { +.drohnenfuehrer-mode-toggle button.active { background: var(--color-primary); color: white; font-weight: 600; } -.handler-form .form-group { +.drohnenfuehrer-form .form-group { margin-bottom: 1rem; } -.handler-form .form-group label { +.drohnenfuehrer-form .form-group label { display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.9rem; } -.handler-form .form-group input { +.drohnenfuehrer-form .form-group input { width: 100%; padding: 0.6rem 0.75rem; border: 1px solid var(--color-border-strong); @@ -72,13 +72,13 @@ box-sizing: border-box; } -.handler-form .form-group input:focus { +.drohnenfuehrer-form .form-group input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px var(--color-focus); } -.btn-handler-primary { +.btn-drohnenfuehrer-primary { width: 100%; padding: 0.75rem; background: var(--color-primary); @@ -92,16 +92,16 @@ transition: background 0.2s; } -.btn-handler-primary:hover:not(:disabled) { +.btn-drohnenfuehrer-primary:hover:not(:disabled) { background: var(--color-primary-dark); } -.btn-handler-primary:disabled { +.btn-drohnenfuehrer-primary:disabled { opacity: 0.6; cursor: not-allowed; } -.handler-error { +.drohnenfuehrer-error { background: #fee2e2; color: #b91c1c; border-radius: 4px; @@ -110,7 +110,7 @@ font-size: 0.9rem; } -.handler-success { +.drohnenfuehrer-success { background: #dcfce7; color: #166534; border-radius: 4px; @@ -119,7 +119,7 @@ font-size: 0.9rem; } -.handler-hint { +.drohnenfuehrer-hint { font-size: 0.88rem; color: var(--color-text-muted); margin-bottom: 1rem; diff --git a/drohnenfuehrer/frontend/src/components/handler/HandlerLogin.js b/drohnenfuehrer/frontend/src/components/drohnenfuehrer/DrohnenfuehrerLogin.js similarity index 75% rename from drohnenfuehrer/frontend/src/components/handler/HandlerLogin.js rename to drohnenfuehrer/frontend/src/components/drohnenfuehrer/DrohnenfuehrerLogin.js index 5d4ffc6..ddc068a 100644 --- a/drohnenfuehrer/frontend/src/components/handler/HandlerLogin.js +++ b/drohnenfuehrer/frontend/src/components/drohnenfuehrer/DrohnenfuehrerLogin.js @@ -1,8 +1,8 @@ import React, { useState } from 'react'; -import { handlerLogin, setHandlerPassword } from '../../services/handler'; -import './HandlerLogin.css'; +import { drohnenfuehrerLogin, setDrohnenfuehrerPassword } from '../../services/drohnenfuehrer'; +import './DrohnenfuehrerLogin.css'; -const HandlerLogin = ({ onLogin }) => { +const DrohnenfuehrerLogin = ({ onLogin }) => { const [mode, setMode] = useState('login'); // 'login' | 'set-password' const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -17,9 +17,9 @@ const HandlerLogin = ({ onLogin }) => { setError(''); setLoading(true); try { - const result = await handlerLogin(email, password); + const result = await drohnenfuehrerLogin(email, password); if (result.success) { - localStorage.setItem('handlerToken', result.token); + localStorage.setItem('drohnenfuehrerToken', result.token); onLogin(result.user); } } catch (err) { @@ -42,7 +42,7 @@ const HandlerLogin = ({ onLogin }) => { } setLoading(true); try { - const result = await setHandlerPassword(email, newPassword); + const result = await setDrohnenfuehrerPassword(email, newPassword); if (result.success) { setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.'); setMode('login'); @@ -57,14 +57,14 @@ const HandlerLogin = ({ onLogin }) => { }; return ( -
-
+
+

Drohnenführer-Login

-

+

Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten.

-
+
- {error &&
{error}
} - {success &&
{success}
} + {error &&
{error}
} + {success &&
{success}
} {mode === 'login' ? ( -
+
setEmail(e.target.value)} required autoFocus /> @@ -92,13 +92,13 @@ const HandlerLogin = ({ onLogin }) => { setPassword(e.target.value)} required />
-
) : ( -
-

+ +

Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein.

@@ -113,7 +113,7 @@ const HandlerLogin = ({ onLogin }) => { setNewPassword2(e.target.value)} required />
-
@@ -123,4 +123,4 @@ const HandlerLogin = ({ onLogin }) => { ); }; -export default HandlerLogin; +export default DrohnenfuehrerLogin; diff --git a/drohnenfuehrer/frontend/src/services/drohnenfuehrer.js b/drohnenfuehrer/frontend/src/services/drohnenfuehrer.js new file mode 100644 index 0000000..8e73eb0 --- /dev/null +++ b/drohnenfuehrer/frontend/src/services/drohnenfuehrer.js @@ -0,0 +1,34 @@ +import axios from 'axios'; +import { API_BASE_URL } from '../utils/constants'; + +const drohnenfuehrerApi = axios.create({ + baseURL: `${API_BASE_URL}/api/drohnenfuehrer`, + timeout: 10000, + headers: { 'Content-Type': 'application/json' } +}); + +drohnenfuehrerApi.interceptors.request.use((config) => { + const token = localStorage.getItem('drohnenfuehrerToken'); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +export const drohnenfuehrerLogin = async (email, password) => { + const response = await drohnenfuehrerApi.post('/login', { email, password }); + return response.data; +}; + +export const setDrohnenfuehrerPassword = async (email, newPassword) => { + const response = await drohnenfuehrerApi.post('/set-password', { email, newPassword }); + return response.data; +}; + +export const getDrohnenfuehrerMe = async () => { + const response = await drohnenfuehrerApi.get('/me'); + return response.data; +}; + +export const updateDrohnenfuehrerMe = async (data) => { + const response = await drohnenfuehrerApi.put('/me', data); + return response.data; +}; diff --git a/drohnenfuehrer/frontend/src/services/handler.js b/drohnenfuehrer/frontend/src/services/handler.js deleted file mode 100644 index 98a00c5..0000000 --- a/drohnenfuehrer/frontend/src/services/handler.js +++ /dev/null @@ -1,34 +0,0 @@ -import axios from 'axios'; -import { API_BASE_URL } from '../utils/constants'; - -const handlerApi = axios.create({ - baseURL: `${API_BASE_URL}/api/handler`, - timeout: 10000, - headers: { 'Content-Type': 'application/json' } -}); - -handlerApi.interceptors.request.use((config) => { - const token = localStorage.getItem('handlerToken'); - if (token) config.headers.Authorization = `Bearer ${token}`; - return config; -}); - -export const handlerLogin = async (email, password) => { - const response = await handlerApi.post('/login', { email, password }); - return response.data; -}; - -export const setHandlerPassword = async (email, newPassword) => { - const response = await handlerApi.post('/set-password', { email, newPassword }); - return response.data; -}; - -export const getHandlerMe = async () => { - const response = await handlerApi.get('/me'); - return response.data; -}; - -export const updateHandlerMe = async (data) => { - const response = await handlerApi.put('/me', data); - return response.data; -}; diff --git a/nachsuche/backend/controllers/userController.js b/nachsuche/backend/controllers/userController.js index 6583d13..4c931f7 100644 --- a/nachsuche/backend/controllers/userController.js +++ b/nachsuche/backend/controllers/userController.js @@ -473,7 +473,12 @@ const importUsers = async (req, res) => { }); } - const overwrite = req.query.overwrite === 'true'; + if (importList.length > 500) { + return res.status(400).json({ + success: false, + message: 'Zu viele Einträge. Maximal 500 Benutzer pro Import erlaubt.' + }); + } const results = { imported: 0, skipped: 0, errors: [] }; diff --git a/nachsuche/backend/middleware/rateLimiter.js b/nachsuche/backend/middleware/rateLimiter.js index 9ae17cc..c727b48 100644 --- a/nachsuche/backend/middleware/rateLimiter.js +++ b/nachsuche/backend/middleware/rateLimiter.js @@ -40,7 +40,28 @@ const authLimiter = rateLimit({ } }); +// Strict rate limiter for invite / set-password endpoints +const inviteLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + skipSuccessfulRequests: true, + message: { + success: false, + message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.' + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + logger.warn(`Invite rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + success: false, + message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.' + }); + } +}); + module.exports = { apiLimiter, - authLimiter + authLimiter, + inviteLimiter }; diff --git a/nachsuche/backend/routes/handlerRoutes.js b/nachsuche/backend/routes/handlerRoutes.js index 27b12b4..6fcb817 100644 --- a/nachsuche/backend/routes/handlerRoutes.js +++ b/nachsuche/backend/routes/handlerRoutes.js @@ -3,10 +3,11 @@ const router = express.Router(); const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController'); const { authenticateToken } = require('../middleware/auth'); const { authenticateHandler } = require('../middleware/handlerAuth'); +const { inviteLimiter } = require('../middleware/rateLimiter'); // Public router.post('/login', handlerLogin); -router.post('/set-password', setHandlerPassword); +router.post('/set-password', inviteLimiter, setHandlerPassword); // Admin only — generate a one-time invite token so a handler can set their password router.post('/:id/invite-token', authenticateToken, generateInviteToken); diff --git a/nachsuche/backend/seed.js b/nachsuche/backend/seed.js index 0132597..406ddd5 100644 --- a/nachsuche/backend/seed.js +++ b/nachsuche/backend/seed.js @@ -27,7 +27,7 @@ const seedDatabase = async () => { // Seed admins (only if not exists) const adminAccounts = [ - { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' }, + { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || null }, { username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null } ]; diff --git a/nachsuche/docker-compose.yml b/nachsuche/docker-compose.yml index fea5bee..738b3c5 100644 --- a/nachsuche/docker-compose.yml +++ b/nachsuche/docker-compose.yml @@ -22,9 +22,13 @@ services: build: ./backend container_name: nachsuche-backend restart: unless-stopped - # Port 5010 only bound to localhost - accessible by host nginx, NOT from external IPs + # Port 5010 bound to localhost only – NOT accessible from external IPs. + # Portal nginx reaches the backend via the shared jagd-network (container name). ports: - - "0.0.0.0:5010:5000" + - "127.0.0.1:5010:5000" + networks: + - default + - jagd-network environment: - NODE_ENV=production - MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin @@ -54,7 +58,10 @@ services: container_name: nachsuche-frontend restart: unless-stopped ports: - - "8080:80" + - "127.0.0.1:8080:80" + networks: + - default + - jagd-network depends_on: - backend healthcheck: @@ -66,3 +73,9 @@ services: volumes: mongo-data: name: nachsuche-mongo-data + +networks: + default: {} + jagd-network: + external: true + name: jagd-network diff --git a/portal/docker-compose.yml b/portal/docker-compose.yml index 32a7176..aafbe41 100644 --- a/portal/docker-compose.yml +++ b/portal/docker-compose.yml @@ -5,5 +5,12 @@ services: restart: unless-stopped ports: - "8090:80" - extra_hosts: - - "host.docker.internal:host-gateway" + networks: + - default + - jagd-network + +networks: + default: {} + jagd-network: + external: true + name: jagd-network diff --git a/portal/extra8002.conf b/portal/extra8002.conf index cf6e321..b728eab 100644 --- a/portal/extra8002.conf +++ b/portal/extra8002.conf @@ -25,7 +25,7 @@ server { # Nachsuche (/nachsuche/) # ────────────────────────────────────────────── location /nachsuche/api/ { - proxy_pass http://host.docker.internal:5010/api/; + proxy_pass http://nachsuche-backend:5000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -36,7 +36,7 @@ server { } location /nachsuche/ { - proxy_pass http://host.docker.internal:8080/; + proxy_pass http://nachsuche-frontend:80/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -50,7 +50,7 @@ server { # Drohnenführer (/drohnenfuehrer/) # ────────────────────────────────────────────── location /drohnenfuehrer/api/ { - proxy_pass http://host.docker.internal:5011/api/; + proxy_pass http://drohnenfuehrer-backend:5000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -61,7 +61,7 @@ server { } location /drohnenfuehrer/ { - proxy_pass http://host.docker.internal:8081/; + proxy_pass http://drohnenfuehrer-frontend:80/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -75,7 +75,7 @@ server { # Stöberhunde (/stoeberhunde/) # ────────────────────────────────────────────── location /stoeberhunde/api/ { - proxy_pass http://host.docker.internal:5012/api/; + proxy_pass http://stoeberhunde-backend:5000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -86,7 +86,7 @@ server { } location /stoeberhunde/ { - proxy_pass http://host.docker.internal:8082/; + proxy_pass http://stoeberhunde-frontend:80/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/stoeberhunde/backend/controllers/authController.js b/stoeberhunde/backend/controllers/authController.js index 640b942..d13a4d1 100644 --- a/stoeberhunde/backend/controllers/authController.js +++ b/stoeberhunde/backend/controllers/authController.js @@ -81,7 +81,7 @@ const logout = async (req, res) => { // Clear the token cookie res.clearCookie('token', { httpOnly: true, - secure: false, + secure: config.nodeEnv === 'production', sameSite: 'lax', path: '/' }); @@ -141,7 +141,11 @@ const forgotPassword = async (req, res) => { // In production, send email here // For development, log the token - logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`); + if (config.nodeEnv !== 'production') { + logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`); + } else { + logger.info(`Password reset token generiert für Benutzer: ${username}`); + } res.json({ success: true, diff --git a/stoeberhunde/backend/controllers/handlerController.js b/stoeberhunde/backend/controllers/handlerController.js deleted file mode 100644 index 18962d3..0000000 --- a/stoeberhunde/backend/controllers/handlerController.js +++ /dev/null @@ -1,117 +0,0 @@ -const jwt = require('jsonwebtoken'); -const bcrypt = require('bcryptjs'); -const User = require('../models/User'); -const config = require('../config/env'); -const logger = require('../utils/logger'); - -// POST /api/handler/login — email + password -const handlerLogin = async (req, res) => { - try { - const { email, password } = req.body; - - if (!email || !password) { - return res.status(400).json({ success: false, message: 'E-Mail und Passwort erforderlich' }); - } - - const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }); - if (!user || !user.passwordHash) { - return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten oder kein Passwort hinterlegt' }); - } - - const valid = await bcrypt.compare(password, user.passwordHash); - if (!valid) { - return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' }); - } - - const token = jwt.sign( - { id: user._id.toString(), role: 'handler' }, - config.jwtSecret, - { expiresIn: '12h' } - ); - - res.json({ - success: true, - token, - user: { - id: user._id, - name: user.name, - email: user.email, - available: user.available, - type: user.type, - phone: user.phone, - landline: user.landline, - address: user.address - } - }); - } catch (error) { - logger.error('Handler-Login Fehler:', error); - res.status(500).json({ success: false, message: 'Serverfehler beim Login' }); - } -}; - -// POST /api/handler/set-password — set first password (handler provides token + new password) -// Admin first sets email, then the handler can set their own password -const setHandlerPassword = async (req, res) => { - try { - const { email, newPassword } = req.body; - - if (!email || !newPassword || newPassword.length < 6) { - return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' }); - } - - const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }); - if (!user) { - return res.status(404).json({ success: false, message: 'Kein Stöberhundeführer mit dieser E-Mail gefunden' }); - } - - user.passwordHash = await bcrypt.hash(newPassword, 12); - await user.save(); - - res.json({ success: true, message: 'Passwort erfolgreich gesetzt' }); - } catch (error) { - logger.error('Passwort setzen Fehler:', error); - res.status(500).json({ success: false, message: 'Serverfehler' }); - } -}; - -// PUT /api/handler/me — handler updates own availability + contact data -const updateHandlerSelf = async (req, res) => { - try { - const userId = req.handlerUser.id; - const { available, phone, landline, address } = req.body; - - const update = {}; - if (typeof available === 'boolean') update.available = available; - if (phone && phone.trim()) update.phone = phone.trim(); - if (landline !== undefined) update.landline = landline ? landline.trim() : null; - if (address && address.trim()) update.address = address.trim(); - - const user = await User.findByIdAndUpdate( - userId, - update, - { new: true, select: '-passwordHash' } - ); - - if (!user) { - return res.status(404).json({ success: false, message: 'Stöberhundeführer nicht gefunden' }); - } - - res.json({ success: true, data: user }); - } catch (error) { - logger.error('Handler-Update Fehler:', error); - res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' }); - } -}; - -// GET /api/handler/me — get own profile -const getHandlerSelf = async (req, res) => { - try { - const user = await User.findById(req.handlerUser.id).select('-passwordHash'); - if (!user) return res.status(404).json({ success: false, message: 'Nicht gefunden' }); - res.json({ success: true, data: user }); - } catch (error) { - res.status(500).json({ success: false, message: 'Serverfehler' }); - } -}; - -module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf }; diff --git a/stoeberhunde/backend/controllers/stoeberhundefuehrerController.js b/stoeberhunde/backend/controllers/stoeberhundefuehrerController.js new file mode 100644 index 0000000..f7a62ff --- /dev/null +++ b/stoeberhunde/backend/controllers/stoeberhundefuehrerController.js @@ -0,0 +1,166 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const User = require('../models/User'); +const config = require('../config/env'); +const logger = require('../utils/logger'); + +// POST /api/stoeberhundefuehrer/login — email + password +const stoeberhundefuehrerLogin = async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ success: false, message: 'E-Mail und Passwort erforderlich' }); + } + + const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }); + if (!user || !user.passwordHash) { + return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten oder kein Passwort hinterlegt' }); + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' }); + } + + const token = jwt.sign( + { id: user._id.toString(), role: 'stoeberhundefuehrer' }, + config.jwtSecret, + { expiresIn: '12h' } + ); + + res.json({ + success: true, + token, + user: { + id: user._id, + name: user.name, + email: user.email, + available: user.available, + type: user.type, + phone: user.phone, + landline: user.landline, + address: user.address + } + }); + } catch (error) { + logger.error('Stöberhundeführer-Login Fehler:', error); + res.status(500).json({ success: false, message: 'Serverfehler beim Login' }); + } +}; + +// POST /api/stoeberhundefuehrer/set-password — set first password +// Admin first sets email, then the Stöberhundeführer can set their own password +const setStoeberhundefuehrerPassword = async (req, res) => { + try { + const { email, inviteToken, newPassword } = req.body; + + if (!email || !inviteToken || !newPassword || newPassword.length < 8) { + return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' }); + } + + const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }) + .select('+inviteToken +inviteTokenExpiry'); + + // Always return the same error to prevent user enumeration + const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token'; + if (!user || !user.inviteToken || !user.inviteTokenExpiry) { + return res.status(401).json({ success: false, message: invalidMsg }); + } + + if (user.inviteTokenExpiry < new Date()) { + user.inviteToken = null; + user.inviteTokenExpiry = null; + await user.save(); + return res.status(401).json({ success: false, message: invalidMsg }); + } + + // Timing-safe comparison to prevent timing attacks + const incoming = Buffer.from(inviteToken.trim()); + const stored = Buffer.from(user.inviteToken); + if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) { + return res.status(401).json({ success: false, message: invalidMsg }); + } + + user.passwordHash = await bcrypt.hash(newPassword, 12); + user.inviteToken = null; + user.inviteTokenExpiry = null; + await user.save(); + + res.json({ success: true, message: 'Passwort erfolgreich gesetzt' }); + } catch (error) { + logger.error('Passwort setzen Fehler:', error); + res.status(500).json({ success: false, message: 'Serverfehler' }); + } +}; + +// PUT /api/stoeberhundefuehrer/me — Stöberhundeführer updates own availability + contact data +const updateStoeberhundefuehrerSelf = async (req, res) => { + try { + const userId = req.stoeberhundefuehrerUser.id; + const { available, phone, landline, address } = req.body; + + const update = {}; + if (typeof available === 'boolean') update.available = available; + if (phone && phone.trim()) update.phone = phone.trim(); + if (landline !== undefined) update.landline = landline ? landline.trim() : null; + if (address && address.trim()) update.address = address.trim(); + + const user = await User.findByIdAndUpdate( + userId, + update, + { new: true, select: '-passwordHash' } + ); + + if (!user) { + return res.status(404).json({ success: false, message: 'Stöberhundeführer nicht gefunden' }); + } + + res.json({ success: true, data: user }); + } catch (error) { + logger.error('Stöberhundeführer-Update Fehler:', error); + res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' }); + } +}; + +// GET /api/stoeberhundefuehrer/me — get own profile +const getStoeberhundefuehrerSelf = async (req, res) => { + try { + const user = await User.findById(req.stoeberhundefuehrerUser.id).select('-passwordHash'); + if (!user) return res.status(404).json({ success: false, message: 'Nicht gefunden' }); + res.json({ success: true, data: user }); + } catch (error) { + res.status(500).json({ success: false, message: 'Serverfehler' }); + } +}; + +// POST /api/stoeberhundefuehrer/:id/invite-token — Admin generates one-time invite token +const generateInviteToken = async (req, res) => { + try { + const user = await User.findOne({ _id: req.params.id, deleted: false }) + .select('+inviteToken +inviteTokenExpiry'); + if (!user) { + return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); + } + if (!user.email) { + return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' }); + } + + const token = crypto.randomBytes(32).toString('hex'); + user.inviteToken = token; + user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + await user.save(); + + res.json({ + success: true, + message: 'Einladungs-Token generiert (gültig 7 Tage)', + data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email } + }); + } catch (error) { + logger.error('Fehler beim Generieren des Einladungs-Tokens:', error); + res.status(500).json({ success: false, message: 'Serverfehler' }); + } +}; + +module.exports = { stoeberhundefuehrerLogin, setStoeberhundefuehrerPassword, generateInviteToken, updateStoeberhundefuehrerSelf, getStoeberhundefuehrerSelf }; diff --git a/stoeberhunde/backend/controllers/userController.js b/stoeberhunde/backend/controllers/userController.js index cc988b7..d286bc2 100644 --- a/stoeberhunde/backend/controllers/userController.js +++ b/stoeberhunde/backend/controllers/userController.js @@ -473,7 +473,12 @@ const importUsers = async (req, res) => { }); } - const overwrite = req.query.overwrite === 'true'; + if (importList.length > 500) { + return res.status(400).json({ + success: false, + message: 'Zu viele Einträge. Maximal 500 Benutzer pro Import erlaubt.' + }); + } const results = { imported: 0, skipped: 0, errors: [] }; diff --git a/stoeberhunde/backend/middleware/rateLimiter.js b/stoeberhunde/backend/middleware/rateLimiter.js index 9ae17cc..c727b48 100644 --- a/stoeberhunde/backend/middleware/rateLimiter.js +++ b/stoeberhunde/backend/middleware/rateLimiter.js @@ -40,7 +40,28 @@ const authLimiter = rateLimit({ } }); +// Strict rate limiter for invite / set-password endpoints +const inviteLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + skipSuccessfulRequests: true, + message: { + success: false, + message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.' + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + logger.warn(`Invite rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + success: false, + message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.' + }); + } +}); + module.exports = { apiLimiter, - authLimiter + authLimiter, + inviteLimiter }; diff --git a/stoeberhunde/backend/middleware/handlerAuth.js b/stoeberhunde/backend/middleware/stoeberhundefuehrerAuth.js similarity index 74% rename from stoeberhunde/backend/middleware/handlerAuth.js rename to stoeberhunde/backend/middleware/stoeberhundefuehrerAuth.js index 350d118..8efb5cb 100644 --- a/stoeberhunde/backend/middleware/handlerAuth.js +++ b/stoeberhunde/backend/middleware/stoeberhundefuehrerAuth.js @@ -1,7 +1,7 @@ const jwt = require('jsonwebtoken'); const config = require('../config/env'); -const authenticateHandler = (req, res, next) => { +const authenticateStoeberhundefuehrer = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; @@ -11,14 +11,14 @@ const authenticateHandler = (req, res, next) => { try { const decoded = jwt.verify(token, config.jwtSecret); - if (decoded.role !== 'handler') { + if (decoded.role !== 'stoeberhundefuehrer') { return res.status(403).json({ success: false, message: 'Zugriff verweigert' }); } - req.handlerUser = decoded; + req.stoeberhundefuehrerUser = decoded; next(); } catch (error) { return res.status(403).json({ success: false, message: 'Ungültiger oder abgelaufener Token' }); } }; -module.exports = { authenticateHandler }; +module.exports = { authenticateStoeberhundefuehrer }; diff --git a/stoeberhunde/backend/models/AuditLog.js b/stoeberhunde/backend/models/AuditLog.js index cc88c74..380dc83 100644 --- a/stoeberhunde/backend/models/AuditLog.js +++ b/stoeberhunde/backend/models/AuditLog.js @@ -15,7 +15,7 @@ const auditLogSchema = new mongoose.Schema({ resource: { type: String, required: true, - enum: ['User', 'Config', 'Admin', 'Handler'], + enum: ['User', 'Config', 'Admin', 'Stoeberhundefuehrer'], index: true }, resourceId: { diff --git a/stoeberhunde/backend/models/User.js b/stoeberhunde/backend/models/User.js index a6f8b79..664c5d6 100644 --- a/stoeberhunde/backend/models/User.js +++ b/stoeberhunde/backend/models/User.js @@ -70,7 +70,7 @@ const userSchema = new mongoose.Schema({ ref: 'Admin', default: null }, - // Handler invite token for initial password setup + // Stöberhundeführer invite token for initial password setup inviteToken: { type: String, default: null, diff --git a/stoeberhunde/backend/routes/handlerRoutes.js b/stoeberhunde/backend/routes/handlerRoutes.js deleted file mode 100644 index 27b12b4..0000000 --- a/stoeberhunde/backend/routes/handlerRoutes.js +++ /dev/null @@ -1,18 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController'); -const { authenticateToken } = require('../middleware/auth'); -const { authenticateHandler } = require('../middleware/handlerAuth'); - -// Public -router.post('/login', handlerLogin); -router.post('/set-password', setHandlerPassword); - -// Admin only — generate a one-time invite token so a handler can set their password -router.post('/:id/invite-token', authenticateToken, generateInviteToken); - -// Authenticated handler only -router.get('/me', authenticateHandler, getHandlerSelf); -router.put('/me', authenticateHandler, updateHandlerSelf); - -module.exports = router; diff --git a/stoeberhunde/backend/routes/stoeberhundefuehrerRoutes.js b/stoeberhunde/backend/routes/stoeberhundefuehrerRoutes.js new file mode 100644 index 0000000..8db2d29 --- /dev/null +++ b/stoeberhunde/backend/routes/stoeberhundefuehrerRoutes.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); +const { stoeberhundefuehrerLogin, setStoeberhundefuehrerPassword, generateInviteToken, updateStoeberhundefuehrerSelf, getStoeberhundefuehrerSelf } = require('../controllers/stoeberhundefuehrerController'); +const { authenticateToken } = require('../middleware/auth'); +const { authenticateStoeberhundefuehrer } = require('../middleware/stoeberhundefuehrerAuth'); +const { inviteLimiter } = require('../middleware/rateLimiter'); + +// Public +router.post('/login', stoeberhundefuehrerLogin); +router.post('/set-password', inviteLimiter, setStoeberhundefuehrerPassword); + +// Admin only — generate a one-time invite token so a Stöberhundeführer can set their password +router.post('/:id/invite-token', authenticateToken, generateInviteToken); + +// Authenticated Stöberhundeführer only +router.get('/me', authenticateStoeberhundefuehrer, getStoeberhundefuehrerSelf); +router.put('/me', authenticateStoeberhundefuehrer, updateStoeberhundefuehrerSelf); + +module.exports = router; diff --git a/stoeberhunde/backend/seed.js b/stoeberhunde/backend/seed.js index 0631e47..a478859 100644 --- a/stoeberhunde/backend/seed.js +++ b/stoeberhunde/backend/seed.js @@ -24,7 +24,7 @@ const seedDatabase = async () => { // Seed admins (only if not exists) const adminAccounts = [ - { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' }, + { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || null }, { username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null } ]; diff --git a/stoeberhunde/backend/server.js b/stoeberhunde/backend/server.js index a98c08a..132dfcf 100644 --- a/stoeberhunde/backend/server.js +++ b/stoeberhunde/backend/server.js @@ -68,7 +68,7 @@ app.use('/api/auth', authLimiter, require('./routes/authRoutes')); // Strict lim app.use('/api', require('./routes/userRoutes')); app.use('/api', require('./routes/auditRoutes')); app.use('/api/config', require('./routes/configRoutes')); -app.use('/api/handler', require('./routes/handlerRoutes')); +app.use('/api/stoeberhundefuehrer', require('./routes/stoeberhundefuehrerRoutes')); // Health check with basic system info app.get('/health', async (req, res) => { diff --git a/stoeberhunde/docker-compose.yml b/stoeberhunde/docker-compose.yml index ce19424..b4adfd2 100644 --- a/stoeberhunde/docker-compose.yml +++ b/stoeberhunde/docker-compose.yml @@ -20,9 +20,13 @@ services: build: ./backend container_name: stoeberhunde-backend restart: unless-stopped - # Port 5012 only bound to localhost + # Port 5012 bound to localhost only – NOT accessible from external IPs. + # Portal nginx reaches the backend via the shared jagd-network (container name). ports: - - "0.0.0.0:5012:5000" + - "127.0.0.1:5012:5000" + networks: + - default + - jagd-network environment: - NODE_ENV=production - MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin @@ -52,7 +56,10 @@ services: container_name: stoeberhunde-frontend restart: unless-stopped ports: - - "8082:80" + - "127.0.0.1:8082:80" + networks: + - default + - jagd-network depends_on: - backend healthcheck: @@ -64,3 +71,9 @@ services: volumes: mongo-data: name: stoeberhunde-mongo-data + +networks: + default: {} + jagd-network: + external: true + name: jagd-network diff --git a/stoeberhunde/frontend/src/App.js b/stoeberhunde/frontend/src/App.js index 7f8619c..d5db12e 100644 --- a/stoeberhunde/frontend/src/App.js +++ b/stoeberhunde/frontend/src/App.js @@ -8,8 +8,8 @@ import PasswordReset from './components/auth/PasswordReset'; import Home from './pages/Home'; import Rules from './pages/Rules'; import Admin from './pages/Admin'; -import HandlerLogin from './components/handler/HandlerLogin'; -import HandlerDashboard from './components/handler/HandlerDashboard'; +import StoeberhundefuehrerLogin from './components/stoeberhundefuehrer/StoeberhundefuehrerLogin'; +import StoeberhundefuehrerDashboard from './components/stoeberhundefuehrer/StoeberhundefuehrerDashboard'; import InstallBanner from './components/common/InstallBanner'; import './App.css'; @@ -19,7 +19,7 @@ function App() { const isAdminRoute = window.location.pathname === adminPath; const isResetPasswordRoute = window.location.pathname === resetPasswordPath; const [view, setView] = useState('public'); - const [handlerUser, setHandlerUser] = useState(null); + const [stoeberhundefuehrerUser, setStoeberhundefuehrerUser] = useState(null); const { isAuthenticated, loading: authLoading, login, logout } = useAuth(); const { users, loading: usersLoading, error, refetch } = useUsers(isAuthenticated); @@ -37,13 +37,13 @@ function App() { setView('public'); }; - const handleHandlerLogin = (userData) => { - setHandlerUser(userData); - setView('handler-dashboard'); + const handleStoeberhundefuehrerLogin = (userData) => { + setStoeberhundefuehrerUser(userData); + setView('stoeberhundefuehrer-dashboard'); }; - const handleHandlerLogout = () => { - setHandlerUser(null); + const handleStoeberhundefuehrerLogout = () => { + setStoeberhundefuehrerUser(null); setView('public'); }; @@ -97,15 +97,15 @@ function App() { isAdmin={false} currentView={view} onViewChange={handleViewChange} - onHandlerLogin={handleHandlerLogout} + onStoeberhundefuehrerLogin={handleStoeberhundefuehrerLogout} />
{isAdminRoute || view === 'login' ? ( - ) : view === 'handler-login' ? ( - - ) : view === 'handler-dashboard' && handlerUser ? ( - + ) : view === 'stoeberhundefuehrer-login' ? ( + + ) : view === 'stoeberhundefuehrer-dashboard' && stoeberhundefuehrerUser ? ( + ) : ( <> {view === 'rules' && } diff --git a/stoeberhunde/frontend/src/components/admin/AuditLogs.js b/stoeberhunde/frontend/src/components/admin/AuditLogs.js index dc85948..1f53cf3 100644 --- a/stoeberhunde/frontend/src/components/admin/AuditLogs.js +++ b/stoeberhunde/frontend/src/components/admin/AuditLogs.js @@ -348,7 +348,7 @@ function AuditLogs() { - + { setFormData({ ...formData, landline: e.target.value })} />
-
- -
@@ -145,4 +145,4 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => { ); }; -export default HandlerDashboard; +export default StoeberhundefuehrerDashboard; diff --git a/stoeberhunde/frontend/src/components/handler/HandlerLogin.css b/stoeberhunde/frontend/src/components/stoeberhundefuehrer/StoeberhundefuehrerLogin.css similarity index 73% rename from stoeberhunde/frontend/src/components/handler/HandlerLogin.css rename to stoeberhunde/frontend/src/components/stoeberhundefuehrer/StoeberhundefuehrerLogin.css index 6715730..3068103 100644 --- a/stoeberhunde/frontend/src/components/handler/HandlerLogin.css +++ b/stoeberhunde/frontend/src/components/stoeberhundefuehrer/StoeberhundefuehrerLogin.css @@ -1,11 +1,11 @@ -.handler-login-container { +.stoeberhundefuehrer-login-container { display: flex; justify-content: center; align-items: flex-start; padding: 2rem 1rem; } -.handler-login-card { +.stoeberhundefuehrer-login-card { background: white; border-radius: 10px; padding: 2rem; @@ -15,18 +15,18 @@ border-top: 4px solid var(--color-primary); } -.handler-login-card h2 { +.stoeberhundefuehrer-login-card h2 { margin: 0 0 0.5rem; color: var(--color-primary); } -.handler-login-subtitle { +.stoeberhundefuehrer-login-subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-bottom: 1.5rem; } -.handler-mode-toggle { +.stoeberhundefuehrer-mode-toggle { display: flex; gap: 0; margin-bottom: 1.5rem; @@ -35,7 +35,7 @@ overflow: hidden; } -.handler-mode-toggle button { +.stoeberhundefuehrer-mode-toggle button { flex: 1; padding: 0.5rem; border: none; @@ -46,24 +46,24 @@ transition: background 0.2s; } -.handler-mode-toggle button.active { +.stoeberhundefuehrer-mode-toggle button.active { background: var(--color-primary); color: white; font-weight: 600; } -.handler-form .form-group { +.stoeberhundefuehrer-form .form-group { margin-bottom: 1rem; } -.handler-form .form-group label { +.stoeberhundefuehrer-form .form-group label { display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.9rem; } -.handler-form .form-group input { +.stoeberhundefuehrer-form .form-group input { width: 100%; padding: 0.6rem 0.75rem; border: 1px solid var(--color-border-strong); @@ -72,13 +72,13 @@ box-sizing: border-box; } -.handler-form .form-group input:focus { +.stoeberhundefuehrer-form .form-group input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px var(--color-focus); } -.btn-handler-primary { +.btn-stoeberhundefuehrer-primary { width: 100%; padding: 0.75rem; background: var(--color-primary); @@ -92,16 +92,16 @@ transition: background 0.2s; } -.btn-handler-primary:hover:not(:disabled) { +.btn-stoeberhundefuehrer-primary:hover:not(:disabled) { background: var(--color-primary-dark); } -.btn-handler-primary:disabled { +.btn-stoeberhundefuehrer-primary:disabled { opacity: 0.6; cursor: not-allowed; } -.handler-error { +.stoeberhundefuehrer-error { background: #fee2e2; color: #b91c1c; border-radius: 4px; @@ -110,7 +110,7 @@ font-size: 0.9rem; } -.handler-success { +.stoeberhundefuehrer-success { background: #dcfce7; color: #166534; border-radius: 4px; @@ -119,7 +119,7 @@ font-size: 0.9rem; } -.handler-hint { +.stoeberhundefuehrer-hint { font-size: 0.88rem; color: var(--color-text-muted); margin-bottom: 1rem; diff --git a/stoeberhunde/frontend/src/components/handler/HandlerLogin.js b/stoeberhunde/frontend/src/components/stoeberhundefuehrer/StoeberhundefuehrerLogin.js similarity index 74% rename from stoeberhunde/frontend/src/components/handler/HandlerLogin.js rename to stoeberhunde/frontend/src/components/stoeberhundefuehrer/StoeberhundefuehrerLogin.js index 5dca8c2..7cf4699 100644 --- a/stoeberhunde/frontend/src/components/handler/HandlerLogin.js +++ b/stoeberhunde/frontend/src/components/stoeberhundefuehrer/StoeberhundefuehrerLogin.js @@ -1,8 +1,8 @@ import React, { useState } from 'react'; -import { handlerLogin, setHandlerPassword } from '../../services/handler'; -import './HandlerLogin.css'; +import { stoeberhundefuehrerLogin, setStoeberhundefuehrerPassword } from '../../services/stoeberhundefuehrer'; +import './StoeberhundefuehrerLogin.css'; -const HandlerLogin = ({ onLogin }) => { +const StoeberhundefuehrerLogin = ({ onLogin }) => { const [mode, setMode] = useState('login'); // 'login' | 'set-password' const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -17,9 +17,9 @@ const HandlerLogin = ({ onLogin }) => { setError(''); setLoading(true); try { - const result = await handlerLogin(email, password); + const result = await stoeberhundefuehrerLogin(email, password); if (result.success) { - localStorage.setItem('handlerToken', result.token); + localStorage.setItem('stoeberhundefuehrerToken', result.token); onLogin(result.user); } } catch (err) { @@ -42,7 +42,7 @@ const HandlerLogin = ({ onLogin }) => { } setLoading(true); try { - const result = await setHandlerPassword(email, newPassword); + const result = await setStoeberhundefuehrerPassword(email, newPassword); if (result.success) { setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.'); setMode('login'); @@ -57,14 +57,14 @@ const HandlerLogin = ({ onLogin }) => { }; return ( -
-
+
+

Stöberhundeführer-Login

-

+

Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten.

-
+
- {error &&
{error}
} - {success &&
{success}
} + {error &&
{error}
} + {success &&
{success}
} {mode === 'login' ? ( -
+
setEmail(e.target.value)} required autoFocus /> @@ -92,13 +92,13 @@ const HandlerLogin = ({ onLogin }) => { setPassword(e.target.value)} required />
-
) : ( -
-

+ +

Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein.

@@ -113,7 +113,7 @@ const HandlerLogin = ({ onLogin }) => { setNewPassword2(e.target.value)} required />
-
@@ -123,4 +123,4 @@ const HandlerLogin = ({ onLogin }) => { ); }; -export default HandlerLogin; +export default StoeberhundefuehrerLogin; diff --git a/stoeberhunde/frontend/src/services/handler.js b/stoeberhunde/frontend/src/services/handler.js deleted file mode 100644 index 98a00c5..0000000 --- a/stoeberhunde/frontend/src/services/handler.js +++ /dev/null @@ -1,34 +0,0 @@ -import axios from 'axios'; -import { API_BASE_URL } from '../utils/constants'; - -const handlerApi = axios.create({ - baseURL: `${API_BASE_URL}/api/handler`, - timeout: 10000, - headers: { 'Content-Type': 'application/json' } -}); - -handlerApi.interceptors.request.use((config) => { - const token = localStorage.getItem('handlerToken'); - if (token) config.headers.Authorization = `Bearer ${token}`; - return config; -}); - -export const handlerLogin = async (email, password) => { - const response = await handlerApi.post('/login', { email, password }); - return response.data; -}; - -export const setHandlerPassword = async (email, newPassword) => { - const response = await handlerApi.post('/set-password', { email, newPassword }); - return response.data; -}; - -export const getHandlerMe = async () => { - const response = await handlerApi.get('/me'); - return response.data; -}; - -export const updateHandlerMe = async (data) => { - const response = await handlerApi.put('/me', data); - return response.data; -}; diff --git a/stoeberhunde/frontend/src/services/stoeberhundefuehrer.js b/stoeberhunde/frontend/src/services/stoeberhundefuehrer.js new file mode 100644 index 0000000..c42c281 --- /dev/null +++ b/stoeberhunde/frontend/src/services/stoeberhundefuehrer.js @@ -0,0 +1,34 @@ +import axios from 'axios'; +import { API_BASE_URL } from '../utils/constants'; + +const stoeberhundefuehrerApi = axios.create({ + baseURL: `${API_BASE_URL}/api/stoeberhundefuehrer`, + timeout: 10000, + headers: { 'Content-Type': 'application/json' } +}); + +stoeberhundefuehrerApi.interceptors.request.use((config) => { + const token = localStorage.getItem('stoeberhundefuehrerToken'); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +export const stoeberhundefuehrerLogin = async (email, password) => { + const response = await stoeberhundefuehrerApi.post('/login', { email, password }); + return response.data; +}; + +export const setStoeberhundefuehrerPassword = async (email, newPassword) => { + const response = await stoeberhundefuehrerApi.post('/set-password', { email, newPassword }); + return response.data; +}; + +export const getStoeberhundefuehrerMe = async () => { + const response = await stoeberhundefuehrerApi.get('/me'); + return response.data; +}; + +export const updateStoeberhundefuehrerMe = async (data) => { + const response = await stoeberhundefuehrerApi.put('/me', data); + return response.data; +};