'use strict'; const fs = require('fs'); require('./patch-ldap-dn'); const ldap = require('ldapjs'); const config = require('./config'); 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 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) { const value = Buffer.from('"' + password + '"', 'utf16le'); await modifyEntry(client, userDN, [{ operation: 'replace', modification: { unicodePwd: [value] } }]); } // ================= 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 || '', email: entry.mail || '', phone: entry.telephoneNumber || '', 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', 'mail', 'telephoneNumber', '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'] }); if (entries.length > 0) dns.push(entryToObject(entries[0]).dn); else throw new LdapError(`Группа "${cn}" не найдена`, 0, 400); } return dns; } // ================= Admin: create / update / block ================= async function createUser({ ouDn, fio, login, password, email, phone, 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 cn = escapeDNValue(fio || login); 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] }); await addEntry(client, userDN, attributes); if (password) { await setPassword(client, userDN, password); } const groupDNs = await resolveGroupDNs(client, groupCNs); for (const groupDN of groupDNs) { await modifyEntry(client, groupDN, [{ operation: 'add', modification: { member: [userDN] } }]); } return { success: true, dn: userDN, login }; } finally { await close(client); } } async function updateUser(login, { fio, email, phone, 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 = []; if (fio !== undefined) { changes.push({ operation: 'replace', modification: { displayName: [fio] } }); const nameParts = (fio || '').trim().split(/\s+/); changes.push({ operation: 'replace', modification: { givenName: [nameParts[0] || ''] } }); changes.push({ operation: 'replace', modification: { sn: [nameParts.slice(1).join(' ') || ''] } }); } if (email !== undefined) { changes.push({ operation: 'replace', modification: { mail: [email || ''] } }); } if (phone !== undefined) { changes.push({ operation: 'replace', modification: { telephoneNumber: [phone || ''] } }); } 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 targetGroupDNs = await resolveGroupDNs(client, groupCNs); const toAdd = targetGroupDNs.filter((d) => !currentGroupDNs.includes(d.toLowerCase())); const toRemove = currentGroupDNs.filter( (d) => !targetGroupDNs.some((t) => t.toLowerCase() === d.toLowerCase()) ); for (const groupDN of toAdd) { await modifyEntry(client, groupDN, [{ 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 };