Files
ldap-auth-service/lib/ldapClient.js
2026-08-27 11:43:56 +05:00

734 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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
};