/** * LDAP-Anmeldung analog zu Stundenerfassung (services/ldap-service.js): * Service-Bind, Benutzersuche (exakt + Varianten ß/ss), Bind mit Nutzerpasswort. */ import ldap from 'ldapjs'; function escapeLDAPFilter(value) { if (!value) return ''; return String(value) .replace(/\\/g, '\\5c') .replace(/\*/g, '\\2a') .replace(/\(/g, '\\28') .replace(/\)/g, '\\29') .replace(/\0/g, '\\00'); } function getUsernameSearchVariants(usernameStr) { const variants = new Set(); variants.add(usernameStr); variants.add(usernameStr.toLowerCase()); variants.add(usernameStr.replace(/\u00df/g, 'ss')); variants.add(usernameStr.replace(/ss/g, '\u00df')); return [...variants]; } function unescapeLdapDN(dn) { if (!dn || typeof dn !== 'string') return dn; let result = ''; const bytes = []; let i = 0; while (i < dn.length) { if (dn[i] === '\\' && i + 2 < dn.length && /^[0-9a-fA-F]{2}$/.test(dn.slice(i + 1, i + 3))) { bytes.push(parseInt(dn.slice(i + 1, i + 3), 16)); i += 3; } else { if (bytes.length > 0) { result += Buffer.from(bytes).toString('utf8'); bytes.length = 0; } result += dn[i]; i++; } } if (bytes.length > 0) { result += Buffer.from(bytes).toString('utf8'); } return result; } function getAttributeValue(entry, attributeName) { const want = String(attributeName || '').toLowerCase(); for (const attr of entry.attributes || []) { if (attr.type.toLowerCase() === want) { const value = Array.isArray(attr.values) ? attr.values[0] : attr.values; return value != null ? String(value) : null; } } return null; } function entryUserDn(entry) { try { if (entry.dn != null) return entry.dn.toString(); if (entry.objectName != null) return entry.objectName.toString(); } catch { /* ignore */ } return ''; } function bindAsync(client, dn, password) { return new Promise((resolve, reject) => { client.bind(dn || '', password ?? '', (err) => (err ? reject(err) : resolve())); }); } function unbindAsync(client) { return new Promise((resolve) => { try { client.unbind(() => resolve()); } catch { resolve(); } }); } function searchWithEvents(client, baseDN, options) { return new Promise((resolve, reject) => { client.search(baseDN, options, (err, res) => { if (err) return reject(err); const entries = []; res.on('searchEntry', (entry) => entries.push(entry)); res.on('error', reject); res.on('end', (result) => { if (result && result.status !== 0) { return reject(new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`)); } resolve(entries); }); }); }); } /** * @param {string} usernameInput Roher Benutzername (Trim, kein toLowerCase — für UTF-8/AD) * @param {string} password * @param {() => object} loadIntegrations * @returns {Promise<{ ok: true, canonicalUsername: string } | { ok: false, skipLdap?: true, ldapAttempted?: true, message?: string }>} */ export async function authenticateLdap(usernameInput, password, loadIntegrations) { const usernameStr = String(usernameInput || '').trim(); if (!usernameStr) { return { ok: false, ldapAttempted: true, message: 'Benutzername darf nicht leer sein', }; } const raw = loadIntegrations().ldap || {}; if (!raw.syncEnabled) { return { ok: false, skipLdap: true }; } const url = String(raw.serverUrl || '').trim(); const baseDN = String(raw.searchBase || '').trim(); if (!url || !baseDN) { return { ok: false, skipLdap: true }; } const usernameAttr = String(raw.usernameAttribute || 'sAMAccountName').trim(); const client = ldap.createClient({ url, timeout: 10000, connectTimeout: 10000, reconnect: false, }); client.on('error', () => {}); try { await bindAsync( client, String(raw.bindDn || '').trim(), raw.bindPassword != null ? String(raw.bindPassword) : '', ); } catch (e) { await unbindAsync(client); return { ok: false, ldapAttempted: true, message: e && e.message ? String(e.message) : String(e), }; } const exactFilter = `(${usernameAttr}=${escapeLDAPFilter(usernameStr)})`; let userDN = null; let canonicalUsername = null; const applyEntries = (entries, fallbackName) => { for (const entry of entries) { const dn = entryUserDn(entry); if (dn) { userDN = dn; canonicalUsername = getAttributeValue(entry, usernameAttr) || fallbackName; } } }; const runVariantSearch = async () => { userDN = null; canonicalUsername = null; const variants = getUsernameSearchVariants(usernameStr); const filterParts = variants.map( (v) => `(${usernameAttr}=${escapeLDAPFilter(v)})`, ); const searchFilter = filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`; const entries = await searchWithEvents(client, baseDN, { filter: searchFilter, scope: 'sub', attributes: ['dn', usernameAttr], }); applyEntries(entries, usernameStr); }; try { const entries = await searchWithEvents(client, baseDN, { filter: exactFilter, scope: 'sub', attributes: ['dn', usernameAttr], }); applyEntries(entries, usernameStr); } catch { try { await runVariantSearch(); } catch (e) { await unbindAsync(client); return { ok: false, ldapAttempted: true, message: e && e.message ? String(e.message) : String(e), }; } } if (!userDN) { try { await runVariantSearch(); } catch (e) { await unbindAsync(client); return { ok: false, ldapAttempted: true, message: e && e.message ? String(e.message) : String(e), }; } } if (!userDN) { await unbindAsync(client); return { ok: false, ldapAttempted: true, message: `Benutzer „${usernameStr}“ nicht gefunden. Prüfen Sie den Benutzernamen (z. B. UTF-8-Zeichen).`, }; } await unbindAsync(client); const bindDN = unescapeLdapDN(userDN); const authClient = ldap.createClient({ url, timeout: 10000, connectTimeout: 10000, reconnect: false, }); try { await new Promise((resolve, reject) => { authClient.on('error', (err) => { try { authClient.unbind(); } catch { /* ignore */ } reject(err); }); authClient.bind(bindDN, password, (err) => { if (err) { const errorMsg = err.message || String(err); try { authClient.unbind(); } catch { /* ignore */ } return reject( new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`), ); } authClient.unbind(() => resolve()); }); }); } catch (e) { return { ok: false, ldapAttempted: true, message: e && e.message ? String(e.message) : String(e), }; } return { ok: true, canonicalUsername: canonicalUsername || usernameStr, }; }