V1.0
This commit is contained in:
@@ -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
417
services/ldap-service.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user