import ldapjs from 'ldapjs'; import util from 'util'; import Bunyan from 'bunyan'; import { log_debug, log_info, log_warn, log_error } from './logger'; export default class LDAP { constructor() { this.ldapjs = ldapjs; 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') || false, 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'), 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: 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', ), }; } 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}`); } } connectSync(...args) { if (!this._connectSync) { this._connectSync = Meteor.wrapAsync(this.connectAsync, this); } return this._connectSync(...args); } searchAllSync(...args) { if (!this._searchAllSync) { this._searchAllSync = Meteor.wrapAsync(this.searchAllAsync, this); } return this._searchAllSync(...args); } 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, }; if (this.options.Internal_Log_Level !== 'disabled') { connectionOptions.log = new Bunyan({ name: 'ldapjs', component: 'client', stream: process.stderr, level: this.options.Internal_Log_Level, }); } 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') .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; } if (this.options.encryption === 'ssl') { connectionOptions.url = `ldaps://${connectionOptions.url}`; connectionOptions.tlsOptions = tlsOptions; } else { connectionOptions.url = `ldap://${connectionOptions.url}`; } log_info('Connecting', connectionOptions.url); log_debug(`connectionOptions${util.inspect(connectionOptions)}`); this.client = ldapjs.createClient(connectionOptions); this.bindSync = Meteor.wrapAsync(this.client.bind, this.client); this.client.on('error', error => { log_error('connection', error); if (replied === false) { replied = true; callback(error, null); } }); this.client.on('idle', () => { log_info('Idle'); this.disconnect(); }); this.client.on('close', () => { log_info('Closed'); }); 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). // 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', tlsOptions); this.client.starttls(tlsOptions, null, (error, response) => { if (error) { log_error('TLS connection', 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); } }); } setTimeout(() => { if (replied === false) { log_error('connection time out', connectionOptions.connectTimeout); replied = true; callback(new Error('Timeout')); } }, connectionOptions.connectTimeout); } 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})`); } } const usernameFilter = this.options.User_Search_Field.split(',').map( item => `(${item}=${username})`, ); 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('')})`; } bindUserIfNecessary(username, password) { if (this.domainBinded === true) { return; } if (!this.options.User_Authentication) { return; } if (!this.options.BaseDN) throw new Error('BaseDN is not provided'); const userDn = `${this.options.User_Authentication_Field}=${username},${this.options.BaseDN}`; this.bindSync(userDn, password); this.domainBinded = true; } bindIfNecessary() { if (this.domainBinded === true) { return; } if (this.options.Authentication !== true) { return; } log_info('Binding UserDN', this.options.Authentication_UserDN); this.bindSync( this.options.Authentication_UserDN, this.options.Authentication_Password, ); this.domainBinded = true; } searchUsersSync(username, page) { 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, pagePause: !!page, }; } log_info('Searching user', username); log_debug('searchOptions', searchOptions); log_debug('BaseDN', this.options.BaseDN); if (page) { return this.searchAllPaged(this.options.BaseDN, searchOptions, page); } return this.searchAllSync(this.options.BaseDN, searchOptions); } getUserByIdSync(id, attribute) { this.bindIfNecessary(); const Unique_Identifier_Field = this.constructor .settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD') .split(','); let filter; if (attribute) { filter = new this.ldapjs.filters.EqualityFilter({ attribute, value: new Buffer(id, 'hex'), }); } else { const filters = []; Unique_Identifier_Field.forEach(item => { filters.push( new this.ldapjs.filters.EqualityFilter({ attribute: item, value: new Buffer(id, 'hex'), }), ); }); filter = new this.ldapjs.filters.OrFilter({ filters }); } const searchOptions = { filter, scope: 'sub', }; log_info('Searching by id', id); log_debug('search filter', searchOptions.filter.toString()); log_debug('BaseDN', this.options.BaseDN); const result = this.searchAllSync(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]; } getUserByUsernameSync(username) { 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 = this.searchAllSync(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]; } 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(')'); const searchOptions = { filter: filter.join('').replace(/#{username}/g, username), scope: 'sub', }; log_debug('Group list filter LDAP:', searchOptions.filter); const result = this.searchAllSync(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; } isUserInGroup(username, ldapUser) { if (!this.options.group_filter_enabled) { return true; } const grps = 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(')'); const searchOptions = { filter: filter.join('').replace(/#{username}/g, username), scope: 'sub', }; log_debug('Group filter LDAP:', searchOptions.filter); const result = this.searchAllSync(this.options.BaseDN, searchOptions); if (!Array.isArray(result) || result.length === 0) { return false; } 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); }); }); } authSync(dn, password) { log_info('Authenticating', dn); try { if (password === '') { throw new Error('Password is not provided'); } this.bindSync(dn, password); log_info('Authenticated', dn); return true; } catch (error) { log_info('Not authenticated', dn); log_debug('error', error); return false; } } disconnect() { this.connected = false; this.domainBinded = false; log_info('Disconecting'); this.client.unbind(); } }