UNPKG

@mntu/nestjs-ldap

Version:

NestJS library to access LDAP

1,172 lines (1,071 loc) 43.5 kB
import { LoggerService } from '@nestjs/common'; import { EventEmitter } from 'events'; import * as Ldap from 'ldapjs'; import type { LdapDomainsConfig, LoggerContext } from './ldap.interface'; import { ldapADattributes, LdapResponseObject, LdapResponseGroup, LdapResponseUser, LdapAddEntry, } from './ldap.interface'; import { Change } from './ldap/change'; export class LdapDomain extends EventEmitter { public domainName: string; public hideSynchronization: boolean; private clientOpts: Ldap.ClientOptions; private bindDN: string; private bindCredentials: string; private adminClient: Ldap.Client; private adminBound: boolean; private userClient: Ldap.Client; private getGroups: ({ user, loggerContext, }: { user: LdapResponseUser; loggerContext?: LoggerContext; }) => Promise<LdapResponseGroup[]>; /** * Create an LDAP class. * * @param {LdapModuleOptions} opts Config options * @param {LogService} logger Logger service * @param {ConfigService} configService Config service * @constructor */ constructor( private readonly options: LdapDomainsConfig, private readonly logger: LoggerService, ) { super(); this.domainName = options.name; this.hideSynchronization = options.hideSynchronization ?? false; this.clientOpts = { url: options.url, tlsOptions: options.tlsOptions, socketPath: options.socketPath, log: options.log, timeout: options.timeout || 5000, connectTimeout: options.connectTimeout || 5000, idleTimeout: options.idleTimeout || 5000, reconnect: options.reconnect || true, strictDN: options.strictDN, queueSize: options.queueSize || 200, queueTimeout: options.queueTimeout || 5000, queueDisable: options.queueDisable || false, }; this.bindDN = options.bindDN; this.bindCredentials = options.bindCredentials; this.adminClient = Ldap.createClient(this.clientOpts); this.adminBound = false; this.userClient = Ldap.createClient(this.clientOpts); this.adminClient.on('connectError', this.handleConnectError.bind(this)); this.userClient.on('connectError', this.handleConnectError.bind(this)); this.adminClient.on('error', this.handleErrorAdmin.bind(this)); this.userClient.on('error', this.handleErrorUser.bind(this)); if (options.reconnect) { this.once('installReconnectListener', () => { this.logger.debug!({ message: `${options.name}: install reconnect listener`, context: LdapDomain.name, function: 'constructor', }); this.adminClient.on('connect', () => this.onConnectAdmin({})); }); } this.adminClient.on('connectTimeout', this.handleErrorAdmin.bind(this)); this.userClient.on('connectTimeout', this.handleErrorUser.bind(this)); if (options.groupSearchBase && options.groupSearchFilter) { if (typeof options.groupSearchFilter === 'string') { const { groupSearchFilter } = options; // eslint-disable-next-line no-param-reassign options.groupSearchFilter = (user: LdapResponseUser): string => groupSearchFilter .replace( /{{dn}}/g, ( options.groupDnProperty && (user[options.groupDnProperty] as string) ) ?.replace(/\(/, '\\(') ?.replace(/\)/, '\\)') || 'undefined', ) .replace(/{{username}}/g, user.sAMAccountName); } this.getGroups = this.findGroups; } else { // Assign an async identity function so there is no need to branch // the authenticate function to have cache set up. this.getGroups = async () => []; } } /** * Format a GUID * * @public * @param {string} objectGUID GUID in Active Directory notation * @returns {string} string GUID */ GUIDtoString = (objectGUID: string): string => (objectGUID && Buffer.from(objectGUID, 'base64') .toString('hex') .replace( /^(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)(..)$/, '$4$3$2$1-$6$5-$8$7-$10$9-$16$15$14$13$12$11', ) .toUpperCase()) || ''; /** * Ldap Date * @param {string} string */ dateFromString = (string: string): Date | null => { const b = string.match(/\d\d/g); return ( b && new Date( Date.UTC( Number.parseInt(b[0] + b[1], 10), Number.parseInt(b[2], 10) - 1, Number.parseInt(b[3], 10), Number.parseInt(b[4], 10), Number.parseInt(b[5], 10), Number.parseInt(b[6], 10), ), ) ); }; /** * Mark admin client unbound so reconnect works as expected and re-emit the error * * @private * @param {Ldap.Error} error The error to be logged and emitted * @returns {void} */ private handleErrorAdmin(error: Ldap.Error): void { if (`${error.code}` !== 'ECONNRESET') { this.logger.error({ message: `${this.domainName}: admin emitted error: [${error.code}]`, error, context: LdapDomain.name, function: 'handleErrorAdmin', }); } this.adminBound = false; } /** * Mark user client unbound so reconnect works as expected and re-emit the error * * @private * @param {Ldap.Error} error The error to be logged and emitted * @returns {void} */ private handleErrorUser(error: Ldap.Error): void { if (`${error.code}` !== 'ECONNRESET') { this.logger.error({ message: `${this.domainName}: user emitted error: [${error.code}]`, error, context: LdapDomain.name, function: 'handleErrorUser', }); } // this.adminBound = false; } /** * Connect error handler * * @private * @param {Ldap.Error} error The error to be logged and emitted * @returns {void} */ private handleConnectError(error: Ldap.Error): void { this.logger.error({ message: `${this.domainName}: emitted error: [${error.code}]`, error, context: LdapDomain.name, function: 'handleConnectError', }); } /** * Bind adminClient to the admin user on connect * * @private * @async * @returns {boolean | Error} */ private async onConnectAdmin({ loggerContext, }: { loggerContext?: LoggerContext; }): Promise<boolean> { // Anonymous binding if (typeof this.bindDN === 'undefined' || this.bindDN === null) { this.adminBound = false; throw new Error(`${this.domainName}: bindDN is undefined`); } return new Promise<boolean>((resolve, reject) => this.adminClient.bind( this.bindDN, this.bindCredentials, (error) => { if (error) { this.logger.error({ message: `${ this.domainName }: bind error: ${error.toString()}`, error, context: LdapDomain.name, function: 'onConnectAdmin', ...loggerContext, }); this.adminBound = false; return reject(error); } this.adminBound = true; if (this.options.reconnect) { this.emit('installReconnectListener'); } return resolve(true); }, ), ); } /** * Ensure that `this.adminClient` is bound. * * @private * @async * @returns {boolean | Error} */ private adminBind = async ({ loggerContext, }: { loggerContext?: LoggerContext; }): Promise<boolean> => this.adminBound ? true : this.onConnectAdmin({ loggerContext }); /** * Conduct a search using the admin client. Used for fetching both * user and group information. * * @private * @async * @param {string} searchBase LDAP search base * @param {Object} options LDAP search options * @param {string} options.filter LDAP search filter * @param {string} options.scope LDAP search scope * @param {(string[]|undefined)} options.attributes Attributes to fetch * @returns {undefined | Ldap.SearchEntryObject[]} * @throws {Error} */ private async search({ searchBase, options, loggerContext, }: { searchBase: string; options: Ldap.SearchOptions; loggerContext?: LoggerContext; }): Promise<LdapResponseObject[]> { return this.adminBind({ loggerContext }).then( () => new Promise<LdapResponseObject[]>((resolve, reject) => this.adminClient.search( searchBase, options, ( searchError: Ldap.Error | null, searchResult: Ldap.SearchCallbackResponse, ) => { if (searchError !== null) { return reject(searchError); } if (typeof searchResult !== 'object') { return reject( new Error( `The LDAP server has empty search: ${searchBase}, options=${JSON.stringify( options, )}`, ), ); } const items: LdapResponseObject[] = []; searchResult.on( 'searchEntry', (entry: Ldap.SearchEntry) => { const object = Object.keys( entry.object, ).reduce((accumulator, key) => { let k = key; if (key.endsWith(';binary')) { k = key.replace(/;binary$/, ''); } switch (k) { case 'objectGUID': return { ...accumulator, objectGUID: this.GUIDtoString( entry.object[ key ] as string, ), } as LdapResponseObject; case 'dn': return { ...accumulator, dn: ( entry.object[ key ] as string ).toLowerCase(), } as LdapResponseObject; case 'sAMAccountName': return { ...accumulator, sAMAccountName: ( entry.object[ key ] as string ).toLowerCase(), } as LdapResponseObject; case 'whenCreated': case 'whenChanged': return { ...accumulator, [k]: this.dateFromString( entry.object[ key ] as string, ), } as LdapResponseObject; default: } // 'thumbnailPhoto' and 'jpegPhoto' is falling there return { ...accumulator, [k]: entry.object[key], } as LdapResponseObject; }, {} as LdapResponseObject); items.push({ ...object, loginDomain: this.domainName, } as LdapResponseObject); // if (this.options.includeRaw === true) { // items[items.length - 1].raw = (entry.raw as unknown) as string; // } }, ); searchResult.on('error', (error: Ldap.Error) => { reject(error); }); searchResult.on( 'end', (result: Ldap.LDAPResult) => { if (result.status !== 0) { return reject( new Error( `non-zero status from LDAP search: ${result.status}`, ), ); } return resolve(items); }, ); return undefined; }, ), ), ); } /** * Sanitize LDAP special characters from input * * {@link https://tools.ietf.org/search/rfc4515#section-3} * * @private * @param {string} input String to sanitize * @returns {string} Sanitized string */ private sanitizeInput(input: string): string { return input .replace(/\*/g, '\\2a') .replace(/\(/g, '\\28') .replace(/\)/g, '\\29') .replace(/\\/g, '\\5c') .replace(/\0/g, '\\00') .replace(/\//g, '\\2f'); } /** * Find the user record for the given username. * * @private * @async * @param {string} username Username to search for * @returns {undefined} If user is not found but no error happened, result is undefined. * @throws {Error} */ private async findUser({ username, loggerContext, }: { username: string; loggerContext?: LoggerContext; }): Promise<LdapResponseUser> { if (!username) { throw new Error('empty username'); } const searchFilter = this.options.searchFilter.replace( /{{username}}/g, this.sanitizeInput(username), ); const options: Ldap.SearchOptions = { filter: searchFilter, scope: this.options.searchScope, attributes: ldapADattributes, timeLimit: this.options.timeLimit || 10, sizeLimit: this.options.sizeLimit || 0, paged: false, }; if (this.options.searchAttributes) { options.attributes = this.options.searchAttributes; } return this.search({ searchBase: this.options.searchBase, options, loggerContext, }) .then( (result) => new Promise<LdapResponseUser>((resolve, reject) => { if (!result) { return reject(new Ldap.NoSuchObjectError()); } switch (result.length) { case 0: return reject(new Ldap.NoSuchObjectError()); case 1: return resolve(result[0] as LdapResponseUser); default: return reject( new Error( `unexpected number of matches (${result.length}) for "${username}" username`, ), ); } }), ) .catch((error: Error) => { this.logger.error({ message: `${ this.domainName }: user search error: ${error.toString()}`, error, context: LdapDomain.name, function: 'findUser', ...loggerContext, }); throw error; }); } /** * Find groups for given user * * @private * @param {Ldap.SearchEntryObject} user The LDAP user object * @returns {Promise<Ldap.SearchEntryObject>} Result handling callback */ private async findGroups({ user, loggerContext, }: { user: LdapResponseUser; loggerContext?: LoggerContext; }): Promise<LdapResponseGroup[]> { if (!user) { throw new Error('no user'); } const searchFilter = typeof this.options.groupSearchFilter === 'function' ? this.options.groupSearchFilter(user) : undefined; const options: Ldap.SearchOptions = { filter: searchFilter, scope: this.options.groupSearchScope, timeLimit: this.options.timeLimit || 10, sizeLimit: this.options.sizeLimit || 0, paged: false, }; if (this.options.groupSearchAttributes) { options.attributes = this.options.groupSearchAttributes; } else { options.attributes = ldapADattributes; } return this.search({ searchBase: this.options.groupSearchBase || this.options.searchBase, options, loggerContext, }).catch((error: Error) => { this.logger.error({ message: `${ this.domainName }: group search error: ${error.toString()}`, error, context: LdapDomain.name, function: 'findGroups', ...loggerContext, }); return []; }); } /** * Search user by Username * * @async * @param {string} userByUsername user name * @returns {Promise<LdapResponseUser>} User in LDAP */ public async searchByUsername({ username, loggerContext, }: { username: string; loggerContext?: LoggerContext; }): Promise<LdapResponseUser | undefined> { return this.findUser({ username, loggerContext }).catch( (error: Error) => { this.logger.error({ message: `${ this.domainName }: Search by Username error: ${error.toString()}`, error, context: LdapDomain.name, function: 'searchByUsername', ...loggerContext, }); throw error; }, ); } /** * Search user by DN * * @async * @param {string} userByDN user distinguished name * @returns {Promise<LdapResponseUser>} User in LDAP */ public async searchByDN({ dn, loggerContext, }: { dn: string; loggerContext?: LoggerContext; }): Promise<LdapResponseUser> { const options: Ldap.SearchOptions = { scope: this.options.searchScope, attributes: ['*'], timeLimit: this.options.timeLimit || 10, sizeLimit: this.options.sizeLimit || 0, paged: false, }; if (this.options.searchAttributes) { options.attributes = this.options.searchAttributes; } return this.search({ searchBase: dn, options, loggerContext }) .then( (result) => new Promise<LdapResponseUser>((resolve, reject) => { if (!result) { return reject(new Error('No result from search')); } switch (result.length) { case 0: return reject(new Ldap.NoSuchObjectError()); case 1: return resolve(result[0] as LdapResponseUser); default: return reject( new Error( `unexpected number of matches (${result.length}) for "${dn}" user DN`, ), ); } }), ) .catch((error: Error | Ldap.NoSuchObjectError) => { if (error instanceof Ldap.NoSuchObjectError) { throw error; } else { this.logger.error({ message: `${ this.domainName }: Search by DN error: ${error.toString()}`, error, context: LdapDomain.name, function: 'searchByDN', ...loggerContext, }); throw error; } }); } /** * Synchronize users * * @async * @returns {Record<string, LdapResponseUser[]>} User in LDAP * @throws {Error} */ public async synchronization({ loggerContext, }: { loggerContext?: LoggerContext; }): Promise<Record<string, Error | LdapResponseUser[]>> { if (this.hideSynchronization) { return {}; } const options: Ldap.SearchOptions = { filter: this.options.searchFilterAllUsers, scope: this.options.searchScopeAllUsers, attributes: ldapADattributes, timeLimit: this.options.timeLimit || 10, sizeLimit: this.options.sizeLimit || 0, paged: true, }; if (this.options.searchAttributesAllUsers) { options.attributes = this.options.searchAttributesAllUsers; } return this.search({ searchBase: this.options.searchBase, options, loggerContext, }) .then(async (sync) => { if (sync) { const usersWithGroups = await Promise.all( sync.map(async (user) => ({ ...user, groups: await this.getGroups({ user: user as LdapResponseUser, loggerContext, }), })), ); return { [this.domainName]: usersWithGroups as LdapResponseUser[], }; } this.logger.error({ message: `${this.domainName}: Synchronize unknown error`, error: 'Unknown', context: LdapDomain.name, function: 'synchronization', ...loggerContext, }); return { [this.domainName]: new Error( `${this.domainName}: Synchronize unknown error`, ), }; }) .catch((error: Error | Ldap.Error) => { this.logger.error({ message: `${ this.domainName }: Synchronize error: ${error.toString()}`, error, context: LdapDomain.name, function: 'synchronization', ...loggerContext, }); return { [this.domainName]: error }; }); } /** * Synchronize groups * * @async * @returns {Record<string, LdapResponseGroup[]>} Group in LDAP * @throws {Error} */ public async synchronizationGroups({ loggerContext, }: { loggerContext?: LoggerContext; }): Promise<Record<string, Error | LdapResponseGroup[]>> { const options: Ldap.SearchOptions = { filter: this.options.searchFilterAllGroups, scope: this.options.groupSearchScope, attributes: ldapADattributes, timeLimit: this.options.timeLimit || 10, sizeLimit: this.options.sizeLimit || 0, paged: true, }; if (this.options.groupSearchAttributes) { options.attributes = this.options.groupSearchAttributes; } return this.search({ searchBase: this.options.searchBase, options, loggerContext, }) .then((sync) => { if (sync) { return { [this.domainName]: sync as LdapResponseGroup[] }; } this.logger.error({ message: `${this.domainName}: Synchronization groups: unknown error`, error: 'Unknown', context: LdapDomain.name, function: 'synchronizationGroups', ...loggerContext, }); return { [this.domainName]: new Error( `${this.domainName}: Synchronization groups: unknown error`, ), }; }) .catch((error: Error) => { this.logger.error({ message: `${ this.domainName }: Synchronization groups: ${error.toString()}`, error, context: LdapDomain.name, function: 'synchronizationGroups', ...loggerContext, }); return { [this.domainName]: error }; }); } /** * Modify using the admin client. * * @public * @async * @param {string} dn LDAP Distiguished Name * @param {Change[]} data LDAP modify data * @param {string} username The optional parameter * @param {string} password The optional parameter * @returns {boolean} The result * @throws {Ldap.Error} */ public async modify({ dn, data, username, password, loggerContext, }: { dn: string; data: Change[]; username?: string; password?: string; loggerContext?: LoggerContext; }): Promise<boolean> { return this.adminBind({ loggerContext }).then( () => new Promise<boolean>((resolve, reject) => { if (password) { // If a password, then we try to connect with user's login and password, and try to modify this.userClient.bind(dn, password, (error): any => { data.forEach((d, i, a) => { if ( d.modification.type === 'thumbnailPhoto' || d.modification.type === 'jpegPhoto' ) { // eslint-disable-next-line no-param-reassign a[i].modification.vals = '...skipped...'; } }); if (error) { this.logger.error({ message: `${ this.domainName }: bind error: ${error.toString()}`, error, context: LdapDomain.name, function: 'modify', ...loggerContext, }); return reject(error); } return this.userClient.modify( dn, data, async ( searchError: Ldap.Error | null, ): Promise<void> => { if (searchError) { this.logger.error({ message: `${ this.domainName }: Modify error "${dn}": ${searchError.toString()}`, error: searchError, context: LdapDomain.name, function: 'modify', ...loggerContext, }); reject(searchError); } this.logger.debug!({ message: `${this.domainName}: Modify success "${dn}"`, context: LdapDomain.name, function: 'modify', ...loggerContext, }); resolve(true); }, ); }); } else { this.adminClient.modify( dn, data, async ( searchError: Ldap.Error | null, ): Promise<void> => { data.forEach((d, i, a) => { if ( d.modification.type === 'thumbnailPhoto' || d.modification.type === 'jpegPhoto' ) { // eslint-disable-next-line no-param-reassign a[i].modification.vals = '...skipped...'; } }); if (searchError) { this.logger.error({ message: `${ this.domainName }: Modify error "${dn}": ${searchError.toString()}`, error: searchError, context: LdapDomain.name, function: 'modify', ...loggerContext, }); reject(searchError); return; } this.logger.debug!({ message: `${ this.domainName }: Modify success "${dn}": ${JSON.stringify( data, )}`, context: LdapDomain.name, function: 'modify', ...loggerContext, }); resolve(true); }, ); } }), ); } /** * Authenticate given credentials against LDAP server (Internal) * * @async * @param {string} username The username to authenticate * @param {string} password The password to verify * @returns {LdapResponseUser} User in LDAP * @throws {Error} */ public async authenticate({ username, password, loggerContext, }: { username: string; password: string; loggerContext?: LoggerContext; }): Promise<LdapResponseUser> { if (!password) { this.logger.error({ message: `${this.domainName}: No password given`, error: 'No password given', context: LdapDomain.name, function: 'authenticate', ...loggerContext, }); throw new Error(`${this.domainName}: No password given`); } try { // 1. Find the user DN in question. const foundUser = await this.findUser({ username, loggerContext, }).catch((error: Error) => { this.logger.error({ message: `${this.domainName}: Not found user: "${username}"`, error, context: LdapDomain.name, function: 'authenticate', ...loggerContext, }); throw error; }); if (!foundUser) { this.logger.error({ message: `${this.domainName}: Not found user: "${username}"`, error: 'Not found user', context: LdapDomain.name, function: 'authenticate', ...loggerContext, }); throw new Error(`Not found user: "${username}"`); } // 2. Attempt to bind as that user to check password. return new Promise<LdapResponseUser>((resolve, reject) => { this.userClient.bind( foundUser[this.options.bindProperty || 'dn'], password, async (bindError): Promise<unknown | LdapResponseUser> => { if (bindError) { this.logger.error({ message: `${ this.domainName }: bind error: ${bindError.toString()}`, error: bindError, context: LdapDomain.name, function: 'authenticate', ...loggerContext, }); return reject(bindError); } // 3. If requested, fetch user groups try { foundUser.groups = await this.getGroups({ user: foundUser, loggerContext, }); return resolve(foundUser); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.toString() : JSON.stringify(error); this.logger.error({ message: `${this.domainName}: Authenticate error: ${errorMessage}`, error, context: LdapDomain.name, function: 'authenticate', ...loggerContext, }); return reject(error); } }, ); }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.toString() : JSON.stringify(error); this.logger.error({ message: `${this.domainName}: LDAP auth error: ${errorMessage}`, error, context: LdapDomain.name, function: 'authenticate', ...loggerContext, }); throw error; } } /** * Trusted domain * * @async * @returns {LdapTrustedDomain} ? * @throws {Error} */ public async trustedDomain({ searchBase, loggerContext, }: { searchBase: string; loggerContext?: LoggerContext; }): Promise<any> { const options: Ldap.SearchOptions = { filter: '(&(objectClass=trustedDomain))', scope: this.options.searchScope, attributes: ldapADattributes, timeLimit: this.options.timeLimit || 10, sizeLimit: this.options.sizeLimit || 0, paged: false, }; const trustedDomain = await this.search({ searchBase, options, loggerContext, }); return trustedDomain; } /** * This is add a LDAP object * * @async * @param {Record<string, string>} value * @returns {LdapResponseUser} User | Profile in LDAP * @throws {Error} */ public async add({ entry, loggerContext, }: { entry: LdapAddEntry; loggerContext?: LoggerContext; }): Promise<LdapResponseUser> { return this.adminBind({ loggerContext }).then( () => new Promise<LdapResponseUser>((resolve, reject) => { if (!this.options.newObject) { throw new Error('ADD operation not available'); } const dn = `uid=${this.sanitizeInput( entry.uid as string, )},${this.sanitizeInput(this.options.newObject)}`; this.adminClient.add(dn, entry, (error: Error) => { if (error) { return reject(error); } return resolve(this.searchByDN({ dn, loggerContext })); }); }), ); } /** * Unbind connections * * @async * @returns {Promise<boolean>} */ public async close(): Promise<boolean> { // It seems to be OK just to call unbind regardless of if the // client has been bound (e.g. how ldapjs pool destroy does) return new Promise<boolean>((resolve) => { this.adminClient.unbind(() => { this.logger.debug!({ message: `${this.domainName}: adminClient: close`, context: LdapDomain.name, function: 'close', }); this.userClient.unbind(() => { this.logger.debug!({ message: `${this.domainName}: userClient: close`, context: LdapDomain.name, function: 'close', }); resolve(true); }); }); }); } }