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

254 lines
8.7 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 { spawn } = require('child_process');
// Sets a user's password via PowerShell/ADSI (IADsUser.SetPassword).
// On a domain-joined machine ADSI negotiates a Kerberos signed/sealed
// connection, which Active Directory accepts as a "protected channel"
// for unicodePwd — unlike a plain LDAP bind over TCP (AD error 53).
//
// The DN and password are passed via environment variables so they never
// appear in the process command line, and the script body is pure ASCII
// (no encoding issues with Windows PowerShell 5.1). The script itself is
// sent over stdin to avoid temp files.
//
// The server is passed explicitly (when `ldapUrl` is provided) so ADSI
// targets the SAME domain controller that the LDAP write went to. Without
// it ADSI does a serverless bind and the DC Locator may pick another DC
// (e.g. on a remote site) that has not replicated the freshly created user
// yet, which fails with "Такой объект на сервере отсутствует" (0x8007203A).
function adsiServerPrefix(ldapUrl) {
const match = /^([a-z0-9+.-]+):\/\/([^/:]+)(?::(\d+))?/i.exec(String(ldapUrl || '').trim());
if (!match) return '';
const scheme = match[1].toLowerCase();
const host = match[2];
const port = match[3];
const prefix = scheme === 'ldaps' ? 'LDAPS://' : 'LDAP://';
return prefix + host + (port ? ':' + port : '') + '/';
}
function setPasswordViaPowerShell(userDN, password, { exe = 'powershell.exe', timeoutMs = 15000, ldapUrl = '' } = {}) {
return new Promise((resolve, reject) => {
const serverPrefix = adsiServerPrefix(ldapUrl);
const script = [
'$ErrorActionPreference = "Stop"',
'$dn = [Environment]::GetEnvironmentVariable("LDAP_PW_DN")',
'$pw = [Environment]::GetEnvironmentVariable("LDAP_PW_VALUE")',
'$server = [Environment]::GetEnvironmentVariable("LDAP_PW_SERVER")',
'$target = if ($server) { $server + $dn } else { "LDAP://" + $dn }',
'$user = [adsi]($target)',
'$user.SetPassword($pw)'
].join('; ');
let child;
try {
child = spawn(exe, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', '-'], {
env: { ...process.env, LDAP_PW_DN: userDN, LDAP_PW_VALUE: password, LDAP_PW_SERVER: serverPrefix },
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe']
});
} catch (err) {
reject(new Error(`Не удалось запустить PowerShell для установки пароля: ${err.message}`));
return;
}
let stdout = '';
let stderr = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
try { child.kill(); } catch (_) { /* ignore */ }
}, timeoutMs);
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`Не удалось запустить PowerShell для установки пароля: ${err.message}`));
});
child.on('close', (code) => {
clearTimeout(timer);
if (timedOut) {
reject(new Error('Таймаут при установке пароля через PowerShell'));
return;
}
if (code === 0) {
resolve();
return;
}
const detail = String(stderr || stdout || '').trim();
reject(new Error(`Ошибка установки пароля через PowerShell (код ${code})${detail ? ': ' + detail : ''}`));
});
child.stdin.end(script);
});
}
// Sets a user's password from a Linux container via Kerberos (GSSAPI).
// AD only accepts unicodePwd over a protected channel. On Linux there is
// no ADSI, so we obtain a Kerberos ticket for the admin account (kinit)
// and perform an LDAP modify over a SASL GSSAPI connection (ldapmodify
// -Y GSSAPI). The Kerberos-sealed channel (SSF 56) is accepted by AD as
// a protected channel — the same mechanism ADSI uses on Windows.
//
// The admin password and the target password are passed via stdin / env
// variables so they never appear in the process command line, and the
// LDIF is sent over stdin to avoid temp files.
function runChild(exe, args, { env = {}, input = '', timeoutMs = 15000, describe = exe } = {}) {
return new Promise((resolve, reject) => {
let child;
try {
child = spawn(exe, args, {
env: { ...process.env, ...env },
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe']
});
} catch (err) {
reject(new Error(`Не удалось запустить ${describe}: ${err.message}`));
return;
}
let stdout = '';
let stderr = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
try { child.kill(); } catch (_) { /* ignore */ }
}, timeoutMs);
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`Не удалось запустить ${describe}: ${err.message}`));
});
child.on('close', (code) => {
clearTimeout(timer);
if (timedOut) {
reject(new Error(`Таймаут при выполнении ${describe}`));
return;
}
resolve({ code, stdout, stderr });
});
child.stdin.end(input);
});
}
function buildPrincipal(login, realm) {
const value = String(login || '').trim();
if (!value) return '';
if (value.includes('@')) {
const at = value.lastIndexOf('@');
const name = value.slice(0, at);
return `${name}@${realm}`;
}
if (value.includes('\\')) {
return `${value.split('\\').pop()}@${realm}`;
}
return `${value}@${realm}`;
}
function buildUnicodePwdLdif(userDN, password) {
const encoded = Buffer.from('"' + password + '"', 'utf16le').toString('base64');
return [
`dn: ${userDN}`,
'changetype: modify',
'replace: unicodePwd',
`unicodePwd:: ${encoded}`,
''
].join('\n');
}
async function setPasswordViaKerberos(userDN, password, {
exe = 'kinit',
ldapmodifyExe = 'ldapmodify',
timeoutMs = 20000,
ldapUrl = '',
principal = '',
adminPassword = '',
ccache = '/tmp/krb5cc_ldapauth',
retries = 3,
retryDelayMs = 1000
} = {}) {
if (!principal) {
throw new Error('Не задан principal администратора (ADMIN_LOGIN/LDAP_REALM) для Kerberos');
}
const maxAttempts = retries >= 1 ? retries : 3;
const ccacheEnv = { KRB5CCNAME: ccache };
async function destroyCache() {
try {
await runChild('kdestroy', ['-c', ccache], {
env: ccacheEnv,
input: '',
timeoutMs: 5000,
describe: 'kdestroy'
});
} catch (_) { /* best-effort cleanup */ }
}
// Transient GSSAPI/KDC errors (e.g. "Server (ldap/...@REALM) unknown" from
// ldapmodify) often pass on retry — DNS canonicalization or DC selection may
// differ. Each attempt gets a fresh kinit ticket so a partial/poisoned
// ccache can't cause repeated failures. Rewriting the same unicodePwd is
// idempotent, so retrying is safe.
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const kinit = await runChild(exe, ['-c', ccache, principal], {
env: ccacheEnv,
input: String(adminPassword || '') + '\n',
timeoutMs,
describe: 'kinit'
});
if (kinit.code !== 0) {
const detail = String(kinit.stderr || kinit.stdout || '').trim();
throw new Error(`Не удалось получить билет Kerberos (kinit, код ${kinit.code})${detail ? ': ' + detail : ''}`);
}
const ldapArgs = ['-Y', 'GSSAPI'];
if (ldapUrl) ldapArgs.push('-H', ldapUrl);
ldapArgs.push('-O', 'maxssf=256');
const ldapmodify = await runChild(ldapmodifyExe, ldapArgs, {
env: ccacheEnv,
input: buildUnicodePwdLdif(userDN, password),
timeoutMs,
describe: 'ldapmodify'
});
if (ldapmodify.code !== 0) {
const detail = String(ldapmodify.stderr || ldapmodify.stdout || '').trim();
throw new Error(`Ошибка установки пароля через Kerberos/GSSAPI (код ${ldapmodify.code})${detail ? ': ' + detail : ''}`);
}
await destroyCache();
return;
} catch (err) {
lastError = err;
await destroyCache();
if (attempt < maxAttempts) {
console.warn(`⚠ Установка пароля через Kerberos: попытка ${attempt}/${maxAttempts} не удалась, повтор через ${retryDelayMs} мс: ${err.message}`);
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
}
throw lastError;
}
module.exports = {
setPasswordViaPowerShell,
setPasswordViaKerberos,
buildPrincipal,
buildUnicodePwdLdif,
adsiServerPrefix
};