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, debugLabel = '') { if (!value) return ''; // Stelle sicher, dass der Wert als String behandelt wird const str = String(value); // Debug-Logging if (debugLabel) { console.log(`[escapeLDAPFilter ${debugLabel}] Input:`, str); const utf8Bytes = Buffer.from(str, 'utf8'); console.log(`[escapeLDAPFilter ${debugLabel}] UTF-8 Bytes:`, Array.from(utf8Bytes)); } // Escape nur die speziellen LDAP-Filter-Zeichen // UTF-8-Zeichen wie ß, ä, ö, ü werden direkt verwendet const escaped = 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 if (debugLabel) { console.log(`[escapeLDAPFilter ${debugLabel}] Output:`, escaped); const escapedBytes = Buffer.from(escaped, 'utf8'); console.log(`[escapeLDAPFilter ${debugLabel}] Escaped UTF-8 Bytes:`, Array.from(escapedBytes)); } return escaped; } /** * 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(); console.log('\n========== LDAP AUTHENTICATE - START =========='); console.log('[LDAP] Username input:', usernameStr); const utf8Bytes = Buffer.from(usernameStr, 'utf8'); console.log('[LDAP] Username UTF-8 Bytes:', Array.from(utf8Bytes)); console.log('[LDAP] Username UTF-8 Bytes (Hex):', Array.from(utf8Bytes).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' ')); console.log('[LDAP] Username length:', usernameStr.length); // Character-by-character analysis console.log('[LDAP] Character analysis:'); for (let i = 0; i < usernameStr.length; i++) { const char = usernameStr[i]; const codePoint = char.codePointAt(0); const charBytes = Buffer.from(char, 'utf8'); console.log(` [${i}] "${char}" | U+${codePoint.toString(16).toUpperCase().padStart(4, '0')} (${codePoint}) | Bytes: [${Array.from(charBytes).join(', ')}]`); } 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); } console.log('[LDAP] Config loaded - baseDN:', config.base_dn); console.log('[LDAP] Config - usernameAttr:', config.username_attribute || 'sAMAccountName'); // LDAP-Verbindung herstellen (mit Service-Account) this.connect(config, (err, client) => { if (err) { console.error('[LDAP] Connection error:', err); return callback(err, false); } console.log('[LDAP] Connected successfully'); const baseDN = config.base_dn || ''; const usernameAttr = config.username_attribute || 'sAMAccountName'; // Primär: Exakte Übereinstimmung mit sAMAccountName (wie in AD gespeichert) // Varianten werden als Fallback verwendet, falls exakte Suche fehlschlägt console.log('[LDAP] Using exact sAMAccountName match (as stored in AD)'); const exactEscaped = this.escapeLDAPFilter(usernameStr, 'exact'); const exactFilter = `(${usernameAttr}=${exactEscaped})`; console.log('[LDAP] Exact LDAP Search Filter:', exactFilter); console.log('[LDAP] Exact Filter UTF-8 Bytes:', Array.from(Buffer.from(exactFilter, 'utf8'))); const exactSearchOptions = { filter: exactFilter, scope: 'sub', attributes: ['dn', usernameAttr] }; console.log('[LDAP] Starting LDAP search with exact match...'); console.log('[LDAP] Search options:', JSON.stringify(exactSearchOptions, null, 2)); let userDN = null; let canonicalUsername = null; let searchAttempted = false; // Zuerst exakte Suche versuchen client.search(baseDN, exactSearchOptions, (err, res) => { if (err) { console.error('[LDAP] Exact search error:', err); // Bei Fehler: Varianten als Fallback versuchen return this.tryVariantSearch(client, baseDN, usernameAttr, usernameStr, password, config, callback); } searchAttempted = true; res.on('searchEntry', (entry) => { userDN = entry.dn.toString(); canonicalUsername = this.getAttributeValue(entry, usernameAttr) || usernameStr; console.log('[LDAP] Exact search entry found!'); console.log('[LDAP] userDN:', userDN); console.log('[LDAP] canonicalUsername:', canonicalUsername); const canonicalBytes = Buffer.from(canonicalUsername, 'utf8'); console.log('[LDAP] canonicalUsername UTF-8 Bytes:', Array.from(canonicalBytes)); console.log('[LDAP] canonicalUsername UTF-8 Bytes (Hex):', Array.from(canonicalBytes).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' ')); }); res.on('error', (err) => { const errorMsg = err.message || String(err); console.error('[LDAP] Exact search error:', errorMsg); console.error('[LDAP] Error details:', err); // Bei Fehler: Varianten als Fallback versuchen if (!userDN) { return this.tryVariantSearch(client, baseDN, usernameAttr, usernameStr, password, config, callback); } client.unbind(); callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false); }); res.on('end', (result) => { console.log('[LDAP] Exact search ended. Status:', result ? result.status : 'unknown'); console.log('[LDAP] userDN found:', userDN ? 'YES' : 'NO'); if (!userDN) { console.log('[LDAP] Exact match not found, trying variants as fallback...'); // Exakte Suche fehlgeschlagen, Varianten als Fallback versuchen return this.tryVariantSearch(client, baseDN, usernameAttr, usernameStr, password, config, callback); } // Exakte Suche erfolgreich - mit Bind fortfahren this.performBind(config, userDN, password, canonicalUsername, callback); }); }); }); }); } /** * Varianten-Suche als Fallback (wenn exakte Suche fehlschlägt) */ static tryVariantSearch(client, baseDN, usernameAttr, usernameStr, password, config, callback) { console.log('[LDAP] Starting variant search as fallback...'); // OR-Filter mit mehreren Varianten (lowercase, ß/ss), damit Login trotz unterschiedlicher Schreibweise funktioniert const variants = this.getUsernameSearchVariants(usernameStr); console.log('[LDAP] Username variants generated:', variants); console.log('[LDAP] Number of variants:', variants.length); const filterParts = variants.map((v, idx) => { const escaped = this.escapeLDAPFilter(v, `variant-${idx}`); return `(${usernameAttr}=${escaped})`; }); const searchFilter = filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`; console.log('[LDAP] Variant LDAP Search Filter:', searchFilter); console.log('[LDAP] Variant Filter UTF-8 Bytes:', Array.from(Buffer.from(searchFilter, 'utf8'))); 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; console.log('[LDAP] Search entry found!'); console.log('[LDAP] userDN:', userDN); console.log('[LDAP] canonicalUsername:', canonicalUsername); const canonicalBytes = Buffer.from(canonicalUsername, 'utf8'); console.log('[LDAP] canonicalUsername UTF-8 Bytes:', Array.from(canonicalBytes)); console.log('[LDAP] canonicalUsername UTF-8 Bytes (Hex):', Array.from(canonicalBytes).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' ')); }); res.on('error', (err) => { client.unbind(); const errorMsg = err.message || String(err); console.error('[LDAP] Search error:', errorMsg); console.error('[LDAP] Error details:', err); callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false); }); res.on('end', (result) => { client.unbind(); console.log('[LDAP] Search ended. Status:', result ? result.status : 'unknown'); console.log('[LDAP] userDN found:', userDN ? 'YES' : 'NO'); console.log('[LDAP] canonicalUsername:', canonicalUsername); if (!userDN) { console.error('[LDAP] User not found in LDAP (even with variants)!'); console.error('[LDAP] Searched for:', usernameStr); console.error('[LDAP] Used filter:', searchFilter); 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); } // Varianten-Suche erfolgreich - mit Bind fortfahren this.performBind(config, userDN, password, canonicalUsername, callback); }); }); } /** * LDAP Bind durchführen (Passwort-Authentifizierung) */ static performBind(config, userDN, password, canonicalUsername, callback) { console.log('[LDAP] Attempting bind with userDN:', userDN); const authClient = ldap.createClient({ url: config.url, timeout: 10000, connectTimeout: 10000 }); authClient.on('error', (err) => { authClient.unbind(); console.error('[LDAP] Bind client error:', err); callback(err, false); }); authClient.bind(userDN, password, (err) => { authClient.unbind(); if (err) { const errorMsg = err.message || String(err); console.error('[LDAP] Bind failed:', errorMsg); console.error('[LDAP] Bind error details:', 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 console.log('[LDAP] Bind successful!'); console.log('[LDAP] Returning canonicalUsername:', canonicalUsername); console.log('========== LDAP AUTHENTICATE - SUCCESS ==========\n'); 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;