591 lines
18 KiB
JavaScript
591 lines
18 KiB
JavaScript
'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
|
||
}; |