170 lines
5.6 KiB
JavaScript
170 lines
5.6 KiB
JavaScript
import express from 'express';
|
|
import { createHash } from 'node:crypto';
|
|
import bcrypt from 'bcryptjs';
|
|
import { pool } from '../db.js';
|
|
import { signToken, requireAuth } from '../middleware/auth.js';
|
|
import { normalizeAvatar, normalizeColor, normalizeTheme, normalizeThemeMode } from '../userFields.js';
|
|
|
|
const router = express.Router();
|
|
|
|
export function publicUser(row) {
|
|
/*
|
|
* Ein Design, das es nicht mehr gibt, wird als "nicht gesetzt" ausgeliefert.
|
|
*
|
|
* Die Prüfung beim Schreiben reicht dafür nicht: sie greift erst ab dem
|
|
* Zeitpunkt, zu dem ein Wert aus der Liste fliegt. Wer ihn vorher gewählt
|
|
* hatte, trägt ihn weiter in der Datenbank - und die Oberfläche bekäme einen
|
|
* Namen, zu dem sie keine Beschriftung kennt.
|
|
*/
|
|
const theme = normalizeTheme(row.theme ?? null);
|
|
|
|
return {
|
|
id: row.id,
|
|
username: row.username,
|
|
email: row.email ?? null,
|
|
name: row.name,
|
|
role: row.role,
|
|
color: row.color ?? null,
|
|
avatar: row.avatar ?? null,
|
|
theme: theme.error ? null : theme.value,
|
|
theme_mode: row.theme_mode ?? null,
|
|
is_active: Boolean(row.is_active),
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
};
|
|
}
|
|
|
|
// POST /api/auth/login { identifier, password }
|
|
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.' });
|
|
}
|
|
|
|
try {
|
|
const [rows] = await pool.query(
|
|
'SELECT * FROM icon_users WHERE username = ? OR email = ? LIMIT 1',
|
|
[identifier, identifier.toLowerCase()]
|
|
);
|
|
const user = rows[0];
|
|
|
|
// Bewusst dieselbe Meldung fuer "kein Benutzer" und "falsches Passwort",
|
|
// damit sich ueber die API keine Benutzernamen aufzaehlen lassen.
|
|
const invalid = () => res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' });
|
|
if (!user) return invalid();
|
|
|
|
const ok = await verifyPassword(password, user);
|
|
if (!ok) return invalid();
|
|
if (!user.is_active) {
|
|
return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' });
|
|
}
|
|
|
|
res.json({ token: signToken(user), user: publicUser(user) });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
/*
|
|
* Passwortpruefung mit Uebergang aus dem alten CRM.
|
|
*
|
|
* Dort lagen die Passwoerter als ungesalzenes SHA-256 in Supabase. Solche
|
|
* Hashes sind an ihrer Form erkennbar (64 Hexziffern) - ein bcrypt-Hash
|
|
* beginnt immer mit $2. Stimmt ein Altpasswort, wird es im selben Zug auf
|
|
* bcrypt umgeschrieben: beim naechsten Mal gilt nur noch der starke Hash.
|
|
*
|
|
* Das ist bewusst kein dauerhafter zweiter Weg, sondern eine Einbahnstrasse -
|
|
* neu vergeben wird ausschliesslich bcrypt.
|
|
*/
|
|
async function verifyPassword(password, user) {
|
|
const hash = String(user.password_hash ?? '');
|
|
|
|
if (/^[a-f0-9]{64}$/i.test(hash)) {
|
|
const legacy = createHash('sha256').update(password, 'utf8').digest('hex');
|
|
if (legacy.toLowerCase() !== hash.toLowerCase()) return false;
|
|
|
|
await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [
|
|
await bcrypt.hash(password, 12),
|
|
user.id,
|
|
]);
|
|
console.log(`[ICON CRM] Passwort von "${user.username}" auf bcrypt umgestellt.`);
|
|
return true;
|
|
}
|
|
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
// GET /api/auth/me
|
|
router.get('/me', requireAuth, (req, res) => {
|
|
res.json({ user: publicUser(req.user) });
|
|
});
|
|
|
|
// PATCH /api/auth/me { color?, avatar?, theme?, theme_mode? }
|
|
// Jeder darf seine eigene Farbe, sein Foto und seine Darstellung setzen.
|
|
// null bzw. "" setzt das Feld zurueck.
|
|
router.patch('/me', requireAuth, async (req, res, next) => {
|
|
const fields = [];
|
|
const values = [];
|
|
const changed = {};
|
|
|
|
for (const [field, normalize] of [
|
|
['color', normalizeColor],
|
|
['avatar', normalizeAvatar],
|
|
['theme', normalizeTheme],
|
|
['theme_mode', normalizeThemeMode],
|
|
]) {
|
|
if (req.body?.[field] === undefined) continue;
|
|
const result = normalize(req.body[field]);
|
|
if (result.error) return res.status(400).json({ error: result.error });
|
|
fields.push(`${field} = ?`);
|
|
values.push(result.value);
|
|
changed[field] = result.value;
|
|
}
|
|
|
|
if (fields.length === 0) {
|
|
return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
|
}
|
|
|
|
try {
|
|
await pool.query(`UPDATE icon_users SET ${fields.join(', ')} WHERE id = ?`, [...values, req.user.id]);
|
|
res.json({ user: publicUser({ ...req.user, ...changed }) });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/change-password { current_password, new_password }
|
|
router.post('/change-password', requireAuth, async (req, res, next) => {
|
|
const current = String(req.body?.current_password || '');
|
|
const next_ = String(req.body?.new_password || '');
|
|
|
|
if (!current || !next_) {
|
|
return res.status(400).json({ error: 'Aktuelles und neues Passwort sind erforderlich.' });
|
|
}
|
|
if (next_.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 icon_users WHERE id = ? LIMIT 1', [
|
|
req.user.id,
|
|
]);
|
|
if (!rows[0]) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
|
|
|
const ok = await bcrypt.compare(current, rows[0].password_hash);
|
|
if (!ok) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' });
|
|
|
|
await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [
|
|
await bcrypt.hash(next_, 12),
|
|
req.user.id,
|
|
]);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export default router;
|