UNPKG

ldapjs-promise

Version:
517 lines (459 loc) 18.2 kB
'use strict'; const ldap = require('ldapjs'); const Control = require('ldapjs').Control; const { EventEmitter } = require('events'); class LdapClient extends EventEmitter { /** * Constructs a new client. * * The options object is required, and must contain either a URL (string) or * a socketPath (string); the socketPath is only if you want to talk to an LDAP * server over a Unix Domain Socket. * * @param {Object} options must have either url or socketPath. * @throws {Error} When an invalid configuration object is supplied. */ constructor(options) { super(); this.log = options.log || require('abstract-logging'); if (!this.log.child) { this.log.child = () => { return this.log; } } this.log.child({ module: 'ldapjs-promise', clazz: 'client' }); this.client = ldap.createClient(options); } addListener(eventName, listener) { return this.on(eventName, listener); } on(eventName, listener) { return this.client.on(eventName, listener); } once(eventName, listener) { return this.client.once(eventName, listener); } removeListener(eventName, listener) { return this.client.removeListener(eventName, listener); } removeAllListeners(eventName) { return this.client.removeAllListeners(eventName); } off(eventName, listener) { return this.removeListener(eventName, listener); } setMaxListeners(n) { this.client.setMaxListeners(n); } getMaxListeners() { return this.client.getMaxListeners(); } listeners(eventName) { return this.client.listeners(eventName); }; rawListeners(eventName) { return this.client.rawListeners(eventName); } listenerCount(eventName) { return this.client.listenerCount(eventName); } prependListener(eventName, listener) { return this.client.prependListener(eventName, listener); } prependOnceListener(eventName, listener) { return this.client.prependOnceListener(eventName, listener); } eventNames() { return this.client.eventNames(); } /** * Performs a simple authentication against the server. * * @param {String} name the DN to bind as. * @param {String} credentials the userPassword associated with name. * @param {Control} controls (optional) either a Control or [Control]. * @throws {TypeError} on invalid input. */ bind(name, credentials, controls) { if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.bind(name, credentials, controls, error => { if (error) { this.log.trace('bind error: %s', error.message); this.unbind(); return reject(error); } this.log.trace('bind successful for user: %s', name); return resolve(); }); }); } /** * Unbinds this client from the LDAP server. */ unbind() { return new Promise((resolve, reject) => { this.client.unbind(error => { if (error) { this.log.trace('unbind error: %s', error.message); return reject(error); } return resolve(); }); }); } /** * Sends an abandon request to the LDAP server. * * @param {Number} messageID the messageID to abandon. * @param {Control} controls (optional) either a Control or [Control]. * @throws {TypeError} on invalid input. */ abandon(messageID, controls) { if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.abandon(messageID, controls, error => { if (error) { this.log.trace('abandon error: %s', error.message); return reject(error); } this.log.trace('abandon successful for: %s', messageID); return resolve(); }); }); } /** * Adds an entry to the LDAP server. * * Entry can be either [Attribute] or a plain JS object where the * values are either a plain value or an array of values. Any value (that is * not an array) will get converted to a string, so keep that in mind. * * @param {String} name the DN of the entry to add. * @param {Object} entry an array of Attributes to be added or a JS object. * @param {Control} controls (optional) either a Control or [Control]. * @throws {TypeError} on invalid input. */ add(name, entry, controls) { if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.add(name, entry, controls, error => { if (error) { this.log.trace('add error: %s', error.message); return reject(error); } this.log.trace('add successful for: %s', name); return resolve(); }); }); } /** * Compares an attribute/value pair with an entry on the LDAP server. * * @param {String} name the DN of the entry to compare attributes with. * @param {String} attr name of an attribute to check. * @param {String} value value of an attribute to check. * @param {Control} controls (optional) either a Control or [Control]. * @throws {TypeError} on invalid input. */ compare(name, attr, value, controls) { if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.compare(name, attr, value, controls, (error, matched) => { if (error) { this.log.trace('compare error: %s', error.message); return reject(error); } return resolve(matched); }); }); } /** * Deletes an entry from the LDAP server. * * @param {String} name the DN of the entry to delete. * @param {Control} controls (optional) either a Control or [Control]. * @throws {TypeError} on invalid input. */ del(name, controls) { if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.del(name, controls, error => { if (error) { this.log.trace('del error: %s', error.message); return reject(error); } return resolve(); }); }); } /** * Performs an extended operation on the LDAP server. * * @param {String} name the OID of the extended operation to perform. * @param {String} value value to pass in for this operation. * @param {Control} controls (optional) either a Control or [Control]. * @throws {TypeError} on invalid input. */ exop(name, value, controls) { if (typeof value === 'undefined') { value = ''; controls = []; } else if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.exop(name, value, controls, (error, resValue, response) => { if (error) { this.log.trace('exop error: %s', error.message); return reject(error); } return resolve({ value: resValue, response: response }); }); }); } /** * Performs an LDAP modify against the server. * * @param {String} name the DN of the entry to modify. * @param {Change} change update to perform (can be [Change]). * @param {Control} controls (optional) either a Control or [Control]. * @throws {TypeError} on invalid input. */ modify(name, change, controls) { if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.modify(name, change, controls, (error) => { if (error) { this.log.trace('modify error: %s', error.message); return reject(error); } return resolve(); }); }); } /** * Performs an LDAP modifyDN against the server. * * This does not allow you to keep the old DN, as while the LDAP protocol * has a facility for that, it's stupid. Just Search/Add. * * This will automatically deal with "new superior" logic. * * @param {String} name the DN of the entry to modify. * @param {String} newName the new DN to move this entry to. * @param {Control} controls (optional) either a Control or [Control]. * @throws {TypeError} on invalid input. */ modifyDN(name, newName, controls) { if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.modifyDN(name, newName, controls, (error) => { if (error) { this.log.trace('modifyDN error: %s', error.message); return reject(error); } return resolve(); }); }); } /** * Performs an LDAP search against the server. * * Note that the defaults for options are a 'base' search, if that's what * you want you can just pass in a string for options and it will be treated * as the search filter. Also, you can either pass in programatic Filter * objects or a filter string as the filter option. * * Note that this method is 'special' in that the returned response will * have two important events on it, namely 'entry' and 'end' that you can hook * to. The former will emit a SearchEntry object for each record that comes * back, and the latter will emit a normal LDAPResult object. * * @param {String} base the DN in the tree to start searching at. * @param {Object} options parameters: * - {String} scope default of 'base'. * - {String} filter default of '(objectclass=*)'. * - {Array} attributes [string] to return. * - {Boolean} attrsOnly whether to return values. * @param {Control} controls (optional) either a Control or [Control]. * @returns * @throws {TypeError} on invalid input. */ search(base, options, controls) { if (Array.isArray(options) || (options instanceof Control)) { controls = options; options = {}; } else if (typeof options === 'undefined') { controls = []; options = { filter: new ldap.filters.PresenceFilter({attribute: 'objectclass'}) }; } if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.search(base, options, controls, (error, response) => { if (error) { this.log.trace('search error: %s', error.message); return reject(error); } return resolve(response); }); }); } /** * Performs an LDAP search against the server. * * Note that the defaults for options are a 'base' search, if that's what * you want you can just pass in a string for options and it will be treated * as the search filter. Also, you can either pass in programatic Filter * objects or a filter string as the filter option. * * @param {String} base the DN in the tree to start searching at. * @param {Object} options parameters: * - {String} scope default of 'base'. * - {String} filter default of '(objectclass=*)'. * - {Array} attributes [string] to return. * - {Boolean} attrsOnly whether to return values. * @param {Control} controls (optional) either a Control or [Control]. * @returns {Object} of entries and referrals. * @throws {TypeError} on invalid input. */ async searchReturnAll(base, options, controls) { const response = await this.search(base, options, controls); const entries = []; let referrals = []; return new Promise((resolve, reject) => { response.on('searchEntry', entry => { entries.push(entry); }); response.on('searchReference', referral => { referrals = referrals.concat(referral.uris); }); response.on('error', error => { if (error.name === 'SizeLimitExceededError' && options.sizeLimit && options.sizeLimit > 0) { return resolve({ entries: entries, referrals: referrals }); } else { return reject(error); } }) response.on('end', result => { if (result.status !== 0) { return reject(result.status); } return resolve({ entries: entries, referrals: referrals }); }); }); } /** * Attempt to secure connection with StartTLS. * * @param {Object} options * @param {Control} controls (optional) either a Control or [Control]. */ starttls(options, controls) { if (typeof controls === 'undefined') { controls = []; } return new Promise((resolve, reject) => { this.client.starttls(options, controls, error => { if (error) { this.log.trace('starttls error: %s', error.message); return reject(error); } return resolve(); }); }); } /** * Disconnect from the LDAP server and do not allow reconnection. * * If the client is instantiated with proper reconnection options, it's * possible to initiate new requests after a call to unbind since the client * will attempt to reconnect in order to fulfill the request. * * Calling destroy will prevent any further reconnection from occurring. * * @param {Object} error (Optional) error that was cause of client destruction */ destroy(error) { this.client.destroy(error); } /** * Initiate LDAP connection. */ connect() { this.client.connect(); } /** * Performs a search of the directory to find the user identified by the * given username. * * @param {String} base the DN in the tree to start searching at. * @param {string} username Either a simple name, e.g. 'juser', or an LDAP * filter that should result in a single user. If it returns multiple users, * only the first result will be returned. If omitted, a filter must be * supplied in the `options`. Default: `(&(objectcategory=user)(sAMAccountName=username))`. * @param {Object} options Options to be used for the search. */ async findUser(base, username, options) { let filter = null; if (typeof username === 'string') { if (username.charAt(0) === '(') { this.log.trace('finding user via custom filter: %s', username); filter = username; } else { this.log.trace('finding user via default filter'); filter = `(&(objectcategory=user)(sAMAccountName=${username}))`; } } const results = await this.searchReturnAll(base, Object.assign({filter}, options || username || {})) .catch(error => Promise.reject(error)); return results.entries[0]; } /** * Query the directory to determine if a user is a member of a specified group. * * @param {String} base the DN in the tree to start searching at. * @param {string} username A username as described in {@link LdapClient#findUser}. * @param {string} groupName The name of the group to verify. * @returns If the user is a member then `true`, otherwise `false`. */ async userInGroup(base, username, groupName) { this.log.trace('determining if user "%s" is in group: %s', username, groupName); const user = await this.findUser(base, username, {attributes: ['memberOf']}) .catch(error => Promise.reject(error)); groupName = groupName.toLowerCase(); const groups = user.attributes .filter(a => a.type === 'memberOf') .map(a => a.values).pop() .filter((group) => group.toLowerCase() === groupName); return groups.length > 0; } } module.exports = LdapClient;