Files
perinco-Pulse/server/src/routes/auth.js
T
schakal00783andClaude Opus 5 9576d18abc feat(profil): eigenes Profilbild hochladen
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>
2026-09-19 23:06:02 +02:00

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;