'use strict'; const fs = require('fs'); require('./patch-ldap-dn'); const ldap = require('ldapjs'); const config = require('./config'); const passwordSetter = require('./passwordSetter'); function urlScheme(url) { const match = /^([a-z0-9+.-]+):\/\//i.exec(String(url || '')); return match ? match[1].toLowerCase() : ''; } class LdapError extends Error { constructor(message, code, status) { super(message); this.name = 'LdapError'; this.ldapCode = code; this.status = status || 500; } } function mapLdapError(err) { if (!err) return err; const code = err.code; switch (code) { case 49: return new LdapError('Неверный логин или пароль', code, 401); case 68: return new LdapError('Запись уже существует', code, 409); case 32: return new LdapError('Объект не найден', code, 404); case 50: return new LdapError('Недостаточно прав для выполнения операции', code, 403); case 53: return new LdapError('Операция запрещена сервером', code, 500); case 19: return new LdapError('Нарушение ограничений (например, слишком короткий пароль)', code, 400); case 21: return new LdapError('Недопустимое значение атрибута (проверьте ФИО, телефон, почту)', code, 400); case 14: return new LdapError('Операция недоступна', code, 500); case 52: return new LdapError('Сервер недоступен', code, 500); case 80: return new LdapError('Не удалось подключиться к LDAP-серверу', code, 502); default: return new LdapError(err.message || 'Ошибка LDAP', code, 500); } } function unescapeDN(value) { return String(value).replace(/\\[0-9a-fA-F]{2}/g, (m) => String.fromCharCode(parseInt(m.slice(1), 16))); } function getRDNName(dn) { if (!dn) return ''; const first = dn.split(',').shift(); if (!first) return ''; const eq = first.indexOf('='); return eq === -1 ? unescapeDN(first) : unescapeDN(first.slice(eq + 1)); } function getCN(dn) { const name = getRDNName(dn); return name; } function escapeDNValue(value) { return String(value).replace(/([,\\#+<>;"=])/g, '\\$1'); } function escapeFilter(value) { return String(value).replace(/[\\*()\0]/g, (ch) => '\\' + ch.charCodeAt(0).toString(16).padStart(2, '0')); } function buildBindNames(login) { const names = []; const netbios = config.domain.split('.')[0].toUpperCase(); if (login.includes('@') || login.startsWith('CN=') || login.startsWith('OU=') || login.includes(',')) { names.push(login); } else { names.push(`${login}@${config.domain}`); names.push(`${netbios}\\${login}`); names.push(login); } return names; } async function connect() { const scheme = urlScheme(config.ldapUrl); const clientOpts = { url: config.ldapUrl, connectTimeout: 10000, timeout: 15000, reconnect: false }; if (scheme === 'ldaps' || scheme === 'ldap') { const tlsOptions = { rejectUnauthorized: config.tlsRejectUnauthorized }; if (config.caFile) { try { tlsOptions.ca = [fs.readFileSync(config.caFile)]; } catch (err) { throw new LdapError(`Не удалось прочитать файл CA: ${config.caFile}: ${err.message}`, 0, 502); } } clientOpts.tlsOptions = tlsOptions; } const client = ldap.createClient(clientOpts); await new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; reject(new LdapError('Не удалось подключиться к LDAP-серверу (таймаут)', 0, 502)); try { client.destroy(); } catch (e) {} }, 15000); client.once('connect', () => { if (settled) return; settled = true; clearTimeout(timer); resolve(); }); client.on('error', (err) => { if (settled) return; settled = true; clearTimeout(timer); reject(new LdapError(`Ошибка подключения к LDAP-серверу: ${err.message || err.code}`, 0, 502)); try { client.destroy(); } catch (e) {} }); }); client.on('error', () => {}); if (config.encryptTraffic && scheme === 'ldap') { try { await new Promise((resolve, reject) => { client.starttls({}, null, (err) => (err ? reject(err) : resolve())); }); } catch (err) { if (err && err.code === 52) { throw new LdapError( 'StartTLS отклонён сервером (код 52): на контроллере домена не настроен LDAPS/TLS. ' + 'Включите LDAPS на DC или установите ENCRYPT_TRAFFIC=false', 0, 502 ); } throw new LdapError(`StartTLS не удался (проверьте LDAP_URL или ENCRYPT_TRAFFIC): ${err.message || err.code}`, 0, 502); } } return client; } async function bind(client, login, password) { const names = buildBindNames(login); let lastErr = null; for (const name of names) { try { await new Promise((resolve, reject) => { client.bind(name, password, (err) => (err ? reject(err) : resolve())); }); return name; } catch (err) { lastErr = err; } } throw mapLdapError(lastErr); } async function close(client) { return new Promise((resolve) => { if (!client || client.destroyed) return resolve(); try { client.unbind((err) => { try { client.destroy(); } catch (e) {} resolve(); }); } catch (err) { try { client.destroy(); } catch (e) {} resolve(); } }); } function searchEntries(client, base, options) { return new Promise((resolve, reject) => { const entries = []; client.search(base, options, (err, res) => { if (err) return reject(mapLdapError(err)); res.on('searchEntry', (entry) => entries.push(entry)); res.on('error', (e) => reject(mapLdapError(e))); res.on('end', (result) => { if (result && result.status !== 0) { return reject(new LdapError(`Ошибка поиска LDAP (статус ${result.status})`, result.status, 500)); } resolve(entries); }); }); }); } function entryToObject(entry) { const obj = {}; const dn = entry.dn ? entry.dn.toString() : String(entry.objectName || ''); if (dn) obj.dn = dn; const alwaysArray = new Set(['memberOf', 'objectClass', 'proxyAddresses']); for (const attr of entry.attributes || []) { const vals = attr.values || []; if (alwaysArray.has(attr.type) || vals.length > 1) { obj[attr.type] = vals; } else { obj[attr.type] = vals[0]; } } return obj; } function addEntry(client, dn, attributes) { return new Promise((resolve, reject) => { const entry = {}; for (const a of attributes || []) { entry[a.attr] = a.vals; } client.add(dn, entry, (err) => (err ? reject(mapLdapError(err)) : resolve())); }); } function normalizeChange(change) { const mod = change.modification || {}; const key = Object.keys(mod)[0]; const values = mod[key]; return { operation: change.operation, modification: { type: key, values: Array.isArray(values) ? values : [values] } }; } function modifyEntry(client, dn, changes) { return new Promise((resolve, reject) => { client.modify(dn, (changes || []).map(normalizeChange), (err) => (err ? reject(mapLdapError(err)) : resolve())); }); } async function setPassword(client, userDN, password) { switch (config.passwordMethod) { case 'powershell': await passwordSetter.setPasswordViaPowerShell(userDN, password, { exe: config.powerShellExe, ldapUrl: config.ldapUrl }); return; case 'kerberos': { const principal = passwordSetter.buildPrincipal(config.adminLogin, config.ldapRealm); await passwordSetter.setPasswordViaKerberos(userDN, password, { ldapUrl: config.ldapUrl, principal, adminPassword: config.adminPassword, retries: config.passwordRetries, retryDelayMs: config.passwordRetryDelayMs }); return; } case 'ldap': default: break; } const value = Buffer.from('"' + password + '"', 'utf16le'); await modifyEntry(client, userDN, [{ operation: 'replace', modification: { unicodePwd: [value] } }]); } function removeEntry(client, dn) { return new Promise((resolve, reject) => { client.del(dn, (err) => (err ? reject(mapLdapError(err)) : resolve())); }); } // ================= Public auth (port of the Go service) ================= async function authenticate(username, password, mode) { if (!username) throw new LdapError('Username is required', 0, 400); if (!password) throw new LdapError('Password is required', 0, 400); const client = await connect(); try { await bind(client, username, password); // Rebind as admin to read user info (like the Go service did with the service account) if (config.adminLogin && config.adminPassword) { try { await bind(client, config.adminLogin, config.adminPassword); } catch (err) { console.warn( `⚠ Административная привязка не удалась (ADMIN_LOGIN/ADMIN_PASSWORD): ${err.message || err.code}. ` + `Проверьте учётные данные в .env (пароли с '#' нужно брать в кавычки). Возвращаются базовые данные.` ); return { success: true, username }; } } return await getUserInfo(client, username, mode); } finally { await close(client); } } async function getUserInfo(client, username, mode) { let attributes = ['dn', 'sAMAccountName']; if (mode !== 'groups' && mode !== 'description') { attributes.push('memberOf', 'mail', 'displayName', 'cn', 'description'); } if (mode === 'description') attributes.push('description'); if (mode === 'groups') attributes.push('memberOf'); if (mode === 'all') attributes = ['*', '+']; const entries = await searchEntries(client, config.baseDN, { scope: 'sub', filter: `(sAMAccountName=${escapeFilter(username)})`, attributes }); if (entries.length === 0) throw new LdapError('User not found', 0, 404); const entry = entryToObject(entries[0]); if (mode === 'description') { return { success: true, description: entry.description || '' }; } if (mode === 'groups') { return { success: true, groups: (entry.memberOf || []).map(getCN).filter(Boolean) }; } const result = { success: true, username: entry.sAMAccountName || username, full_name: entry.displayName || entry.cn || username, email: entry.mail || '', description: entry.description || '', groups: (entry.memberOf || []).map(getCN).filter(Boolean) }; if (mode === 'all') { result.all_info = entry; } return result; } // ================= Admin: listing ================= async function listOUs() { const client = await connect(); try { await bind(client, config.adminLogin, config.adminPassword); const entries = await searchEntries(client, config.baseDN, { scope: 'sub', filter: '(objectClass=organizationalUnit)', attributes: ['dn'] }); return entries.map((e) => { const obj = entryToObject(e); return { dn: obj.dn, name: getRDNName(obj.dn) }; }); } finally { await close(client); } } async function listGroups() { const client = await connect(); try { await bind(client, config.adminLogin, config.adminPassword); const base = config.groupOU || config.baseDN; const entries = await searchEntries(client, base, { scope: 'sub', filter: '(objectClass=group)', attributes: ['dn', 'cn'] }); return entries.map((e) => { const obj = entryToObject(e); return { dn: obj.dn, cn: obj.cn || getCN(obj.dn) }; }); } finally { await close(client); } } function parseUser(entry) { const uac = parseInt(entry.userAccountControl, 10) || 0; return { dn: entry.dn, login: entry.sAMAccountName || '', full_name: entry.displayName || entry.cn || '', given_name: entry.givenName || '', sn: entry.sn || '', middle_name: entry.middleName || '', email: entry.mail || '', phone: entry.telephoneNumber || '', position: entry.description || '', upn: entry.userPrincipalName || '', disabled: (uac & 0x0002) !== 0, groups: (entry.memberOf || []).map(getCN).filter(Boolean), member_of: entry.memberOf || [] }; } async function listUsers() { const client = await connect(); try { await bind(client, config.adminLogin, config.adminPassword); const entries = await searchEntries(client, config.baseDN, { scope: 'sub', filter: '(&(objectClass=user)(objectCategory=person))', attributes: ['dn', 'sAMAccountName', 'displayName', 'cn', 'givenName', 'sn', 'middleName', 'mail', 'telephoneNumber', 'description', 'userPrincipalName', 'userAccountControl', 'memberOf'] }); return entries.map((e) => parseUser(entryToObject(e))); } finally { await close(client); } } async function findUserByLogin(client, login) { const entries = await searchEntries(client, config.baseDN, { scope: 'sub', filter: `(sAMAccountName=${escapeFilter(login)})`, attributes: ['*'] }); if (entries.length === 0) throw new LdapError(`Пользователь "${login}" не найден`, 0, 404); return entryToObject(entries[0]); } async function getUser(login) { const client = await connect(); try { await bind(client, config.adminLogin, config.adminPassword); const entry = await findUserByLogin(client, login); const user = parseUser(entry); user.ou = getParentDN(entry.dn); return user; } finally { await close(client); } } function getParentDN(dn) { const parts = dn.split(','); parts.shift(); return parts.join(','); } async function resolveGroupDNs(client, groupCNs) { const dns = []; for (const cn of groupCNs || []) { const entries = await searchEntries(client, config.groupOU || config.baseDN, { scope: 'sub', filter: `(cn=${escapeFilter(cn)})`, attributes: ['dn', 'primaryGroupToken'] }); if (entries.length > 0) { const obj = entryToObject(entries[0]); dns.push({ dn: obj.dn, primaryGroupToken: obj.primaryGroupToken }); } else { throw new LdapError(`Группа "${cn}" не найдена`, 0, 400); } } return dns; } // ================= Admin: create / update / block ================= async function findFreeCn(client, ouDn, name) { const base = String(name || '').trim(); const entries = await searchEntries(client, ouDn || config.baseDN, { scope: 'one', filter: '(objectClass=*)', attributes: ['cn'] }); const taken = new Set(entries.map((e) => String(entryToObject(e).cn || '').toLowerCase())); let candidate = base; let n = 1; while (taken.has(candidate.toLowerCase())) { n += 1; candidate = `${base} ${n}`; } return candidate; } async function createUser({ ouDn, fio, login, password, email, phone, position, logonScript, homeDrive, homeBasePath, groupCNs }) { const client = await connect(); try { await bind(client, config.adminLogin, config.adminPassword); const existing = await searchEntries(client, config.baseDN, { scope: 'sub', filter: `(sAMAccountName=${escapeFilter(login)})`, attributes: ['dn'] }); if (existing.length > 0) { throw new LdapError(`Логин "${login}" уже занят`, 0, 409); } const upn = `${login}@${config.domain}`; const upnExisting = await searchEntries(client, config.baseDN, { scope: 'sub', filter: `(userPrincipalName=${escapeFilter(upn)})`, attributes: ['dn'] }); if (upnExisting.length > 0) { throw new LdapError(`Логин "${login}" уже занят`, 0, 409); } const cn = escapeDNValue(await findFreeCn(client, ouDn, fio)); const userDN = `CN=${cn},${ouDn || config.baseDN}`; const attributes = [ { attr: 'objectClass', vals: ['top', 'person', 'organizationalPerson', 'user'] }, { attr: 'sAMAccountName', vals: [login] }, { attr: 'userPrincipalName', vals: [`${login}@${config.domain}`] }, { attr: 'displayName', vals: [fio] }, { attr: 'userAccountControl', vals: ['512'] } ]; const nameParts = (fio || '').trim().split(/\s+/); if (nameParts[0]) attributes.push({ attr: 'givenName', vals: [nameParts[0]] }); if (nameParts[1]) attributes.push({ attr: 'sn', vals: [nameParts.slice(1).join(' ')] }); if (email) attributes.push({ attr: 'mail', vals: [email] }); if (phone) attributes.push({ attr: 'telephoneNumber', vals: [phone] }); if (position) { const positionVal = String(position).trim(); attributes.push({ attr: 'description', vals: [positionVal] }); attributes.push({ attr: 'title', vals: [positionVal] }); } if (logonScript) attributes.push({ attr: 'scriptPath', vals: [String(logonScript).trim()] }); if (homeDrive && homeBasePath) { attributes.push({ attr: 'homeDrive', vals: [String(homeDrive).trim()] }); attributes.push({ attr: 'homeDirectory', vals: [`${String(homeBasePath).replace(/\\+$/, '')}\\${login}`] }); } await addEntry(client, userDN, attributes); try { if (password) { await setPassword(client, userDN, password); } const groupDNs = await resolveGroupDNs(client, groupCNs); const created = await findUserByLogin(client, login); const memberDNs = new Set((created.memberOf || []).map((d) => String(d).toLowerCase())); const primaryGroupId = String(created.primaryGroupID || '').trim(); for (const group of groupDNs) { if (String(group.primaryGroupToken || '').trim() === primaryGroupId) continue; if (memberDNs.has(String(group.dn).toLowerCase())) continue; await modifyEntry(client, group.dn, [{ operation: 'add', modification: { member: [userDN] } }]); } } catch (err) { try { await removeEntry(client, userDN); } catch (_) { // best-effort rollback: keep the original error } throw err; } return { success: true, dn: userDN, login }; } finally { await close(client); } } function cleanValue(value, maxLen) { return String(value || '') .replace(/[\u0000-\u001f\u007f]/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, maxLen || 200); } function splitFioParts(fio) { const parts = cleanValue(fio).split(' '); return { lastName: parts[0] || '', firstName: parts[1] || '', middleName: parts.slice(2).join(' ') || '' }; } async function updateUser(login, { fio, lastName, firstName, middleName, email, phone, position, logonScript, homeDrive, homeBasePath, password, groupCNs }) { const client = await connect(); try { await bind(client, config.adminLogin, config.adminPassword); const entry = await findUserByLogin(client, login); const userDN = entry.dn; const changes = []; const hasNameFields = lastName !== undefined || firstName !== undefined || middleName !== undefined; if (fio !== undefined || hasNameFields) { let lname = lastName !== undefined ? cleanValue(lastName, 64) : null; let fname = firstName !== undefined ? cleanValue(firstName, 64) : null; let mname = middleName !== undefined ? cleanValue(middleName, 64) : null; if (fio !== undefined && !hasNameFields) { const parts = splitFioParts(fio); if (lname === null) lname = parts.lastName; if (fname === null) fname = parts.firstName; if (mname === null) mname = parts.middleName; } const display = [lname, fname, mname].filter(Boolean).join(' '); if (display) { changes.push({ operation: 'replace', modification: { displayName: [display] } }); } if (lname !== null && lname) { changes.push({ operation: 'replace', modification: { sn: [lname] } }); } if (fname !== null && fname) { changes.push({ operation: 'replace', modification: { givenName: [fname] } }); } if (mname !== null && mname) { changes.push({ operation: 'replace', modification: { middleName: [mname] } }); } } if (email !== undefined) { const emailVal = cleanValue(email, 254); if (emailVal) { changes.push({ operation: 'replace', modification: { mail: [emailVal] } }); } } if (phone !== undefined) { changes.push({ operation: 'replace', modification: { telephoneNumber: [cleanValue(phone, 60)] } }); } if (position !== undefined) { const positionVal = cleanValue(position, 120); changes.push({ operation: 'replace', modification: { description: [positionVal] } }); changes.push({ operation: 'replace', modification: { title: [positionVal] } }); } if (logonScript !== undefined) { changes.push({ operation: 'replace', modification: { scriptPath: [cleanValue(logonScript, 255)] } }); } if (homeDrive !== undefined) { changes.push({ operation: 'replace', modification: { homeDrive: [cleanValue(homeDrive, 3)] } }); } if (homeBasePath !== undefined) { changes.push({ operation: 'replace', modification: { homeDirectory: homeBasePath.trim() ? [`${String(homeBasePath).trim().replace(/\\+$/, '')}\\${login}`] : [''] } }); } if (changes.length > 0) { await modifyEntry(client, userDN, changes); } if (password) { await setPassword(client, userDN, password); } if (groupCNs !== undefined) { const currentGroupDNs = (entry.memberOf || []).map((d) => d.toLowerCase()); const primaryGroupId = String(entry.primaryGroupID || '').trim(); const targetGroupDNs = await resolveGroupDNs(client, groupCNs); const toAdd = targetGroupDNs.filter( (g) => String(g.primaryGroupToken || '').trim() !== primaryGroupId && !currentGroupDNs.includes(g.dn.toLowerCase()) ); const toRemove = currentGroupDNs.filter( (d) => !targetGroupDNs.some((t) => t.dn.toLowerCase() === d) ); for (const group of toAdd) { await modifyEntry(client, group.dn, [{ operation: 'add', modification: { member: [userDN] } }]); } for (const groupDN of toRemove) { await modifyEntry(client, groupDN, [{ operation: 'delete', modification: { member: [userDN] } }]); } } return { success: true, dn: userDN, login }; } finally { await close(client); } } async function setUserEnabled(login, enabled) { const client = await connect(); try { await bind(client, config.adminLogin, config.adminPassword); const entry = await findUserByLogin(client, login); const userDN = entry.dn; const uac = parseInt(entry.userAccountControl, 10) || 0; const newUac = enabled ? (uac & ~0x0002) : (uac | 0x0002); await modifyEntry(client, userDN, [ { operation: 'replace', modification: { userAccountControl: [String(newUac)] } } ]); return { success: true, dn: userDN, login, enabled }; } finally { await close(client); } } async function testConnection() { const client = await connect(); try { if (config.adminLogin && config.adminPassword) { await bind(client, config.adminLogin, config.adminPassword); } await searchEntries(client, config.baseDN, { scope: 'base', filter: '(objectClass=*)', attributes: ['distinguishedName'] }); return true; } finally { await close(client); } } module.exports = { LdapError, authenticate, getUserInfo, listOUs, listGroups, listUsers, getUser, createUser, updateUser, setUserEnabled, testConnection, buildBindNames, getCN, escapeFilter };