const { Client } = require('ldapts'); /** * LDAP / Active Directory Sync Module (ldapts) * * Reads users from LDAP/AD and syncs them into the local SQLite database. * AD users are identified by source='ad' and cannot be edited/deleted locally. * * Punkt 1: Migrated from ldapjs to ldapts * Punkt 7: Proper client cleanup with try/finally * * ENV variables: * LDAP_SERVER - e.g. pidc02.seatle.intra * LDAP_PORT - e.g. 389 (LDAP) or 636 (LDAPS), default: 389 * LDAP_SEARCH_BASE - e.g. DC=SEATLE,DC=INTRA * LDAP_DOMAIN - e.g. SEATLE (used for reference) * LDAP_IGNORE_CERT_ERRORS- true/false (default: false) * LDAP_BIND_USER - Service account in user@domain.fqdn format * LDAP_BIND_PASSWORD - Password for the service account * LDAP_SYNC_INTERVAL - Sync interval in ms (default: 300000 = 5 min) * LDAP_FILTER - Custom LDAP filter (default: active users) * LDAP_ATTRIBUTES - Comma-separated LDAP attributes */ const LDAP_SERVER = process.env.LDAP_SERVER || ''; const LDAP_PORT = parseInt(process.env.LDAP_PORT) || 389; const LDAP_SEARCH_BASE = process.env.LDAP_SEARCH_BASE || ''; const LDAP_DOMAIN = process.env.LDAP_DOMAIN || ''; const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false').toLowerCase() === 'true'; const LDAP_BIND_USER = process.env.LDAP_BIND_USER || ''; const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || ''; const LDAP_SYNC_INTERVAL = parseInt(process.env.LDAP_SYNC_INTERVAL) || 300000; const LDAP_FILTER = process.env.LDAP_FILTER || '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'; const LDAP_ATTRIBUTES = (process.env.LDAP_ATTRIBUTES || 'mail,displayName,memberOf,distinguishedName,sAMAccountName').split(',').map(a => a.trim()); let syncTimer = null; let isSyncing = false; // Punkt 9: Sync lock to prevent concurrent syncs function isLDAPConfigured() { return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD); } function extractRole(memberOf) { if (!memberOf) return 'user'; const groups = Array.isArray(memberOf) ? memberOf : [memberOf]; const groupStrings = groups.map(g => String(g).toLowerCase()); if (groupStrings.some(g => g.includes('admin') || g.includes('domain admins') || g.includes('domänen-admins'))) { return 'admin'; } return 'user'; } async function syncLDAPUsers(db) { if (!isLDAPConfigured()) { console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.'); return; } // Punkt 9: Prevent concurrent sync runs if (isSyncing) { console.log('[LDAP] Sync bereits aktiv - übersprungen.'); return; } isSyncing = true; const useTLS = LDAP_PORT === 636; const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`; console.log('[LDAP] Starte Synchronisation mit', url); const client = new Client({ url, tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined, connectTimeout: 10000, }); try { await client.bind(LDAP_BIND_USER, LDAP_BIND_PASSWORD); console.log('[LDAP] Bind erfolgreich, suche Nutzer...'); const { searchEntries } = await client.search(LDAP_SEARCH_BASE, { filter: LDAP_FILTER, scope: 'sub', attributes: LDAP_ATTRIBUTES, }); const adUsers = []; for (const entry of searchEntries) { // ldapts may return attributes as arrays; normalize to single values const rawMail = Array.isArray(entry.mail) ? entry.mail[0] : entry.mail; const rawName = Array.isArray(entry.displayName) ? entry.displayName[0] : entry.displayName; const rawCn = Array.isArray(entry.cn) ? entry.cn[0] : entry.cn; const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName; const rawSAM = Array.isArray(entry.sAMAccountName) ? entry.sAMAccountName[0] : entry.sAMAccountName; const rawMemberOf = Array.isArray(entry.memberOf) ? entry.memberOf : (entry.memberOf ? [entry.memberOf] : []); const email = (rawMail || '').toLowerCase().trim(); const name = rawName || rawCn || ''; const distinguishedName = rawDN || ''; const memberOf = rawMemberOf; const username = (rawSAM || '').trim(); if (!email && !username) continue; // Skip users without email AND username adUsers.push({ email: email || (username + '@ad.local'), name, role: extractRole(memberOf), distinguishedName, username, }); } console.log('[LDAP] Gefunden:', adUsers.length, 'Nutzer'); // Sync LDAP users into database (async for PostgreSQL compatibility) const existingRows = await db.prepare('SELECT id, email, name, role, status FROM users WHERE source = \'ad\'').all(); const existingMap = {}; existingRows.forEach(row => { existingMap[row.email.toLowerCase()] = row; }); let inserted = 0; let updated = 0; const insertStmt = db.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, ?, \'inaktiv\', \'ad\', ?)'); const updateStmt = db.prepare('UPDATE users SET name = ?, username = ?, role = ? WHERE id = ?'); const syncTransaction = db.transaction(async () => { for (const adUser of adUsers) { const existing = existingMap[adUser.email]; if (existing) { await updateStmt.run(adUser.name, adUser.username, adUser.role, existing.id); updated++; delete existingMap[adUser.email]; } else { try { await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.role, adUser.username); inserted++; } catch (err) { if (err.message && err.message.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) { console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email); } else { console.error('[LDAP] Insert-Fehler:', err.message); } } } } }); await syncTransaction(); // Remove stale AD users const adEmails = adUsers.map(u => u.email.toLowerCase()); const toRemove = existingRows.filter(r => !adEmails.includes(r.email.toLowerCase())); let removed = 0; if (toRemove.length > 0) { const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id)); if (removeIds.length > 0) { const placeholders = removeIds.map(() => '?').join(','); await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds); removed = removeIds.length; } } console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' entfernt'); } catch (err) { console.error('[LDAP] Sync-Fehler:', err.message); } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup isSyncing = false; // Punkt 9: Release sync lock } } function startLDAPSync(db) { if (!isLDAPConfigured()) { console.log('[LDAP] LDAP-Sync nicht konfiguriert. Setze LDAP_SERVER, LDAP_SEARCH_BASE, LDAP_BIND_USER und LDAP_BIND_PASSWORD Umgebungsvariablen.'); return; } // Initial sync syncLDAPUsers(db); // Periodic sync if (syncTimer) clearInterval(syncTimer); syncTimer = setInterval(() => { syncLDAPUsers(db); }, LDAP_SYNC_INTERVAL); console.log('[LDAP] Automatischer Sync alle ' + (LDAP_SYNC_INTERVAL / 1000) + ' Sekunden aktiviert.'); } function stopLDAPSync() { if (syncTimer) { clearInterval(syncTimer); syncTimer = null; console.log('[LDAP] Sync gestoppt.'); } } /** * Authenticate a user against LDAP/Active Directory. * Uses the sAMAccountName (username) to bind to the LDAP server. * Punkt 7: Proper client cleanup with try/finally */ async function authenticateLDAP(username, password) { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } // VULN-11: LDAP Injection prevention - validate username const safeUsername = String(username || '').replace(/[*()\\\x00]/g, '').trim(); if (!safeUsername || !/^[a-zA-Z0-9._-]+$/.test(safeUsername)) { throw new Error('Ungueltiger Anmeldename.'); } const useTLS = LDAP_PORT === 636; const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`; // Build the bind DN: username@domain.fqdn (UPN format) const bindDomain = LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN; const bindDN = safeUsername + '@' + bindDomain; const client = new Client({ url, tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined, connectTimeout: 10000, }); try { await client.bind(bindDN, password); console.log('[LDAP] Authentifizierung erfolgreich für', bindDN); return { username: username, bindDN: bindDN }; } catch (err) { console.log('[LDAP] Authentifizierung fehlgeschlagen für', bindDN, ':', err.message); throw new Error('Ungueltige Anmeldedaten.'); } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup } } module.exports = { isLDAPConfigured, syncLDAPUsers, startLDAPSync, stopLDAPSync, authenticateLDAP };