wekan/packages/wekan-ldap/server/ldap.js
Harry Adel 217cd7bcb6 Use ldapts instead ldapjs
We're using ldapts v4.2.6 because Wekan is on 2.16
2026-02-13 18:45:59 +02:00

480 lines
16 KiB
JavaScript

import { Client } from 'ldapts';
import { Log } from 'meteor/logging';
// copied from https://github.com/ldapjs/node-ldapjs/blob/a113953e0d91211eb945d2a3952c84b7af6de41c/lib/filters/index.js#L167
function escapedToHex (str) {
if (str !== undefined) {
return str.replace(/\\([0-9a-f][^0-9a-f]|[0-9a-f]$|[^0-9a-f]|$)/gi, function (match, p1) {
if (!p1) {
return '\\5c';
}
const hexCode = p1.charCodeAt(0).toString(16);
const rest = p1.substring(1);
return '\\' + hexCode + rest;
});
} else {
return undefined;
}
}
// 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.connected = false;
this.options = {
host : this.constructor.settings_get('LDAP_HOST'),
port : this.constructor.settings_get('LDAP_PORT'),
Reconnect : this.constructor.settings_get('LDAP_RECONNECT'),
timeout : this.constructor.settings_get('LDAP_TIMEOUT'),
connect_timeout : this.constructor.settings_get('LDAP_CONNECT_TIMEOUT'),
idle_timeout : this.constructor.settings_get('LDAP_IDLE_TIMEOUT'),
encryption : this.constructor.settings_get('LDAP_ENCRYPTION'),
ca_cert : this.constructor.settings_get('LDAP_CA_CERT'),
reject_unauthorized : this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') !== undefined ? this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') : true,
Authentication : this.constructor.settings_get('LDAP_AUTHENTIFICATION'),
Authentication_UserDN : this.constructor.settings_get('LDAP_AUTHENTIFICATION_USERDN'),
Authentication_Password : this.constructor.settings_get('LDAP_AUTHENTIFICATION_PASSWORD'),
Authentication_Fallback : this.constructor.settings_get('LDAP_LOGIN_FALLBACK'),
BaseDN : this.constructor.settings_get('LDAP_BASEDN'),
Internal_Log_Level : this.constructor.settings_get('INTERNAL_LOG_LEVEL'), //this setting does not have any effect any more and should be deprecated
User_Authentication : this.constructor.settings_get('LDAP_USER_AUTHENTICATION'),
User_Authentication_Field : this.constructor.settings_get('LDAP_USER_AUTHENTICATION_FIELD'),
User_Attributes : this.constructor.settings_get('LDAP_USER_ATTRIBUTES'),
User_Search_Filter : escapedToHex(this.constructor.settings_get('LDAP_USER_SEARCH_FILTER')),
User_Search_Scope : this.constructor.settings_get('LDAP_USER_SEARCH_SCOPE'),
User_Search_Field : this.constructor.settings_get('LDAP_USER_SEARCH_FIELD'),
Search_Page_Size : this.constructor.settings_get('LDAP_SEARCH_PAGE_SIZE'),
Search_Size_Limit : this.constructor.settings_get('LDAP_SEARCH_SIZE_LIMIT'),
group_filter_enabled : this.constructor.settings_get('LDAP_GROUP_FILTER_ENABLE'),
group_filter_object_class : this.constructor.settings_get('LDAP_GROUP_FILTER_OBJECTCLASS'),
group_filter_group_id_attribute : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE'),
group_filter_group_member_attribute: this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE'),
group_filter_group_member_format : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT'),
group_filter_group_name : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_NAME'),
AD_Simple_Auth : this.constructor.settings_get('LDAP_AD_SIMPLE_AUTH'),
Default_Domain : this.constructor.settings_get('LDAP_DEFAULT_DOMAIN'),
};
}
static settings_get(name, ...args) {
let value = process.env[name];
if (value !== undefined) {
if (value === 'true' || value === 'false') {
value = JSON.parse(value);
} else if (value !== '' && !isNaN(value)) {
value = Number(value);
}
return value;
} else {
//Log.warn(`Lookup for unset variable: ${name}`);
}
}
async connect() {
Log.info('Init setup');
const tlsOptions = {
rejectUnauthorized: this.options.reject_unauthorized,
};
if (this.options.ca_cert && this.options.ca_cert !== '') {
// Split CA cert into array of strings
const chainLines = this.constructor.settings_get('LDAP_CA_CERT').replace(/\\n/g,'\n').split('\n');
let cert = [];
const ca = [];
chainLines.forEach((line) => {
cert.push(line);
if (line.match(/-END CERTIFICATE-/)) {
ca.push(cert.join('\n'));
cert = [];
}
});
tlsOptions.ca = ca;
}
let url;
if (this.options.encryption === 'ssl') {
url = `ldaps://${this.options.host}:${this.options.port}`;
} else {
url = `ldap://${this.options.host}:${this.options.port}`;
}
Log.info(`Connecting ${url}`);
const clientOptions = {
url,
timeout : this.options.timeout,
connectTimeout: this.options.connect_timeout,
strictDN : false,
};
if (this.options.encryption === 'ssl') {
clientOptions.tlsOptions = tlsOptions;
}
Log.debug(`clientOptions ${JSON.stringify(clientOptions)}`);
this.client = new Client(clientOptions);
if (this.options.encryption === 'tls') {
// 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
tlsOptions.host = this.options.host;
Log.info('Starting TLS');
Log.debug(`tlsOptions ${JSON.stringify(tlsOptions)}`);
await this.client.startTLS(tlsOptions);
Log.info('TLS connected');
}
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;
}
}
}
return values;
}
getUserFilter(username) {
const filter = [];
if (this.options.User_Search_Filter !== '') {
if (this.options.User_Search_Filter[0] === '(') {
filter.push(`${this.options.User_Search_Filter}`);
} else {
filter.push(`(${this.options.User_Search_Filter})`);
}
}
// Escape the username to prevent LDAP injection
const escapedUsername = escapedToHex(username);
const usernameFilter = this.options.User_Search_Field.split(',').map((item) => `(${item}=${escapedUsername})`);
if (usernameFilter.length === 0) {
Log.error('LDAP_LDAP_User_Search_Field not defined');
} else if (usernameFilter.length === 1) {
filter.push(`${usernameFilter[0]}`);
} else {
filter.push(`(|${usernameFilter.join('')})`);
}
return `(&${filter.join('')})`;
}
async bindUserIfNecessary(username, password) {
if (this.domainBinded === true) {
return;
}
if (!this.options.User_Authentication) {
return;
}
/* if SimpleAuth is configured, the BaseDN is not needed */
if (!this.options.BaseDN && !this.options.AD_Simple_Auth) throw new Error('BaseDN is not provided');
// Escape the username to prevent LDAP injection in DN construction
const escapedUsername = escapedToHex(username);
var userDn = "";
if (this.options.AD_Simple_Auth === true || this.options.AD_Simple_Auth === 'true') {
userDn = `${escapedUsername}@${this.options.Default_Domain}`;
} else {
userDn = `${this.options.User_Authentication_Field}=${escapedUsername},${this.options.BaseDN}`;
}
Log.info(`Binding with User ${userDn}`);
await this.bind(userDn, password);
this.domainBinded = true;
}
async bindIfNecessary() {
if (this.domainBinded === true) {
return;
}
if (this.options.Authentication !== true) {
return;
}
Log.info(`Binding UserDN ${this.options.Authentication_UserDN}`);
await this.bind(this.options.Authentication_UserDN, this.options.Authentication_Password);
this.domainBinded = true;
}
async searchUsers(username) {
await this.bindIfNecessary();
const searchOptions = {
filter : this.getUserFilter(username),
scope : this.options.User_Search_Scope || 'sub',
sizeLimit: this.options.Search_Size_Limit,
};
if (!!this.options.User_Attributes) searchOptions.attributes = this.options.User_Attributes.split(',');
if (this.options.Search_Page_Size > 0) {
searchOptions.paged = {
pageSize: this.options.Search_Page_Size,
};
}
Log.info(`Searching user ${username}`);
Log.debug(`searchOptions ${searchOptions}`);
Log.debug(`BaseDN ${this.options.BaseDN}`);
return await this.searchAll(this.options.BaseDN, searchOptions);
}
async getUserById(id, attribute) {
await this.bindIfNecessary();
const Unique_Identifier_Field = this.constructor.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD').split(',');
const escapedValue = hexToLdapEscaped(id);
let filter;
if (attribute) {
filter = `(${attribute}=${escapedValue})`;
} else {
const filters = Unique_Identifier_Field.map((item) => `(${item}=${escapedValue})`);
filter = `(|${filters.join('')})`;
}
const searchOptions = {
filter,
scope: 'sub',
};
Log.info(`Searching by id ${id}`);
Log.debug(`search filter ${searchOptions.filter}`);
Log.debug(`BaseDN ${this.options.BaseDN}`);
const result = await this.searchAll(this.options.BaseDN, searchOptions);
if (!Array.isArray(result) || result.length === 0) {
return;
}
if (result.length > 1) {
Log.error(`Search by id ${id} returned ${result.length} records`);
}
return result[0];
}
async getUserByUsername(username) {
await this.bindIfNecessary();
const searchOptions = {
filter: this.getUserFilter(username),
scope : this.options.User_Search_Scope || 'sub',
};
Log.info(`Searching user ${username}`);
Log.debug(`searchOptions ${searchOptions}`);
Log.debug(`BaseDN ${this.options.BaseDN}`);
const result = await this.searchAll(this.options.BaseDN, searchOptions);
if (!Array.isArray(result) || result.length === 0) {
return;
}
if (result.length > 1) {
Log.error(`Search by username ${username} returned ${result.length} records`);
}
return result[0];
}
async getUserGroups(username, ldapUser) {
if (!this.options.group_filter_enabled) {
return true;
}
const filter = ['(&'];
if (this.options.group_filter_object_class !== '') {
filter.push(`(objectclass=${this.options.group_filter_object_class})`);
}
if (this.options.group_filter_group_member_attribute !== '') {
const format_value = ldapUser[this.options.group_filter_group_member_format];
if (format_value) {
filter.push(`(${this.options.group_filter_group_member_attribute}=${format_value})`);
}
}
filter.push(')');
// Escape the username to prevent LDAP injection
const escapedUsername = escapedToHex(username);
const searchOptions = {
filter: filter.join('').replace(/#{username}/g, escapedUsername).replace("\\", "\\\\"),
scope : 'sub',
};
Log.debug(`Group list filter LDAP: ${searchOptions.filter}`);
const result = await this.searchAll(this.options.BaseDN, searchOptions);
if (!Array.isArray(result) || result.length === 0) {
return [];
}
const grp_identifier = this.options.group_filter_group_id_attribute || 'cn';
const groups = [];
result.map((item) => {
groups.push(item[grp_identifier]);
});
Log.debug(`Groups: ${groups.join(', ')}`);
return groups;
}
async isUserInGroup(username, ldapUser) {
if (!this.options.group_filter_enabled) {
return true;
}
const grps = await this.getUserGroups(username, ldapUser);
const filter = ['(&'];
if (this.options.group_filter_object_class !== '') {
filter.push(`(objectclass=${this.options.group_filter_object_class})`);
}
if (this.options.group_filter_group_member_attribute !== '') {
const format_value = ldapUser[this.options.group_filter_group_member_format];
if (format_value) {
filter.push(`(${this.options.group_filter_group_member_attribute}=${format_value})`);
}
}
if (this.options.group_filter_group_id_attribute !== '') {
filter.push(`(${this.options.group_filter_group_id_attribute}=${this.options.group_filter_group_name})`);
}
filter.push(')');
// Escape the username to prevent LDAP injection
const escapedUsername = escapedToHex(username);
const searchOptions = {
filter: filter.join('').replace(/#{username}/g, escapedUsername).replace("\\", "\\\\"),
scope : 'sub',
};
Log.debug(`Group filter LDAP: ${searchOptions.filter}`);
const result = await this.searchAll(this.options.BaseDN, searchOptions);
if (!Array.isArray(result) || result.length === 0) {
return false;
}
return true;
}
async auth(dn, password) {
Log.info(`Authenticating ${dn}`);
try {
if (password === '') {
throw new Error('Password is not provided');
}
await this.bind(dn, password);
Log.info(`Authenticated ${dn}`);
return true;
} catch (error) {
Log.info(`Not authenticated ${dn}`);
Log.debug('error', error);
return false;
}
}
async disconnect() {
this.connected = false;
this.domainBinded = false;
Log.info('Disconecting');
try {
await this.client.unbind();
} catch (error) {
Log.debug('Error during disconnect', error);
}
}
}