kerberos
This commit is contained in:
18
.env.example
18
.env.example
@@ -10,6 +10,8 @@ LDAP_DOMAIN=school25.ru
|
||||
# ============ Administrator account ============
|
||||
# Account that has rights to create / modify / block users in AD.
|
||||
# May be a sAMAccountName, a UPN (user@domain) or a full DN.
|
||||
# NOTE: with LDAP_PASSWORD_METHOD=kerberos the account must be a
|
||||
# sAMAccountName or UPN (a DN cannot be used as a Kerberos principal).
|
||||
ADMIN_LOGIN=admin
|
||||
# Passwords containing '#' (dotenv comment marker), spaces or other special chars
|
||||
# must be wrapped in quotes, e.g. ADMIN_PASSWORD="my#pass"
|
||||
@@ -32,9 +34,19 @@ ENCRYPT_TRAFFIC=false
|
||||
# ============ Password setting ============
|
||||
# AD only allows setting unicodePwd over a protected channel (LDAPS / StartTLS /
|
||||
# Kerberos signed+sealed connection). Without TLS the plain LDAP modify returns
|
||||
# LDAP error 53. On a domain-joined Windows server the recommended workaround is
|
||||
# to set the password via PowerShell/ADSI (IADsUser.SetPassword), which uses a
|
||||
# Kerberos protected channel automatically.
|
||||
# LDAP error 53.
|
||||
#
|
||||
# Password method (LDAP_PASSWORD_METHOD):
|
||||
# kerberos - (default on Linux) kinit + ldapmodify -Y GSSAPI using the admin
|
||||
# account; requires krb5/openldap-clients/cyrus-sasl-gssapiv2
|
||||
# packages and /etc/krb5.conf. No TLS needed.
|
||||
# powershell - (default on Windows) PowerShell/ADSI IADsUser.SetPassword on a
|
||||
# domain-joined machine (Kerberos protected channel).
|
||||
# ldap - plain unicodePwd LDAP modify; works only over LDAPS/StartTLS.
|
||||
# LDAP_PASSWORD_METHOD=kerberos
|
||||
# Kerberos realm (default: LDAP_DOMAIN in uppercase, e.g. SCHOOL25.RU)
|
||||
# LDAP_REALM=SCHOOL25.RU
|
||||
# Legacy switch kept for compatibility (implies LDAP_PASSWORD_METHOD=powershell):
|
||||
# LDAP_USE_POWERSHELL_PASSWORD=true
|
||||
# LDAP_POWERSHELL_EXE=powershell.exe
|
||||
|
||||
|
||||
24
config/krb5.conf
Normal file
24
config/krb5.conf
Normal file
@@ -0,0 +1,24 @@
|
||||
# Kerberos configuration for setting AD passwords from the Linux container.
|
||||
# The realm is the LDAP domain in uppercase; kdc locations are resolved via
|
||||
# DNS SRV records (_kerberos._tcp.<domain>) so no hardcoded server list is
|
||||
# needed. Override the realm with the LDAP_REALM env variable if required.
|
||||
|
||||
[libdefaults]
|
||||
default_realm = SCHOOL25.RU
|
||||
dns_lookup_kdc = true
|
||||
dns_lookup_realm = false
|
||||
rdns = false
|
||||
forwardable = true
|
||||
renew_lifetime = 7d
|
||||
ticket_lifetime = 24h
|
||||
|
||||
[realms]
|
||||
SCHOOL25.RU = {
|
||||
kdc = dc.school25.ru
|
||||
kdc = dc3.school25.ru
|
||||
kdc = srv.school25.ru
|
||||
}
|
||||
|
||||
[domain_realm]
|
||||
.school25.ru = SCHOOL25.RU
|
||||
school25.ru = SCHOOL25.RU
|
||||
@@ -7,6 +7,11 @@ function getEnv(key, defaultValue) {
|
||||
return value !== undefined && value !== '' ? value : defaultValue;
|
||||
}
|
||||
|
||||
const legacyPowerShell = getEnv('LDAP_USE_POWERSHELL_PASSWORD', '') === 'true';
|
||||
const passwordMethodDefault = legacyPowerShell
|
||||
? 'powershell'
|
||||
: process.platform === 'win32' ? 'powershell' : 'kerberos';
|
||||
|
||||
const config = {
|
||||
ldapUrl: getEnv('LDAP_URL', 'ldap://dc.school25.ru:389'),
|
||||
baseDN: getEnv('LDAP_BASE_DN', 'DC=school25,DC=ru'),
|
||||
@@ -19,7 +24,8 @@ const config = {
|
||||
encryptTraffic: getEnv('ENCRYPT_TRAFFIC', 'true') === 'true',
|
||||
tlsRejectUnauthorized: getEnv('LDAP_TLS_REJECT_UNAUTHORIZED', 'false') === 'true',
|
||||
caFile: getEnv('LDAP_CA_FILE', ''),
|
||||
usePowerShellPassword: getEnv('LDAP_USE_POWERSHELL_PASSWORD', process.platform === 'win32' ? 'true' : 'false') === 'true',
|
||||
passwordMethod: getEnv('LDAP_PASSWORD_METHOD', passwordMethodDefault),
|
||||
ldapRealm: getEnv('LDAP_REALM', String(getEnv('LDAP_DOMAIN', 'school25.ru')).toUpperCase()),
|
||||
powerShellExe: getEnv('LDAP_POWERSHELL_EXE', 'powershell.exe'),
|
||||
emailHost: getEnv('EMAIL_HOST', ''),
|
||||
emailPort: parseInt(getEnv('EMAIL_PORT', '587'), 10),
|
||||
|
||||
@@ -4,7 +4,7 @@ const fs = require('fs');
|
||||
require('./patch-ldap-dn');
|
||||
const ldap = require('ldapjs');
|
||||
const config = require('./config');
|
||||
const setPasswordViaPowerShell = require('./passwordSetter');
|
||||
const passwordSetter = require('./passwordSetter');
|
||||
|
||||
function urlScheme(url) {
|
||||
const match = /^([a-z0-9+.-]+):\/\//i.exec(String(url || ''));
|
||||
@@ -238,9 +238,22 @@ function modifyEntry(client, dn, changes) {
|
||||
}
|
||||
|
||||
async function setPassword(client, userDN, password) {
|
||||
if (config.usePowerShellPassword) {
|
||||
await setPasswordViaPowerShell(userDN, password, { exe: config.powerShellExe, ldapUrl: config.ldapUrl });
|
||||
return;
|
||||
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
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'ldap':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const value = Buffer.from('"' + password + '"', 'utf16le');
|
||||
await modifyEntry(client, userDN, [{ operation: 'replace', modification: { unicodePwd: [value] } }]);
|
||||
|
||||
@@ -87,4 +87,140 @@ function setPasswordViaPowerShell(userDN, password, { exe = 'powershell.exe', ti
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = setPasswordViaPowerShell;
|
||||
// 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'
|
||||
} = {}) {
|
||||
if (!principal) {
|
||||
throw new Error('Не задан principal администратора (ADMIN_LOGIN/LDAP_REALM) для Kerberos');
|
||||
}
|
||||
const ccacheEnv = { KRB5CCNAME: ccache };
|
||||
|
||||
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'
|
||||
});
|
||||
|
||||
try {
|
||||
await runChild('kdestroy', ['-c', ccache], {
|
||||
env: ccacheEnv,
|
||||
input: '',
|
||||
timeoutMs: 5000,
|
||||
describe: 'kdestroy'
|
||||
});
|
||||
} catch (_) { /* best-effort cleanup */ }
|
||||
|
||||
if (ldapmodify.code !== 0) {
|
||||
const detail = String(ldapmodify.stderr || ldapmodify.stdout || '').trim();
|
||||
throw new Error(`Ошибка установки пароля через Kerberos/GSSAPI (код ${ldapmodify.code})${detail ? ': ' + detail : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setPasswordViaPowerShell,
|
||||
setPasswordViaKerberos,
|
||||
buildPrincipal,
|
||||
buildUnicodePwdLdif,
|
||||
adsiServerPrefix
|
||||
};
|
||||
Reference in New Issue
Block a user