Use ldapts instead ldapjs

We're using ldapts v4.2.6 because Wekan is on 2.16
This commit is contained in:
Harry Adel 2026-02-13 18:45:59 +02:00
parent 6bf60c496b
commit 217cd7bcb6
4 changed files with 143 additions and 297 deletions

View file

@ -143,8 +143,7 @@ wekan-accounts-oidc@1.0.10
wekan-accounts-sandstorm@0.9.0
wekan-fontawesome@6.4.2
wekan-fullcalendar@3.10.5
wekan-ldap@0.0.2
wekan-ldap@0.1.0
wekan-markdown@1.0.9
wekan-oidc@1.1.0
yasaricli:slugify@0.0.7
zodern:types@1.0.13

View file

@ -26,6 +26,6 @@ Package.onUse(function(api) {
});
Npm.depends({
'ldapjs': '2.3.3',
'ldapts': '4.2.6',
'limax': '4.1.0'
});

View file

@ -1,4 +1,4 @@
import ldapjs from 'ldapjs';
import { Client } from 'ldapts';
import { Log } from 'meteor/logging';
// copied from https://github.com/ldapjs/node-ldapjs/blob/a113953e0d91211eb945d2a3952c84b7af6de41c/lib/filters/index.js#L167
@ -18,10 +18,14 @@ function escapedToHex (str) {
}
}
// Convert hex string to LDAP escaped binary filter value
// e.g. "0102ff" -> "\\01\\02\\ff"
function hexToLdapEscaped(hex) {
return hex.match(/.{2}/g).map(h => '\\' + h).join('');
}
export default class LDAP {
constructor() {
this.ldapjs = ldapjs;
this.connected = false;
this.options = {
@ -74,54 +78,8 @@ export default class LDAP {
}
async connect() {
return new Promise((resolve, reject) => {
this.connectAsync((error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
async searchAll(BaseDN, options) {
return new Promise((resolve, reject) => {
this.searchAllAsync(BaseDN, options, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
async bind(dn, password) {
return new Promise((resolve, reject) => {
this.client.bind(dn, password, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
connectAsync(callback) {
Log.info('Init setup');
let replied = false;
const connectionOptions = {
url : `${this.options.host}:${this.options.port}`,
timeout : this.options.timeout,
connectTimeout: this.options.connect_timeout,
idleTimeout : this.options.idle_timeout,
reconnect : this.options.Reconnect,
};
const tlsOptions = {
rejectUnauthorized: this.options.reject_unauthorized,
};
@ -141,79 +99,114 @@ export default class LDAP {
tlsOptions.ca = ca;
}
let url;
if (this.options.encryption === 'ssl') {
connectionOptions.url = `ldaps://${connectionOptions.url}`;
connectionOptions.tlsOptions = tlsOptions;
url = `ldaps://${this.options.host}:${this.options.port}`;
} else {
connectionOptions.url = `ldap://${connectionOptions.url}`;
url = `ldap://${this.options.host}:${this.options.port}`;
}
Log.info(`Connecting ${connectionOptions.url}`);
Log.debug(`connectionOptions ${JSON.stringify(connectionOptions)}`);
Log.info(`Connecting ${url}`);
this.client = ldapjs.createClient(connectionOptions);
const clientOptions = {
url,
timeout : this.options.timeout,
connectTimeout: this.options.connect_timeout,
strictDN : false,
};
this.client.on('error', (error) => {
Log.error(`connection ${error}`);
if (replied === false) {
replied = true;
callback(error, null);
}
});
if (this.options.encryption === 'ssl') {
clientOptions.tlsOptions = tlsOptions;
}
this.client.on('idle', () => {
Log.info('Idle');
this.disconnect();
});
Log.debug(`clientOptions ${JSON.stringify(clientOptions)}`);
this.client.on('close', () => {
Log.info('Closed');
});
this.client = new Client(clientOptions);
if (this.options.encryption === 'tls') {
// Set host parameter for tls.connect which is used by ldapjs starttls. This shouldn't be needed in newer nodejs versions (e.g v5.6.0).
// Set host parameter for tls.connect which is used by starttls. This shouldn't be needed in newer nodejs versions (e.g v5.6.0).
// https://github.com/RocketChat/Rocket.Chat/issues/2035
// https://github.com/mcavage/node-ldapjs/issues/349
tlsOptions.host = this.options.host;
Log.info('Starting TLS');
Log.debug(`tlsOptions ${JSON.stringify(tlsOptions)}`);
this.client.starttls(tlsOptions, null, (error, response) => {
if (error) {
Log.error(`TLS connection ${JSON.stringify(error)}`);
if (replied === false) {
replied = true;
callback(error, null);
}
return;
}
Log.info('TLS connected');
this.connected = true;
if (replied === false) {
replied = true;
callback(null, response);
}
});
} else {
this.client.on('connect', (response) => {
Log.info('LDAP connected');
this.connected = true;
if (replied === false) {
replied = true;
callback(null, response);
}
});
await this.client.startTLS(tlsOptions);
Log.info('TLS connected');
}
setTimeout(() => {
if (replied === false) {
Log.error(`connection time out ${connectionOptions.connectTimeout}`);
replied = true;
callback(new Error('Timeout'));
this.connected = true;
}
async bind(dn, password) {
await this.client.bind(dn, password);
}
getBufferAttributes() {
const fields = [];
let uidField = this.constructor.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD');
if (uidField && uidField !== '') {
fields.push(...uidField.replace(/\s/g, '').split(','));
}
let searchField = this.constructor.settings_get('LDAP_USER_SEARCH_FIELD');
if (searchField && searchField !== '') {
fields.push(...searchField.replace(/\s/g, '').split(','));
}
return fields;
}
async searchAll(BaseDN, options) {
const searchOptions = {
filter: options.filter,
scope : options.scope || 'sub',
};
if (options.attributes) {
searchOptions.attributes = options.attributes;
}
if (options.sizeLimit) {
searchOptions.sizeLimit = options.sizeLimit;
}
if (options.paged) {
searchOptions.paged = {
pageSize: options.paged.pageSize || 250,
};
}
// Request unique identifier fields as Buffers so that
// getLdapUserUniqueID() in sync.js can call .toString('hex')
const bufferAttributes = this.getBufferAttributes();
if (bufferAttributes.length > 0) {
searchOptions.explicitBufferAttributes = bufferAttributes;
}
const { searchEntries } = await this.client.search(BaseDN, searchOptions);
Log.info(`Search result count ${searchEntries.length}`);
return searchEntries.map((entry) => this.extractLdapEntryData(entry));
}
extractLdapEntryData(entry) {
const values = {
_raw: {},
};
for (const key of Object.keys(entry)) {
const value = entry[key];
values._raw[key] = value;
if (!['thumbnailPhoto', 'jpegPhoto'].includes(key)) {
if (value instanceof Buffer) {
values[key] = value.toString();
} else {
values[key] = value;
}
}
}, connectionOptions.connectTimeout);
}
return values;
}
getUserFilter(username) {
@ -285,7 +278,7 @@ export default class LDAP {
this.domainBinded = true;
}
async searchUsers(username, page) {
async searchUsers(username) {
await this.bindIfNecessary();
const searchOptions = {
filter : this.getUserFilter(username),
@ -297,8 +290,7 @@ export default class LDAP {
if (this.options.Search_Page_Size > 0) {
searchOptions.paged = {
pageSize : this.options.Search_Page_Size,
pagePause: !!page,
pageSize: this.options.Search_Page_Size,
};
}
@ -306,10 +298,6 @@ export default class LDAP {
Log.debug(`searchOptions ${searchOptions}`);
Log.debug(`BaseDN ${this.options.BaseDN}`);
if (page) {
return this.searchAllPaged(this.options.BaseDN, searchOptions, page);
}
return await this.searchAll(this.options.BaseDN, searchOptions);
}
@ -318,23 +306,14 @@ export default class LDAP {
const Unique_Identifier_Field = this.constructor.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD').split(',');
const escapedValue = hexToLdapEscaped(id);
let filter;
if (attribute) {
filter = new this.ldapjs.filters.EqualityFilter({
attribute,
value: Buffer.from(id, 'hex'),
});
filter = `(${attribute}=${escapedValue})`;
} else {
const filters = [];
Unique_Identifier_Field.forEach((item) => {
filters.push(new this.ldapjs.filters.EqualityFilter({
attribute: item,
value : Buffer.from(id, 'hex'),
}));
});
filter = new this.ldapjs.filters.OrFilter({ filters });
const filters = Unique_Identifier_Field.map((item) => `(${item}=${escapedValue})`);
filter = `(|${filters.join('')})`;
}
const searchOptions = {
@ -343,7 +322,7 @@ export default class LDAP {
};
Log.info(`Searching by id ${id}`);
Log.debug(`search filter ${searchOptions.filter.toString()}`);
Log.debug(`search filter ${searchOptions.filter}`);
Log.debug(`BaseDN ${this.options.BaseDN}`);
const result = await this.searchAll(this.options.BaseDN, searchOptions);
@ -471,134 +450,6 @@ export default class LDAP {
return true;
}
extractLdapEntryData(entry) {
const values = {
_raw: entry.raw,
};
Object.keys(values._raw).forEach((key) => {
const value = values._raw[key];
if (!['thumbnailPhoto', 'jpegPhoto'].includes(key)) {
if (value instanceof Buffer) {
values[key] = value.toString();
} else {
values[key] = value;
}
}
});
return values;
}
searchAllPaged(BaseDN, options, page) {
this.bindIfNecessary();
const processPage = ({ entries, title, end, next }) => {
Log.info(title);
// Force LDAP idle to wait the record processing
this.client._updateIdle(true);
page(null, entries, {
end, next: () => {
// Reset idle timer
this.client._updateIdle();
next && next();
}
});
};
this.client.search(BaseDN, options, (error, res) => {
if (error) {
Log.error(error);
page(error);
return;
}
res.on('error', (error) => {
Log.error(error);
page(error);
return;
});
let entries = [];
const internalPageSize = options.paged && options.paged.pageSize > 0 ? options.paged.pageSize * 2 : 500;
res.on('searchEntry', (entry) => {
entries.push(this.extractLdapEntryData(entry));
if (entries.length >= internalPageSize) {
processPage({
entries,
title: 'Internal Page',
end : false,
});
entries = [];
}
});
res.on('page', (result, next) => {
if (!next) {
this.client._updateIdle(true);
processPage({
entries,
title: 'Final Page',
end : true,
});
} else if (entries.length) {
Log.info('Page');
processPage({
entries,
title: 'Page',
end : false,
next,
});
entries = [];
}
});
res.on('end', () => {
if (entries.length) {
processPage({
entries,
title: 'Final Page',
end : true,
});
entries = [];
}
});
});
}
searchAllAsync(BaseDN, options, callback) {
this.bindIfNecessary();
this.client.search(BaseDN, options, (error, res) => {
if (error) {
Log.error(error);
callback(error);
return;
}
res.on('error', (error) => {
Log.error(error);
callback(error);
return;
});
const entries = [];
res.on('searchEntry', (entry) => {
entries.push(this.extractLdapEntryData(entry));
});
res.on('end', () => {
Log.info(`Search result count ${entries.length}`);
callback(null, entries);
});
});
}
async auth(dn, password) {
Log.info(`Authenticating ${dn}`);
@ -616,10 +467,14 @@ export default class LDAP {
}
}
disconnect() {
async disconnect() {
this.connected = false;
this.domainBinded = false;
Log.info('Disconecting');
this.client.unbind();
try {
await this.client.unbind();
} catch (error) {
Log.debug('Error during disconnect', error);
}
}
}

View file

@ -341,58 +341,50 @@ export async function importNewUsers(ldap) {
}
let count = 0;
ldap.searchUsers('*', Meteor.bindEnvironment(async (error, ldapUsers, {next, end} = {}) => {
if (error) {
throw error;
const ldapUsers = await ldap.searchUsers('*');
for (const ldapUser of ldapUsers) {
count++;
const uniqueId = getLdapUserUniqueID(ldapUser);
// Look to see if user already exists
const userQuery = {
'services.ldap.id': uniqueId.value,
};
log_debug('userQuery', userQuery);
let username;
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
username = slug(getLdapUsername(ldapUser));
}
for (const ldapUser of ldapUsers) {
count++;
// Add user if it was not added before
let user = await Meteor.users.findOneAsync(userQuery);
const uniqueId = getLdapUserUniqueID(ldapUser);
// Look to see if user already exists
if (!user && username && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') === true) {
const userQuery = {
'services.ldap.id': uniqueId.value,
username,
};
log_debug('userQuery', userQuery);
log_debug('userQuery merge', userQuery);
let username;
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
username = slug(getLdapUsername(ldapUser));
}
// Add user if it was not added before
let user = await Meteor.users.findOneAsync(userQuery);
if (!user && username && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') === true) {
const userQuery = {
username,
};
log_debug('userQuery merge', userQuery);
user = await Meteor.users.findOneAsync(userQuery);
if (user) {
await syncUserData(user, ldapUser);
}
}
if (!user) {
await addLdapUser(ldapUser, username);
}
if (count % 100 === 0) {
log_info('Import running. Users imported until now:', count);
user = await Meteor.users.findOneAsync(userQuery);
if (user) {
await syncUserData(user, ldapUser);
}
}
if (end) {
log_info('Import finished. Users imported:', count);
if (!user) {
await addLdapUser(ldapUser, username);
}
next(count);
}));
if (count % 100 === 0) {
log_info('Import running. Users imported until now:', count);
}
}
log_info('Import finished. Users imported:', count);
}
async function sync() {