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 user accounts only — * excludes computers, service accounts (sa_*), trust accounts * and the built-in Administrator/Gast) * LDAP_ATTRIBUTES - Comma-separated LDAP attributes * * Rollen (Fix 2, überarbeitet): Rollen werden AUSSCHLIESSLICH über die App * verwaltet (Nutzerverwaltung). Der Sync legt neue User immer als 'user' an * und ändert die Rolle bestehender User NIE — AD-Gruppen fließen nicht in * App-Rechte ein. */ 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; // Fix 1: Standard-Filter nur echte Benutzerkonten — keine Computer ($), keine // Service-Accounts (sa_*), keine Trust-Accounts, nicht deaktivierte Konten. // 1.2.840.113556.1.4.803 = LDAP_MATCHING_RULE_BIT_AND (userAccountControl-Bits) // Bit 2 = ACCOUNTDISABLE, Bit 512 = NORMAL_ACCOUNT (nur echte Benutzerkonten // haben dieses Bit; Computer = 4096, Domain-Controller = 8192 haben es NICHT). const LDAP_FILTER = process.env.LDAP_FILTER || '(&(objectClass=user)(sAMAccountName=*)(!(sAMAccountName=*$))(!(sAMAccountName=sa_*))' + '(!(sAMAccountName=Administrator))(!(sAMAccountName=Gast))' + '(!(userAccountControl:1.2.840.113556.1.4.803:=2))' + '(userAccountControl:1.2.840.113556.1.4.803:=512))'; 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); } 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 email = (rawMail || '').toLowerCase().trim(); const name = rawName || rawCn || ''; const distinguishedName = rawDN || ''; const username = (rawSAM || '').trim(); if (!email && !username) continue; // Skip users without email AND username adUsers.push({ email: email || (username + '@ad.local'), name, 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; // H2: Statements innerhalb der Transaktion über txDb erzeugen. // WICHTIG (PostgreSQL): Ein UNIQUE-Verstoß bricht die GESAMTE Transaktion // ab ("current transaction is aborted") — ein try/catch um den Insert // hilft dort nicht. Daher werden Kollisionen VOR dem Insert per SELECT // erkannt (ON CONFLICT wäre die Alternative, ist aber im gemeinsamen // SQLite/PG-SQL-Dialekt nicht portabel). const syncTransaction = db.transaction(async function () { const txDb = this; // Rollen werden app-seitig verwaltet: neue User immer 'user', bestehende // User behalten ihre in der App gesetzte Rolle (Sync fasst role nie an). const insertStmt = txDb.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'ad\', ?)'); const updateStmt = txDb.prepare('UPDATE users SET name = ?, username = ? WHERE id = ?'); const emailExistsStmt = txDb.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)'); const usernameExistsStmt = txDb.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)'); for (const adUser of adUsers) { const existing = existingMap[adUser.email]; if (existing) { await updateStmt.run(adUser.name, adUser.username, existing.id); updated++; delete existingMap[adUser.email]; } else { // Kollisions-Precheck: E-Mail oder Username bereits belegt? const emailTaken = await emailExistsStmt.get(adUser.email); if (emailTaken) { console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email); continue; } const usernameTaken = adUser.username ? await usernameExistsStmt.get(adUser.username) : null; if (usernameTaken) { console.warn('[LDAP] Username bereits vorhanden:', adUser.username); continue; } try { await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.username); inserted++; } catch (err) { // Fallback für Race-Conditions zwischen Precheck und Insert if (err.message?.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) { console.warn('[LDAP] E-Mail bereits vorhanden (Race):', 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())); // H3: Mass-deletion protection — a single failed/empty LDAP result must not // wipe the entire AD user base (and their tasks via ON DELETE CASCADE). // Abort the sync if we would remove more than LDAP_MAX_DELETE_PCT (default 25%) // of the currently known AD users, or if the LDAP search returned zero entries. const LDAP_MAX_DELETE_PCT = parseInt(process.env.LDAP_MAX_DELETE_PCT) || 25; let removed = 0; if (toRemove.length > 0) { if (adUsers.length === 0) { console.warn('[LDAP] Sync lieferte 0 Nutzer — Löschen abgebrochen (Schutz gegen Massenlöschung).'); } else { const deleteRatio = (toRemove.length / existingRows.length) * 100; if (deleteRatio > LDAP_MAX_DELETE_PCT) { console.warn(`[LDAP] ${toRemove.length} von ${existingRows.length} AD-Nutzern würden gelöscht werden (${deleteRatio.toFixed(1)}% > ${LDAP_MAX_DELETE_PCT}% Schwellwert) — Löschen abgebrochen.`); } else { 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'); // Fix 1 (Cleanup): Bereits kontaminierte Konten entfernen — Computer-Accounts // ($-Suffix), Service-Accounts (sa_*) und der eingebaute Administrator/Gast. // Diese sollten laut geschärftem Filter nie mehr gesynct werden; der Cleanup // räumt Bestände auf, die vor dem Fix angelegt wurden. Läuft nur, wenn der // Standard-Filter aktiv ist (bei Custom-Filter entscheidet der Betreiber). const isDefaultFilter = !process.env.LDAP_FILTER; if (isDefaultFilter) { const junkRows = await db.prepare( `SELECT id FROM users WHERE source = 'ad' AND (username LIKE '%$' OR username LIKE 'sa\\_%' ESCAPE '\\' OR LOWER(username) IN ('administrator', 'gast'))` ).all(); const cleanupIds = junkRows.map(r => r.id).filter(id => Number.isInteger(id)); if (cleanupIds.length > 0) { const placeholders = cleanupIds.map(() => '?').join(','); const del = await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...cleanupIds); console.log('[LDAP] Cleanup: ' + del.changes + ' Computer-/Service-Accounts 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 };