Jeder setzt sein Bild unter "Mein Konto", Vorgesetzte und Administratoren auch fuer die Benutzer, die sie verwalten. Wo bisher die Initialen standen, steht jetzt das Bild - Seitenleiste, Kopfzeile, Team, Dashboard, Reports, Projekte, Kunden und die Buchungsliste. Ohne Bild bleiben die Initialen. Der Browser verkleinert das Bild vor dem Hochladen auf ein Quadrat von 256 px und schneidet mittig zu (lib/imageCompress.ts): aus einem 4-MB-Foto werden wenige Kilobyte, und der Server muss kein Bild verarbeiten. Gespeichert wird es als Data-URL in pulse_users.avatar (LONGTEXT). Kein Upload-Verzeichnis, das gesichert und beim Deployment am Leben gehalten werden muesste. Geprueft wird serverseitig in lib/avatar.js: nur PNG, JPEG, GIF, WebP und AVIF als Data-URL, hoechstens 2 MB. SVG ist bewusst nicht erlaubt, es kann Skripte tragen. Externe http(s)-Adressen ebenso wenig - sonst laedt jeder Seitenaufruf ein Bild von einem fremden Server und verraet, wer wann mit Pulse arbeitet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
177 lines
7.2 KiB
JavaScript
177 lines
7.2 KiB
JavaScript
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;
|