import express from 'express'; import bcrypt from 'bcryptjs'; import crypto from 'node:crypto'; import { pool } from '../db.js'; import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js'; import { getSettings } from '../lib/settings.js'; import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js'; import { normalizeAvatar } from '../lib/avatar.js'; const router = express.Router(); /* * Bremse gegen Passwortraten: 10 Fehlversuche je IP und Kennung in 15 Minuten. * Im Speicher reicht - es gibt genau eine Backend-Instanz. */ const attempts = new Map(); const WINDOW = 15 * 60000; function isLimited(key) { const now = Date.now(); const recent = (attempts.get(key) || []).filter((t) => now - t < WINDOW); if (recent.length) attempts.set(key, recent); else attempts.delete(key); return recent.length >= 10; } function recordFailure(key) { attempts.set(key, [...(attempts.get(key) || []), Date.now()]); } // Oeffentlich: was zeigt der Anmeldeschirm? router.get('/config', async (req, res, next) => { try { const { sso } = await getSettings(); const ssoOn = Boolean(sso.enabled && ssoConfigured(sso)); res.json({ sso_enabled: ssoOn, sso_label: sso.button_label, local_login: ssoOn ? sso.local_login !== false : true }); } catch (err) { next(err); } }); router.post('/login', async (req, res, next) => { const identifier = String(req.body?.identifier || '').trim(); const password = String(req.body?.password || ''); if (!identifier || !password) { return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' }); } const key = `${req.ip}|${identifier.toLowerCase()}`; if (isLimited(key)) { return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' }); } try { const [rows] = await pool.query('SELECT * FROM pulse_users WHERE username = ? OR email = ? LIMIT 1', [ identifier, identifier.toLowerCase(), ]); const user = rows[0]; // Identische Meldung fuer "gibt es nicht" und "falsches Passwort". const ok = user?.password_hash ? await bcrypt.compare(password, user.password_hash) : false; if (!ok) { recordFailure(key); return res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' }); } if (!user.is_active) return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' }); await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]); res.json({ token: signToken(user), user: publicUser(user) }); } catch (err) { next(err); } }); router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) })); // Eigene Darstellung: Design, Modus, Farbe, Profilbild. router.patch('/me', requireAuth, async (req, res, next) => { const updates = {}; if (req.body?.theme !== undefined) { const theme = req.body.theme; if (theme !== null && !['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].includes(theme)) { return res.status(400).json({ error: 'Unbekanntes Design.' }); } updates.theme = theme === 'perinco' ? null : theme; } if (req.body?.theme_mode !== undefined) { const mode = req.body.theme_mode; if (mode !== null && mode !== 'light' && mode !== 'dark') return res.status(400).json({ error: 'Unbekannter Modus.' }); updates.theme_mode = mode; } if (req.body?.color !== undefined) { const color = req.body.color; if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' }); updates.color = color; } if (req.body?.avatar !== undefined) { const avatar = normalizeAvatar(req.body.avatar); if (avatar.error) return res.status(400).json({ error: avatar.error }); updates.avatar = avatar.value; } const keys = Object.keys(updates); if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); try { await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [ ...keys.map((k) => updates[k]), req.user.id, ]); res.json({ user: publicUser({ ...req.user, ...updates }) }); } catch (err) { next(err); } }); router.post('/change-password', requireAuth, async (req, res, next) => { const current = String(req.body?.current_password || ''); const wanted = String(req.body?.new_password || ''); if (wanted.length < 8) return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' }); try { const [rows] = await pool.query('SELECT password_hash FROM pulse_users WHERE id = ?', [req.user.id]); const hash = rows[0]?.password_hash; if (!hash) return res.status(400).json({ error: 'Dieses Konto meldet sich über SSO an und hat kein Passwort.' }); if (!(await bcrypt.compare(current, hash))) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' }); await pool.query('UPDATE pulse_users SET password_hash = ? WHERE id = ?', [await bcrypt.hash(wanted, 12), req.user.id]); res.json({ ok: true }); } catch (err) { next(err); } }); /* ---------------------------------------------------------------- SSO -- */ router.get('/sso/start', async (req, res, next) => { try { const { sso } = await getSettings(); if (!sso.enabled || !ssoConfigured(sso)) { return res.redirect('/?sso_error=' + encodeURIComponent('SSO ist nicht eingerichtet.')); } const state = signShort({ n: crypto.randomBytes(12).toString('hex'), t: 'sso' }); res.redirect(authorizeUrl(sso, state)); } catch (err) { next(err); } }); router.get('/sso/callback', async (req, res) => { const back = (message) => res.redirect('/?sso_error=' + encodeURIComponent(message)); try { if (req.query.error) return back(String(req.query.error_description || req.query.error).split('\n')[0]); try { const state = verifyShort(String(req.query.state || '')); if (state.t !== 'sso') throw new Error('state'); } catch { return back('Anmeldung abgelaufen. Bitte erneut versuchen.'); } const { sso } = await getSettings(); if (!sso.enabled || !ssoConfigured(sso)) return back('SSO ist nicht eingerichtet.'); const identity = await exchangeCode(sso, String(req.query.code || '')); // Nur wer in der SSO-Verwaltung uebernommen wurde, kommt herein - verknuepft // wird ueber die Objekt-ID, nicht ueber die Mailadresse. const [rows] = await pool.query("SELECT * FROM pulse_users WHERE external_id = ? AND source = 'sso' LIMIT 1", [identity.oid]); const user = rows[0]; if (!user) { return back(`Für ${identity.email || 'dieses Konto'} ist Pulse noch nicht freigeschaltet. Bitte an einen Manager oder Administrator wenden.`); } if (!user.is_active) return back('Dieses Benutzerkonto ist deaktiviert.'); await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]); // Token im Fragment: landet weder im Server-Log noch im Referer. res.redirect(`/#sso_token=${encodeURIComponent(signToken(user))}`); } catch (err) { console.error('[Pulse] SSO-Anmeldung fehlgeschlagen:', err.message); back('Anmeldung über Microsoft fehlgeschlagen: ' + err.message); } }); export default router;