const { Client, Attribute, Change } = require('ldapts'); const { transliterateUmlauts, toSamAccountName } = require('./umlauts'); /** * LDAP Operations Module (ldapts) * * Provides functions for browsing the AD tree and creating users in Active Directory. * Uses ldapts (maintained) instead of deprecated ldapjs. * Punkt 1: Migrated from ldapjs to ldapts * Punkt 7: Proper client cleanup with try/finally in all functions */ 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_CREATE_OU = process.env.LDAP_CREATE_OU || ''; const LDAP_UPN_SUFFIX = process.env.LDAP_UPN_SUFFIX || ''; function isLDAPConfigured() { return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD); } /** * Normalize ldapts attribute values. * ldapts may return attributes as arrays; this helper extracts single values. */ function attr(entry, key) { const val = entry[key]; if (Array.isArray(val)) return val[0] || ''; if (val !== undefined && val !== null) return val; return ''; } function attrArray(entry, key) { const val = entry[key]; if (Array.isArray(val)) return val; if (val !== undefined && val !== null) return [val]; return []; } /** * Create and bind an LDAP client using ldapts. * Punkt 7: Returns a bound client; caller must call client.unbind() in finally block. */ async function createClient() { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } const useTLS = LDAP_PORT === 636; const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`; 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); return client; } catch (err) { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup on bind failure throw new Error('LDAP Bind fehlgeschlagen: ' + (err.message || err)); } } /** * Browse the AD tree and return OUs under the configured base or a given path. * Returns a hierarchical tree structure. */ async function browseOUTree(searchBase) { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } // H1: Validate searchBase - must be a DN under the configured LDAP_SEARCH_BASE // (prevents arbitrary LDAP tree browsing outside the allowed scope) const base = searchBase || LDAP_SEARCH_BASE; if (base !== LDAP_SEARCH_BASE) { const escapeDNRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const basePattern = new RegExp(',' + escapeDNRegex(LDAP_SEARCH_BASE) + '$', 'i'); if (!basePattern.test(base)) { throw new Error('Ungültige Suchbasis: muss unterhalb von ' + LDAP_SEARCH_BASE + ' liegen.'); } } const client = await createClient(); try { const { searchEntries } = await client.search(base, { filter: '(objectClass=organizationalUnit)', scope: 'one', attributes: ['distinguishedName', 'name'], sizeLimit: 500, }); const ous = searchEntries.map(entry => ({ dn: attr(entry, 'distinguishedName') || '', name: attr(entry, 'name') || '', })); // Recursively fetch children for each OU const results = []; for (const ou of ous) { let children = []; try { children = await browseOUTree(ou.dn); } catch (e) { // Ignore errors for individual OU children } results.push({ dn: ou.dn, name: ou.name, children: children, }); } return results; } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup } } /** * Escape special characters in LDAP distinguished names. */ function escapeLDAPDN(str) { return str.replace(/[,+"\\<>;]/g, '\\$&'); } /** * Replace German umlauts and ß for sAMAccountName compatibility. * (Delegiert an zentrale umlauts.js — Logik dort gepflegt.) */ function replaceUmlauts(str) { return transliterateUmlauts(str); } /** * Create a user in Active Directory. * Punkt 7: Proper client cleanup with try/finally */ async function createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c }) { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } if (!ou || !username || !password) { throw new Error('OU, Anmeldename und Passwort sind erforderlich.'); } if (!vorname || !nachname) { throw new Error('Vorname und Nachname sind erforderlich, um einen AD-Benutzer anzulegen.'); } const client = await createClient(); try { // CN format: Nachname, Vorname (as per AD convention) const cnValue = nachname + ', ' + vorname; const escapedCN = escapeLDAPDN(cnValue); const dn = 'CN=' + escapedCN + ',' + ou; // Build UPN — FIX: Username umlautfrei normalisieren (ä→ae etc.), // damit kein ungültiger UPN wie müller@... entsteht. const upnSuffix = LDAP_UPN_SUFFIX || LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN.toLowerCase() + '.intra'; const normalizedUsername = toSamAccountName(username) || 'user' + Date.now().toString(36); const userPrincipalName = normalizedUsername + '@' + upnSuffix; // sAMAccountName: max 20 chars let sAMAccountName = toSamAccountName(username); if (vorname && nachname) { sAMAccountName = toSamAccountName(nachname + vorname.charAt(0)); } sAMAccountName = sAMAccountName.substring(0, 20); // Fallback: Name nur aus Sonderzeichen → generischer Name (AD lehnt leeren sAMAccountName ab) if (!sAMAccountName) { sAMAccountName = ('user' + Date.now().toString(36)).substring(0, 20); console.warn('[LDAP] sAMAccountName war nach Normalisierung leer — Fallback:', sAMAccountName); } // userAccountControl: 514 = NORMAL_ACCOUNT + ACCOUNTDISABLE // ldapts requires attribute values as strings (numbers cause "The string argument must be of type string" error) const userAccountControl = '514'; const effectiveDisplayName = displayName || (nachname + ', ' + vorname); const entry = { objectClass: ['top', 'person', 'organizationalPerson', 'user'], cn: cnValue, sn: nachname, givenName: vorname, displayName: effectiveDisplayName, sAMAccountName: sAMAccountName, userPrincipalName: userPrincipalName, userAccountControl: userAccountControl, }; if (email) entry.mail = email; if (department) entry.department = department; if (telefon) entry.telephoneNumber = telefon; if (titel) entry.title = titel; if (physicalDeliveryOfficeName) entry.physicalDeliveryOfficeName = physicalDeliveryOfficeName; if (company) entry.company = company; if (description) entry.description = description; if (wWWHomePage) entry.wWWHomePage = wWWHomePage; if (streetAddress) entry.streetAddress = streetAddress; if (postOfficeBox) entry.postOfficeBox = postOfficeBox; if (l) entry.l = l; if (st) entry.st = st; if (postalCode) entry.postalCode = postalCode; // c (country) must be a 2-letter ISO-3166 code if (c) { const countryCode = String(c).trim().toUpperCase().substring(0, 2); if (countryCode.length === 2 && /^[A-Z]{2}$/.test(countryCode)) { entry.c = countryCode; } } // Step 1: Create user as DISABLED try { await client.add(dn, entry); } catch (err) { if (err.message && err.message.includes('ENTRY_ALREADY_EXISTS')) { throw new Error('Ein Benutzer mit diesem Namen existiert bereits an dieser Stelle im AD.'); } if (err.message && err.message.includes('Constraint Violation')) { console.error('[LDAP] Constraint Violation:', err.message, 'Entry:', JSON.stringify(entry, null, 2)); throw new Error('Constraint Violation: Ein Pflichtfeld fehlt oder enthält einen ungültigen Wert. Bitte Vorname, Nachname und Anmeldename prüfen. Das Land-Feld (c) muss ein 2-Buchstaben-Code sein (z.B. DE).'); } throw new Error('Fehler beim Erstellen: ' + (err.message || err)); } console.log('[LDAP] Benutzer erstellt (deaktiviert):', dn); // Step 2: Set the password const unicodePwd = Buffer.from('"' + password + '"', 'utf16le'); try { await client.modify(dn, [ new Change({ operation: 'replace', modification: new Attribute({ type: 'unicodePwd', values: [unicodePwd], }), }), ]); } catch (pwdErr) { console.warn('[LDAP] Passwort konnte nicht gesetzt werden (Benutzer wurde deaktiviert erstellt):', pwdErr.message); return { dn: dn, username: username, warning: 'Benutzer erstellt (deaktiviert), aber Passwort konnte nicht gesetzt werden: ' + pwdErr.message, }; } console.log('[LDAP] Passwort gesetzt für:', dn); // Step 3: Enable the account (userAccountControl: 512 = NORMAL_ACCOUNT, enabled) // ldapts requires attribute values as strings try { await client.modify(dn, [ new Change({ operation: 'replace', modification: new Attribute({ type: 'userAccountControl', values: ['512'], }), }), ]); } catch (enableErr) { console.warn('[LDAP] Konto konnte nicht aktiviert werden (Benutzer wurde mit Passwort erstellt):', enableErr.message); return { dn: dn, username: username, warning: 'Benutzer erstellt und Passwort gesetzt, aber Konto konnte nicht aktiviert werden: ' + enableErr.message, }; } console.log('[LDAP] Konto aktiviert für:', dn); // P2: Clear plaintext password from memory after use password = null; return { dn: dn, username: username }; } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup } } /** * Check if a user exists in AD by sAMAccountName. * Punkt 7: Proper client cleanup */ async function checkADUserExists(username, sAMAccountName) { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } const client = await createClient(); try { // P1: LDAP-Injection prevention - sanitize username and samName before building filter const escapeLDAPFilter = (str) => String(str || '').replace(/[*()\\\x00]/g, '\\$&'); const safeSamName = escapeLDAPFilter(sAMAccountName || username); const safeUsername = escapeLDAPFilter(username); const { searchEntries } = await client.search(LDAP_SEARCH_BASE, { filter: '(|(sAMAccountName=' + safeSamName + ')(userPrincipalName=' + safeUsername + '@*))', scope: 'sub', attributes: ['distinguishedName', 'sAMAccountName', 'displayName', 'userPrincipalName'], sizeLimit: 100, }); if (searchEntries.length > 0) { const entry = searchEntries[0]; return { distinguishedName: attr(entry, 'distinguishedName') || '', sAMAccountName: attr(entry, 'sAMAccountName') || '', displayName: attr(entry, 'displayName') || '', userPrincipalName: attr(entry, 'userPrincipalName') || '', }; } return null; } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup } } /** * Delete a user from Active Directory by DN. * Punkt 7: Proper client cleanup */ async function deleteADUser(dn) { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } const client = await createClient(); try { await client.del(dn); console.log('[LDAP] Benutzer gelöscht (Rollback):', dn); } catch (err) { console.error('[LDAP] Fehler beim Löschen des Benutzers (Rollback):', err.message); throw err; } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup } } /** * Search for AD groups/security principals matching a query. * Returns all groups (no GRP_ filter - used for security group search). * Punkt 7: Proper client cleanup */ async function searchADGroups(query) { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } // H2: Validate query - length limit + only safe characters (prevents LDAP injection & DoS) const safeQuery = String(query || '').trim(); if (!safeQuery || safeQuery.length < 2 || safeQuery.length > 100) { return []; } if (!/^[a-zA-Z0-9äöüÄÖÜß._\- ]+$/.test(safeQuery)) { return []; } const client = await createClient(); try { const escapedQuery = safeQuery.replace(/[()*\\]/g, '\\$&'); const { searchEntries } = await client.search(LDAP_SEARCH_BASE, { filter: `(&(objectClass=group)(|(cn=*${escapedQuery}*)(displayName=*${escapedQuery}*)(sAMAccountName=*${escapedQuery}*)))`, scope: 'sub', attributes: ['distinguishedName', 'cn', 'displayName', 'sAMAccountName', 'description', 'groupType'], sizeLimit: 100, }); return searchEntries.map(entry => ({ dn: attr(entry, 'distinguishedName') || '', cn: attr(entry, 'cn') || '', displayName: attr(entry, 'displayName') || attr(entry, 'cn') || '', sAMAccountName: attr(entry, 'sAMAccountName') || '', description: attr(entry, 'description') || '', })); } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup } } /** * Add a user to one or more AD groups. * Punkt 7: Proper client cleanup */ async function addUserToGroups(userDN, groupDNs) { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } const client = await createClient(); const results = []; try { for (const groupDN of groupDNs) { try { await client.modify(groupDN, [ new Change({ operation: 'add', modification: new Attribute({ type: 'member', values: [userDN], }), }), ]); results.push({ dn: groupDN, status: 'added' }); } catch (err) { if (err.message && err.message.includes('already exists')) { results.push({ dn: groupDN, status: 'already_member' }); } else { results.push({ dn: groupDN, status: 'error', error: err.message }); } } } } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup } return results; } /** * Browse all AD groups under the configured search base. * Returns only GRP_ groups for static display. * Punkt 7: Proper client cleanup */ async function browseADGroups() { if (!isLDAPConfigured()) { throw new Error('LDAP nicht konfiguriert.'); } const client = await createClient(); try { const { searchEntries } = await client.search(LDAP_SEARCH_BASE, { filter: '(&(objectClass=group)(cn=GRP_*))', scope: 'sub', attributes: ['distinguishedName', 'cn', 'displayName', 'sAMAccountName', 'description', 'memberOf'], sizeLimit: 500, }); const groups = searchEntries.map(entry => ({ dn: attr(entry, 'distinguishedName') || '', cn: attr(entry, 'cn') || '', displayName: attr(entry, 'displayName') || attr(entry, 'cn') || '', sAMAccountName: attr(entry, 'sAMAccountName') || '', description: attr(entry, 'description') || '', })); // Sort groups by displayName/cn for easier browsing groups.sort((a, b) => (a.displayName || a.cn).localeCompare(b.displayName || b.cn)); return groups; } finally { await client.unbind().catch(() => {}); // Punkt 7: Always cleanup } } module.exports = { isLDAPConfigured, browseOUTree, createADUser, checkADUserExists, deleteADUser, searchADGroups, addUserToGroups, browseADGroups };