import ldapjs from 'ldapjs'; import type { ILDAPConnectionOptions, LDAPEncryptionType, LDAPSearchScope, ILDAPEntry, ILDAPCallback, ILDAPPageCallback, } from '@rocket.chat/core-typings'; import { settings } from '../../../app/settings/server'; import { logger, connLogger, searchLogger, authLogger, bindLogger, mapLogger } from './Logger'; import { getLDAPConditionalSetting } from './getLDAPConditionalSetting'; interface ILDAPEntryCallback { (entry: ldapjs.SearchEntry): T | undefined; } interface ILDAPSearchEndCallback { (error?: any): void; } interface ILDAPSearchPageCallback { (result: ldapjs.SearchEntry[]): void; } interface ILDAPSearchAllCallbacks { dataCallback?: ILDAPSearchPageCallback; endCallback?: ILDAPSearchEndCallback; entryCallback?: ILDAPEntryCallback; } type ILDAPExtractedValue = string | Array; export class LDAPConnection { public ldapjs: any; public connected: boolean; public options: ILDAPConnectionOptions; public client: ldapjs.Client; private _receivedResponse: boolean; private _connectionTimedOut: boolean; private _connectionCallback: ILDAPCallback; private usingAuthentication: boolean; constructor() { this.ldapjs = ldapjs; this.connected = false; this._receivedResponse = false; this._connectionTimedOut = false; this.options = { host: settings.get('LDAP_Host') ?? '', port: settings.get('LDAP_Port') ?? 389, reconnect: settings.get('LDAP_Reconnect') ?? false, timeout: settings.get('LDAP_Timeout') ?? 60000, connectionTimeout: settings.get('LDAP_Connect_Timeout') ?? 1000, idleTimeout: settings.get('LDAP_Idle_Timeout') ?? 1000, encryption: settings.get('LDAP_Encryption') ?? 'plain', caCert: settings.get('LDAP_CA_Cert'), rejectUnauthorized: settings.get('LDAP_Reject_Unauthorized') || false, baseDN: settings.get('LDAP_BaseDN') ?? '', userSearchFilter: settings.get('LDAP_User_Search_Filter') ?? '', userSearchScope: settings.get('LDAP_User_Search_Scope') ?? 'sub', userSearchField: getLDAPConditionalSetting('LDAP_User_Search_Field') ?? '', searchPageSize: settings.get('LDAP_Search_Page_Size') ?? 250, searchSizeLimit: settings.get('LDAP_Search_Size_Limit') ?? 1000, uniqueIdentifierField: settings.get('LDAP_Unique_Identifier_Field'), groupFilterEnabled: settings.get('LDAP_Group_Filter_Enable') ?? false, groupFilterObjectClass: settings.get('LDAP_Group_Filter_ObjectClass'), groupFilterGroupIdAttribute: settings.get('LDAP_Group_Filter_Group_Id_Attribute'), groupFilterGroupMemberAttribute: settings.get('LDAP_Group_Filter_Group_Member_Attribute'), groupFilterGroupMemberFormat: settings.get('LDAP_Group_Filter_Group_Member_Format'), groupFilterGroupName: settings.get('LDAP_Group_Filter_Group_Name'), authentication: settings.get('LDAP_Authentication') ?? false, authenticationUserDN: settings.get('LDAP_Authentication_UserDN') ?? '', authenticationPassword: settings.get('LDAP_Authentication_Password') ?? '', attributesToQuery: this.parseAttributeList(settings.get('LDAP_User_Search_AttributesToQuery')), }; if (!this.options.host) { logger.warn('LDAP Host is not configured.'); } if (!this.options.baseDN) { logger.warn('LDAP Search BaseDN is not configured.'); } } public async connect(): Promise { return new Promise((resolve, reject) => { this.initializeConnection((error, result) => { if (error) { return reject(error); } return resolve(result); }); }); } public disconnect(): void { this.usingAuthentication = false; this.connected = false; connLogger.info('Disconnecting'); if (this.client) { this.client.unbind(); } } public async testConnection(): Promise { try { await this.connect(); await this.maybeBindDN(); } finally { this.disconnect(); } } public async searchByUsername(escapedUsername: string): Promise { const searchOptions: ldapjs.SearchOptions = { filter: this.getUserFilter(escapedUsername), scope: this.options.userSearchScope || 'sub', sizeLimit: this.options.searchSizeLimit, attributes: this.options.attributesToQuery, }; if (this.options.searchPageSize > 0) { searchOptions.paged = { pageSize: this.options.searchPageSize, pagePause: false, }; } searchLogger.info({ msg: 'Searching by username', username: escapedUsername, baseDN: this.options.baseDN, searchOptions, }); return this.search(this.options.baseDN, searchOptions); } public async findOneByUsername(username: string): Promise { const results = await this.searchByUsername(username); if (results.length === 1) { return results[0]; } } public async searchById(id: string, attribute?: string): Promise { const searchOptions: ldapjs.SearchOptions = { scope: this.options.userSearchScope || 'sub', attributes: this.options.attributesToQuery, }; if (attribute) { searchOptions.filter = new this.ldapjs.filters.EqualityFilter({ attribute, value: Buffer.from(id, 'hex'), }); } else if (this.options.uniqueIdentifierField) { // If we don't know what attribute the id came from, we have to look for all of them. const possibleFields = this.options.uniqueIdentifierField.split(',').concat(this.options.userSearchField.split(',')); const filters = []; for (const field of possibleFields) { if (!field) { continue; } filters.push( new this.ldapjs.filters.EqualityFilter({ attribute: field, value: Buffer.from(id, 'hex'), }), ); } searchOptions.filter = new this.ldapjs.filters.OrFilter({ filters }); } else { throw new Error('Unique Identifier Field is not configured.'); } searchLogger.info({ msg: 'Searching by id', id }); searchLogger.debug({ msg: 'search filter', searchOptions, baseDN: this.options.baseDN }); return this.search(this.options.baseDN, searchOptions); } public async findOneById(id: string, attribute?: string): Promise { const results = await this.searchById(id, attribute); if (results.length === 1) { return results[0]; } } public async searchAllUsers({ dataCallback, endCallback, entryCallback, }: ILDAPSearchAllCallbacks): Promise { searchLogger.info('Searching all users'); const searchOptions: ldapjs.SearchOptions = { filter: this.getUserFilter('*'), scope: this.options.userSearchScope || 'sub', sizeLimit: this.options.searchSizeLimit, attributes: this.options.attributesToQuery, }; if (this.options.searchPageSize > 0) { let count = 0; await this.doPagedSearch( this.options.baseDN, searchOptions, this.options.searchPageSize, (error, entries: ldapjs.SearchEntry[], { end, next } = { end: false, next: undefined }) => { if (error) { endCallback?.(error); return; } count += entries.length; dataCallback?.(entries); if (end) { endCallback?.(); } if (next) { next(count); } }, entryCallback, ); return; } await this.doAsyncSearch( this.options.baseDN, searchOptions, (error, result) => { dataCallback?.(result); endCallback?.(error); }, entryCallback, ); } public async authenticate(dn: string, password: string): Promise { authLogger.info({ msg: 'Authenticating', dn }); try { await this.bindDN(dn, password); authLogger.info({ msg: 'Authenticated', dn }); return true; } catch (error) { authLogger.info({ msg: 'Not authenticated', dn }); authLogger.debug({ msg: 'error', error }); return false; } } public async search(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise { return this.doCustomSearch(baseDN, searchOptions, (entry) => this.extractLdapEntryData(entry)); } public async searchRaw(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise { return this.doCustomSearch(baseDN, searchOptions, (entry) => entry); } public async searchAndCount(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise { let count = 0; await this.doCustomSearch(baseDN, searchOptions, async () => { count++; }); return count; } public extractLdapAttribute(value: Buffer | Buffer[] | string): ILDAPExtractedValue { if (Array.isArray(value)) { return value.map((item) => this.extractLdapAttribute(item)); } if (value instanceof Buffer) { return value.toString(); } return value; } public extractLdapEntryData(entry: ldapjs.SearchEntry): ILDAPEntry { const values: ILDAPEntry = { _raw: entry.raw, }; Object.keys(values._raw).forEach((key) => { values[key] = this.extractLdapAttribute(values._raw[key]); const dataType = typeof values[key]; // eslint-disable-next-line no-control-regex if (dataType === 'string' && values[key].length > 100 && /[\x00-\x1F]/.test(values[key])) { mapLogger.debug({ msg: 'Extracted Attribute', key, type: dataType, length: values[key].length, value: `${values[key].substr(0, 100)}...`, }); return; } mapLogger.debug({ msg: 'Extracted Attribute', key, type: dataType, value: values[key] }); }); return values; } public async doCustomSearch(baseDN: string, searchOptions: ldapjs.SearchOptions, entryCallback: ILDAPEntryCallback): Promise { await this.runBeforeSearch(searchOptions); if (!searchOptions.scope) { searchOptions.scope = this.options.userSearchScope || 'sub'; } searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN }); let realEntries = 0; return new Promise((resolve, reject) => { this.client.search(baseDN, searchOptions, (error, res: ldapjs.SearchCallbackResponse) => { if (error) { searchLogger.error(error); reject(error); return; } res.on('error', (error) => { searchLogger.error(error); reject(error); }); const entries: T[] = []; res.on('searchEntry', (entry) => { try { const result = entryCallback(entry); if (result) { entries.push(result as T); } realEntries++; } catch (e) { searchLogger.error(e); throw e; } }); res.on('end', () => { searchLogger.info(`LDAP Search found ${realEntries} entries and loaded the data of ${entries.length}.`); resolve(entries); }); }); }); } /* Create an LDAP search filter based on the username */ public getUserFilter(username: string): string { const filter: string[] = []; this.addUserFilters(filter, username); const usernameFilter = this.options.userSearchField.split(',').map((item) => `(${item}=${username})`); if (usernameFilter.length === 0) { logger.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('')})`; } public async isUserAcceptedByGroupFilter(username: string, userdn: string): Promise { if (!this.options.groupFilterEnabled) { return true; } const filter = ['(&']; if (this.options.groupFilterObjectClass) { filter.push(`(objectclass=${this.options.groupFilterObjectClass})`); } if (this.options.groupFilterGroupMemberAttribute) { filter.push(`(${this.options.groupFilterGroupMemberAttribute}=${this.options.groupFilterGroupMemberFormat})`); } if (this.options.groupFilterGroupIdAttribute) { filter.push(`(${this.options.groupFilterGroupIdAttribute}=${this.options.groupFilterGroupName})`); } filter.push(')'); const searchOptions: ldapjs.SearchOptions = { filter: filter .join('') .replace(/#{username}/g, username) .replace(/#{userdn}/g, userdn), scope: 'sub', }; searchLogger.debug({ msg: 'Group filter LDAP:', filter: searchOptions.filter }); const result = await this.searchRaw(this.options.baseDN, searchOptions); if (!Array.isArray(result) || result.length === 0) { return false; } return true; } protected addUserFilters(filters: string[], _username: string): void { const { userSearchFilter } = this.options; if (userSearchFilter !== '') { if (userSearchFilter[0] === '(') { filters.push(`${userSearchFilter}`); } else { filters.push(`(${userSearchFilter})`); } } } public async bindDN(dn: string, password: string): Promise { return new Promise((resolve, reject) => { try { this.client.bind(dn, password, (error) => { if (error) { return reject(error); } resolve(); }); } catch (error) { reject(error); } }); } private async doAsyncSearch( baseDN: string, searchOptions: ldapjs.SearchOptions, callback: ILDAPCallback, entryCallback?: ILDAPEntryCallback, ): Promise { await this.runBeforeSearch(searchOptions); searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN }); this.client.search(baseDN, searchOptions, (error: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse): void => { if (error) { searchLogger.error(error); callback(error); return; } res.on('error', (error) => { searchLogger.error(error); callback(error); }); const entries: T[] = []; res.on('searchEntry', (entry) => { try { const result = entryCallback ? entryCallback(entry) : entry; entries.push(result as T); } catch (e) { searchLogger.error(e); throw e; } }); res.on('end', () => { searchLogger.info({ msg: 'Search result count', count: entries.length }); callback(null, entries); }); }); } private processSearchPage( { entries, title, end, next }: { entries: T[]; title: string; end: boolean; next?: () => void }, callback: ILDAPPageCallback, ): void { searchLogger.info(title); // Force LDAP idle to wait the record processing this._updateIdle(true); callback(null, entries, { end, next: () => { // Reset idle timer this._updateIdle(); next?.(); }, }); } private async doPagedSearch( baseDN: string, searchOptions: ldapjs.SearchOptions, pageSize: number, callback: ILDAPPageCallback, entryCallback?: ILDAPEntryCallback, ): Promise { searchOptions.paged = { pageSize, pagePause: true, }; await this.runBeforeSearch(searchOptions); searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN }); this.client.search(baseDN, searchOptions, (error: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse): void => { if (error) { searchLogger.error(error); callback(error); return; } res.on('error', (error) => { searchLogger.error(error); callback(error); }); let entries: T[] = []; const internalPageSize = pageSize * 2; res.on('searchEntry', (entry) => { try { const result = entryCallback ? entryCallback(entry) : entry; entries.push(result as T); if (entries.length >= internalPageSize) { this.processSearchPage( { entries, title: 'Internal Page', end: false, }, callback, ); entries = []; } } catch (e) { searchLogger.error(e); throw e; } }); res.on('page', (_result, next) => { if (!next) { this._updateIdle(true); this.processSearchPage( { entries, title: 'Final Page', end: true, }, callback, ); entries = []; } else if (entries.length) { this.processSearchPage( { entries, title: 'Page', end: false, next, }, callback, ); entries = []; } }); res.on('end', () => { if (entries.length) { this.processSearchPage( { entries, title: 'Final Page', end: true, }, callback, ); entries = []; } }); }); } private _updateIdle(override?: boolean): void { // @ts-expect-error use a private function to signal to the lib that we're still working this.client._updateIdle(override); } protected async maybeBindDN(): Promise { if (this.usingAuthentication) { return; } if (!this.options.authentication) { return; } if (!this.options.authenticationUserDN) { logger.error('Invalid UserDN for authentication'); return; } bindLogger.info({ msg: 'Binding UserDN', userDN: this.options.authenticationUserDN }); try { await this.bindDN(this.options.authenticationUserDN, this.options.authenticationPassword); this.usingAuthentication = true; } catch (error) { authLogger.error({ msg: 'Base Authentication Issue', err: error, dn: this.options.authenticationUserDN, }); this.usingAuthentication = false; } } protected async runBeforeSearch(_searchOptions: ldapjs.SearchOptions): Promise { return this.maybeBindDN(); } /* Get list of options to initialize a new ldapjs Client */ private getClientOptions(): { clientOptions: ldapjs.ClientOptions; tlsOptions: Record; } { const clientOptions: ldapjs.ClientOptions = { url: `${this.options.host}:${this.options.port}`, timeout: this.options.timeout, connectTimeout: this.options.connectionTimeout, idleTimeout: this.options.idleTimeout, reconnect: this.options.reconnect, log: connLogger, }; const tlsOptions: Record = { rejectUnauthorized: this.options.rejectUnauthorized, }; if (this.options.caCert) { // Split CA cert into array of strings const chainLines = this.options.caCert.split('\n'); let cert: string[] = []; const ca: string[] = []; 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') { clientOptions.url = `ldaps://${clientOptions.url}`; clientOptions.tlsOptions = tlsOptions; } else { clientOptions.url = `ldap://${clientOptions.url}`; } return { clientOptions, tlsOptions, }; } private handleConnectionResponse(error: any, response?: any): void { if (!this._receivedResponse) { this._receivedResponse = true; this._connectionCallback(error, response); return; } if (this._connectionTimedOut && !error) { connLogger.info('Received a response after the connection timedout.'); } else { logger.debug('Ignored error/response:'); } if (error) { connLogger.debug(error); } else { connLogger.debug(response); } } private initializeConnection(callback: ILDAPCallback): void { connLogger.info('Init Setup'); this._receivedResponse = false; this._connectionTimedOut = false; this._connectionCallback = callback; const { clientOptions, tlsOptions } = this.getClientOptions(); connLogger.info({ msg: 'Connecting', url: clientOptions.url }); connLogger.debug({ msg: 'clientOptions', clientOptions }); this.client = ldapjs.createClient(clientOptions); this.client.on('error', (error) => { connLogger.error(error); this.handleConnectionResponse(error, null); }); this.client.on('idle', () => { searchLogger.info('Idle'); this.disconnect(); }); this.client.on('close', () => { searchLogger.info('Closed'); }); if (this.options.encryption === 'tls') { // Set host parameter for tls.connect which is used by ldapjs starttls. This may not be needed anymore // https://github.com/RocketChat/Rocket.Chat/issues/2035 // https://github.com/mcavage/node-ldapjs/issues/349 tlsOptions.host = this.options.host; connLogger.info('Starting TLS'); connLogger.debug({ msg: 'tlsOptions', tlsOptions }); this.client.starttls(tlsOptions, null, (error, response) => { if (error) { connLogger.error({ msg: 'TLS connection', error }); return this.handleConnectionResponse(error, null); } connLogger.info('TLS connected'); this.connected = true; this.handleConnectionResponse(null, response); }); } else { this.client.on('connect', (response) => { connLogger.info('LDAP connected'); this.connected = true; this.handleConnectionResponse(null, response); }); } setTimeout(() => { if (!this._receivedResponse) { connLogger.error({ msg: 'connection time out', timeout: clientOptions.connectTimeout }); this.handleConnectionResponse(new Error('Timeout')); this._connectionTimedOut = true; } }, clientOptions.connectTimeout); } private parseAttributeList(csv: string | undefined): Array { if (!csv) { return ['*', '+']; } const list = csv.split(',').map((item) => item.trim()); if (!list?.length) { return ['*', '+']; } return list; } }