This commit is contained in:
2026-02-02 19:12:40 +01:00
parent c6421049c8
commit 952c353118
17 changed files with 982 additions and 513 deletions

View File

@@ -1,7 +1,7 @@
// LDAP-Scheduler Service
const { db } = require('../database');
const LDAPService = require('../ldap-service');
const LDAPService = require('./ldap-service');
// Automatische LDAP-Synchronisation einrichten
function setupLDAPScheduler() {

417
services/ldap-service.js Normal file
View File

@@ -0,0 +1,417 @@
const ldap = require('ldapjs');
const { db } = require('../database');
const bcrypt = require('bcryptjs');
/**
* LDAP-Service für Benutzer-Synchronisation
*/
class LDAPService {
/**
* LDAP-Konfiguration aus der Datenbank abrufen
*/
static getConfig(callback) {
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, config) => {
if (err) {
return callback(err, null);
}
callback(null, config);
});
}
/**
* LDAP-Verbindung herstellen
*/
static connect(config, callback) {
if (!config || !config.enabled || !config.url) {
return callback(new Error('LDAP ist nicht konfiguriert oder deaktiviert'));
}
const client = ldap.createClient({
url: config.url,
timeout: 10000,
connectTimeout: 10000
});
// Fehlerbehandlung
client.on('error', (err) => {
callback(err, null);
});
// Bind mit Credentials
const bindDN = config.bind_dn || '';
const bindPassword = config.bind_password || '';
// Hinweis: Passwort wird im Klartext gespeichert
// In einer produktiven Umgebung sollte man eine Verschlüsselung mit einem Master-Key verwenden
client.bind(bindDN, bindPassword, (err) => {
if (err) {
client.unbind();
return callback(err, null);
}
callback(null, client);
});
}
/**
* Benutzer aus LDAP abrufen
*/
static searchUsers(client, config, callback) {
const baseDN = config.base_dn || '';
const searchFilter = config.user_search_filter || '(objectClass=person)';
const searchOptions = {
filter: searchFilter,
scope: 'sub',
attributes: [
config.username_attribute || 'sAMAccountName',
config.firstname_attribute || 'givenName',
config.lastname_attribute || 'sn'
]
};
const users = [];
client.search(baseDN, searchOptions, (err, res) => {
if (err) {
return callback(err, null);
}
res.on('searchEntry', (entry) => {
const user = {
username: this.getAttributeValue(entry, config.username_attribute || 'sAMAccountName'),
firstname: this.getAttributeValue(entry, config.firstname_attribute || 'givenName'),
lastname: this.getAttributeValue(entry, config.lastname_attribute || 'sn')
};
// Nur Benutzer mit allen erforderlichen Feldern hinzufügen
if (user.username && user.firstname && user.lastname) {
users.push(user);
}
});
res.on('error', (err) => {
callback(err, null);
});
res.on('end', (result) => {
if (result && result.status !== 0) {
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`), null);
}
callback(null, users);
});
});
}
/**
* Wert eines LDAP-Attributs extrahieren
*
* Die ldapjs-Bibliothek behandelt UTF-8-Zeichen automatisch korrekt.
* Diese Funktion stellt sicher, dass UTF-8-Zeichen wie ß, ä, ö, ü korrekt zurückgegeben werden.
*/
static getAttributeValue(entry, attributeName) {
const attr = entry.attributes.find(a => a.type === attributeName);
if (!attr) {
return null;
}
const value = Array.isArray(attr.values) ? attr.values[0] : attr.values;
// Stelle sicher, dass der Wert als String zurückgegeben wird (UTF-8 wird automatisch korrekt behandelt)
return value != null ? String(value) : null;
}
/**
* Escaped einen Wert für LDAP-Filter (verhindert LDAP-Injection)
*
* WICHTIG: UTF-8-Zeichen wie ß, ä, ö, ü müssen NICHT escaped werden.
* LDAP-Filter unterstützen UTF-8 direkt nach RFC 4515.
* Nur die speziellen LDAP-Filter-Zeichen werden escaped.
*/
static escapeLDAPFilter(value) {
if (!value) return '';
// Stelle sicher, dass der Wert als String behandelt wird
const str = String(value);
// Escape nur die speziellen LDAP-Filter-Zeichen
// UTF-8-Zeichen wie ß, ä, ö, ü werden direkt verwendet
return str
.replace(/\\/g, '\\5c') // Backslash
.replace(/\*/g, '\\2a') // Stern
.replace(/\(/g, '\\28') // Öffnende Klammer
.replace(/\)/g, '\\29') // Schließende Klammer
.replace(/\0/g, '\\00'); // Null-Byte
}
/**
* Benutzer in SQLite synchronisieren
*/
static syncUsers(ldapUsers, callback) {
let syncedCount = 0;
let errorCount = 0;
const errors = [];
if (!ldapUsers || ldapUsers.length === 0) {
return callback(null, { synced: 0, errors: [] });
}
// Verarbeite jeden Benutzer
const processUser = (index) => {
if (index >= ldapUsers.length) {
return callback(null, { synced: syncedCount, errors: errors });
}
const ldapUser = ldapUsers[index];
// .trim() behält UTF-8-Zeichen wie ß, ä, ö, ü korrekt bei
// Stelle sicher, dass Werte als String behandelt werden
const username = String(ldapUser.username || '').trim();
const firstname = String(ldapUser.firstname || '').trim();
const lastname = String(ldapUser.lastname || '').trim();
// Prüfe ob Benutzer bereits existiert (case-insensitive)
db.get('SELECT id, role FROM users WHERE username = ? COLLATE NOCASE', [username], (err, existingUser) => {
if (err) {
errors.push(`Fehler beim Prüfen von ${username}: ${err.message}`);
errorCount++;
return processUser(index + 1);
}
if (existingUser) {
// Benutzer existiert - aktualisiere nur Name, behalte Rolle (case-insensitive)
db.run(
'UPDATE users SET firstname = ?, lastname = ? WHERE username = ? COLLATE NOCASE',
[firstname, lastname, username],
(err) => {
if (err) {
errors.push(`Fehler beim Aktualisieren von ${username}: ${err.message}`);
errorCount++;
} else {
syncedCount++;
}
processUser(index + 1);
}
);
} else {
// Neuer Benutzer - erstelle mit Standard-Rolle
// Generiere ein zufälliges Passwort (Benutzer muss es beim ersten Login ändern)
const defaultPassword = bcrypt.hashSync('changeme123', 10);
db.run(
'INSERT INTO users (username, password, firstname, lastname, role) VALUES (?, ?, ?, ?, ?)',
[username, defaultPassword, firstname, lastname, 'mitarbeiter'],
(err) => {
if (err) {
errors.push(`Fehler beim Erstellen von ${username}: ${err.message}`);
errorCount++;
} else {
syncedCount++;
}
processUser(index + 1);
}
);
}
});
};
processUser(0);
}
/**
* Sync-Log-Eintrag erstellen
*/
static createSyncLog(syncType, status, usersSynced, errorMessage, callback) {
const startedAt = new Date().toISOString();
const completedAt = new Date().toISOString();
db.run(
`INSERT INTO ldap_sync_log (sync_type, status, users_synced, error_message, sync_started_at, sync_completed_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[syncType, status, usersSynced, errorMessage || null, startedAt, completedAt],
(err) => {
if (callback) {
callback(err);
}
}
);
}
/**
* Letzte Synchronisation aktualisieren
*/
static updateLastSync(callback) {
db.run(
'UPDATE ldap_config SET last_sync = CURRENT_TIMESTAMP WHERE id = 1',
(err) => {
if (callback) {
callback(err);
}
}
);
}
/**
* Suchvarianten für Benutzername (case-insensitive, ß/ss) für LDAP-Filter.
* So findet der Login auch "GeißlerJ", wenn LDAP "geißlerj" oder "GeisslerJ" speichert.
*/
static getUsernameSearchVariants(usernameStr) {
const variants = new Set();
variants.add(usernameStr);
variants.add(usernameStr.toLowerCase());
variants.add(usernameStr.replace(/\u00df/g, 'ss')); // ß -> ss
variants.add(usernameStr.replace(/ss/g, '\u00df')); // ss -> ß (z. B. Geissler -> Geißler)
return [...variants];
}
/**
* Benutzer gegen LDAP authentifizieren
*
* Unterstützt UTF-8-Zeichen wie ß, ä, ö, ü in Usernamen.
* Sucht mit mehreren Varianten (Groß-/Kleinschreibung, ß/ss), damit z. B. "GeißlerJ"
* auch gefunden wird, wenn LDAP "geißlerj" oder "GeisslerJ" speichert.
* Gibt bei Erfolg den kanonischen LDAP-Benutzernamen zurück (für DB-Lookup).
*/
static authenticate(username, password, callback) {
// Stelle sicher, dass Username als String behandelt wird (UTF-8 wird korrekt unterstützt)
const usernameStr = String(username || '').trim();
if (!usernameStr) {
return callback(new Error('Benutzername darf nicht leer sein'), false);
}
// Konfiguration abrufen
this.getConfig((err, config) => {
if (err || !config || !config.enabled) {
return callback(new Error('LDAP ist nicht aktiviert'), false);
}
// LDAP-Verbindung herstellen (mit Service-Account)
this.connect(config, (err, client) => {
if (err) {
return callback(err, false);
}
const baseDN = config.base_dn || '';
const usernameAttr = config.username_attribute || 'sAMAccountName';
// OR-Filter mit mehreren Varianten (exakt, lowercase, ß/ss), damit Login trotz unterschiedlicher Schreibweise funktioniert
const variants = this.getUsernameSearchVariants(usernameStr);
const filterParts = variants.map(v => `(${usernameAttr}=${this.escapeLDAPFilter(v)})`);
const searchFilter = filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`;
const searchOptions = {
filter: searchFilter,
scope: 'sub',
attributes: ['dn', usernameAttr]
};
let userDN = null;
let canonicalUsername = null;
client.search(baseDN, searchOptions, (err, res) => {
if (err) {
client.unbind();
const errorMsg = err.message || String(err);
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${errorMsg}. Hinweis: Prüfen Sie, ob der Benutzername UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt enthält.`), false);
}
res.on('searchEntry', (entry) => {
userDN = entry.dn.toString();
// Kanonischen Benutzernamen aus LDAP verwenden (für DB-Lookup nach Sync)
canonicalUsername = this.getAttributeValue(entry, usernameAttr) || usernameStr;
});
res.on('error', (err) => {
client.unbind();
const errorMsg = err.message || String(err);
callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false);
});
res.on('end', (result) => {
client.unbind();
if (!userDN) {
return callback(new Error(`Benutzer "${usernameStr}" nicht gefunden. Hinweis: Prüfen Sie, ob der Benutzername korrekt ist und UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt geschrieben sind.`), false);
}
const authClient = ldap.createClient({
url: config.url,
timeout: 10000,
connectTimeout: 10000
});
authClient.on('error', (err) => {
authClient.unbind();
callback(err, false);
});
authClient.bind(userDN, password, (err) => {
authClient.unbind();
if (err) {
const errorMsg = err.message || String(err);
return callback(new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`), false);
}
// Erfolg: kanonischen Benutzernamen mitgeben, damit die DB-Lookup mit dem Sync-Benutzernamen funktioniert
callback(null, true, { username: canonicalUsername });
});
});
});
});
});
}
/**
* Vollständige Synchronisation durchführen
*/
static performSync(syncType, callback) {
const startedAt = new Date();
// Konfiguration abrufen
this.getConfig((err, config) => {
if (err) {
this.createSyncLog(syncType, 'error', 0, `Fehler beim Abrufen der Konfiguration: ${err.message}`, () => {});
return callback(err);
}
if (!config || !config.enabled) {
const errorMsg = 'LDAP-Synchronisation ist nicht aktiviert';
this.createSyncLog(syncType, 'error', 0, errorMsg, () => {});
return callback(new Error(errorMsg));
}
// LDAP-Verbindung herstellen
this.connect(config, (err, client) => {
if (err) {
this.createSyncLog(syncType, 'error', 0, `LDAP-Verbindungsfehler: ${err.message}`, () => {});
return callback(err);
}
// Benutzer aus LDAP abrufen
this.searchUsers(client, config, (err, ldapUsers) => {
// Verbindung schließen
client.unbind();
if (err) {
this.createSyncLog(syncType, 'error', 0, `LDAP-Suchfehler: ${err.message}`, () => {});
return callback(err);
}
// Benutzer synchronisieren
this.syncUsers(ldapUsers, (err, result) => {
if (err) {
this.createSyncLog(syncType, 'error', result.synced, `Sync-Fehler: ${err.message}`, () => {});
return callback(err);
}
// Letzte Synchronisation aktualisieren
this.updateLastSync(() => {
const status = result.errors.length > 0 ? 'error' : 'success';
const errorMsg = result.errors.length > 0 ? result.errors.join('; ') : null;
this.createSyncLog(syncType, status, result.synced, errorMsg, () => {
callback(null, result);
});
});
});
});
});
});
}
}
module.exports = LDAPService;

View File

@@ -530,18 +530,56 @@ function generatePDFToBuffer(timesheetId, req) {
}
// Check-in/Check-out URL-Basis (wie im Dashboard-Frontend)
function getCheckinBaseUrl(req) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
return baseUrl.replace(/:\d+$/, ':3334');
function getCheckinBaseUrl(req, callback) {
// Versuche Root URL aus Datenbank zu laden
db.get('SELECT checkin_root_url FROM system_options WHERE id = 1', (err, options) => {
if (err) {
console.warn('Fehler beim Laden der Root URL, verwende Fallback:', err);
}
let checkinBaseUrl = null;
if (options && options.checkin_root_url && options.checkin_root_url.trim() !== '') {
checkinBaseUrl = options.checkin_root_url.trim();
// Stelle sicher, dass kein trailing slash vorhanden ist
checkinBaseUrl = checkinBaseUrl.replace(/\/+$/, '');
}
// Fallback: Konstruiere URL aus Request (Port 3334 für Check-in)
if (!checkinBaseUrl) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
}
callback(checkinBaseUrl);
});
}
// PDF mit Check-in- und Check-out-QR-Codes (A4)
async function generateCheckinCheckoutQRPDF(req, res) {
// urlType: 'internal' oder 'external'
async function generateCheckinCheckoutQRPDF(req, res, urlType = 'internal') {
const userId = req.session.userId;
if (!userId) {
return res.status(401).send('Nicht angemeldet');
}
const checkinBaseUrl = getCheckinBaseUrl(req);
let checkinBaseUrl;
if (urlType === 'external') {
// Externe URL: Lade aus Datenbank
checkinBaseUrl = await new Promise((resolve) => {
getCheckinBaseUrl(req, resolve);
});
} else {
// Interne URL: Konstruiere aus Request (Port 3334)
const baseUrl = `${req.protocol}://${req.get('host')}`;
if (baseUrl.match(/:\d+$/)) {
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
} else {
const url = new URL(baseUrl);
checkinBaseUrl = `${url.protocol}//${url.hostname}:3334`;
}
}
const checkinUrl = `${checkinBaseUrl}/api/checkin/${userId}`;
const checkoutUrl = `${checkinBaseUrl}/api/checkout/${userId}`;
@@ -554,11 +592,12 @@ async function generateCheckinCheckoutQRPDF(req, res) {
const firstname = (req.session.firstname || '').replace(/\s+/g, '');
const lastname = (req.session.lastname || '').replace(/\s+/g, '');
const namePart = [firstname, lastname].filter(Boolean).join('_') || 'User';
const urlTypeLabel = urlType === 'external' ? 'Extern' : 'Intern';
const today = new Date();
const dateStr = today.getFullYear() + '-' +
String(today.getMonth() + 1).padStart(2, '0') + '-' +
String(today.getDate()).padStart(2, '0');
const filename = `Check-in_Check-out_QR_${namePart}_${dateStr}.pdf`;
const filename = `Check-in_Check-out_QR_${urlTypeLabel}_${namePart}_${dateStr}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('X-Content-Type-Options', 'nosniff');
@@ -573,7 +612,10 @@ async function generateCheckinCheckoutQRPDF(req, res) {
const left1 = 50 + (pageWidth / 2 - qrSize - gap / 2);
const left2 = 50 + (pageWidth / 2 + gap / 2);
doc.fontSize(18).text('Check-in / Check-out Zeiterfassung', { align: 'center' });
const title = urlType === 'external'
? 'Check-in / Check-out Zeiterfassung (Externe URLs)'
: 'Check-in / Check-out Zeiterfassung (Interne URLs)';
doc.fontSize(18).text(title, { align: 'center' });
doc.moveDown(1.5);
const topY = doc.y;