PooledLdapConnection.js

/**
 * Copyright (c) 2016, Foothill-De Anza Community College District
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification,
 * are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice,
 * this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation and/or
 * other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its contributors
 * may be used to endorse or promote products derived from this software without
 * specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
 * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
 * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

'use strict';
let Errors = require('./Errors');
let EventLogger = require('./EventLogger');
let Ldap = require('ldapjs');
let Pool = require('pool2');
let Promise = require('bluebird');
let OS = require('os');

/**
 * Sensible defaults for setting up the connection pool if none
 * are provided by the developer
 * @private
 */
let defaultPoolOptions = {
    idleTimeout: 60000 * 5,
    min: 0,
    max: OS.cpus().length,
    maxRequests: Infinity,
    requestTimeout: Infinity,
    syncTimeout: 30 * 1000
};

class PooledLdapConnection {

    /**
     * Create a new connection pool bound to the LDAP server described by the
     * provided URL.
     * @param {String} url ldap:// or ldaps:// URL to the target host
     * @param {String} bindDn DN or username of the user to perform an initial bind
     * @param {String} bindCredential Password for the initial bind
     * @param {Object} [tlsOptions=null] If using LDAPS, provide a standard Node.js tlsOptions object
     */
    constructor(url, bindDn, bindCredential, tlsOptions=null, poolOptions={}) {
        // Add parameter values to instance
        this.ldapUrl = url;
        this.ldapBindDn = bindDn;
        this.ldapBindCredential = bindCredential;
        this.ldapTlsOptions = tlsOptions;
        
        // Take the default pooling options, and apply any developer overrides
        let finalPoolOptions = Object.assign({}, defaultPoolOptions, poolOptions); 

        // Create, promisify, and configure an object pool for managing multiple client connections
        this.pool = Promise.promisifyAll(new Pool({

            // Pool behavior configuration
            idleTimeout: finalPoolOptions.idleTimeout,
            min: finalPoolOptions.min,
            max: finalPoolOptions.max,
            maxRequests: finalPoolOptions.maxRequests,
            requestTimeout: finalPoolOptions.requestTimeout,
            syncTimeout: finalPoolOptions.syncTimeout,

            acquire: function(callback) {
                // Create an LDAP client
                let client = Ldap.createClient({
                    url: url,
                    tlsOptions: tlsOptions
                });

                // Add configuration properties to client object
                client.config = {
                    url: url
                };

                // Attach an error handler to the client
                client.on('error', function(error) {
                    // Log
                    EventLogger.log(`Encountered a serious error on LDAP client for ${url}`, { error: error });

                    // Raise error up the stack to be dealt with
                    throw error;
                });

                // Attach an error handler to the client
                client.on('destroy', function() {
                    // Log
                    EventLogger.log(`Destroyed LDAP client connection ${url}`);
                });

                // Create an dispose function to take the LDAP connection out of the pool and disconnect it
                client.dispose = () => {
                    this.remove(client);
                };

                // Create an release function for putting the LDAP connection back into the pool
                client.release = () => {
                    EventLogger.log(`Releasing LDAP connection ${url} back to the connection pool`);
                    this.release(client);
                };

                // Bind using configured credential
                client.bind(
                    bindDn,
                    bindCredential,
                    error => {
                        EventLogger.log(`Created new LDAP connection ${url} for connection pool`);

                        // Notify pool that the newly connected client is ready
                        callback(error, client);
                    });

                // Create promisified 'add' function
                client.addAsync = Promise.promisify(client.add);

                // Create promisified 'bind' function
                client.bindAsync = Promise.promisify(client.bind);

                // Create promisified 'compare' function
                client.compareAsync = Promise.promisify(client.compare);

                // Create promisified 'del' function
                client.delAsync = Promise.promisify(client.del);

                // Create promisified 'exop' function
                client.exopAsync = Promise.promisify(client.exop);

                // Create promisified 'modify' function
                client.modifyAsync = Promise.promisify(client.modify);

                // Create promisified 'search' function
                client.searchAsync = Promise.promisify(client.search);
            },

            // Called when an LDAP client has become stale and should be removed from the pool
            dispose: function(ldapClient, callback) {                
                EventLogger.log(`Disposing of LDAP client connection ${ldapClient.config.url}`);

                // Destroy client
                ldapClient.destroy();
                callback();
            }

        }));
    }

    /**
     * Get an LDAP connection from the pool, and if necessary, creating a new
     * connection.
     * @returns {Promise} Resolved with a connection from the pool
     */
    acquire() {
        return this.pool.acquireAsync();
    }

    /**
     * Add an entry to the LDAP directory.
     * @param {String} dn Distinguished name for the new entry
     * @param {Object} attributes Key/values for the new attributes
     * @param {Control|Control[]} controls Optional LDAP controls to decorate the request
     * @returns {Promise} Resolved when add is complete
     */
    add(dn, attributes, controls) {
        return this
            .$performAsyncLdapOperation('addAsync', dn, attributes, controls)
            .tap(() => {
                EventLogger.log('Added new entry to LDAP', {
                    attributes: attributes,
                    controls: controls,
                    dn: dn,
                    url: this.ldapUrl
                });
            });
    }
    
    /**
     * Compare an entry in the LDAP with the given attribute and value
     * @param {String} dn Distinguished name for the new entry
     * @param {String} attribute Name of the attribute to compare
     * @param {any} value Value to be compared
     * @param {Control|Control[]} controls Optional LDAP controls to decorate the request
     * @returns {Promise} Resolved with result of the comparison
     */
    compare(dn, attribute, value, controls) {
        return this.$performAsyncLdapOperation('compareAsync', dn, attribute, value, controls);
    }
    
    /**
     * Change the password credential on an existing account in the LDAP directory.
     * @param {String} dn Distinguished name for the existing account
     * @param {String} credential New credential to assign
     * @param {String} [attributeName=userPassword] Optionally, specify the name of the password attribute in the directory (for example, Active Directory requires password writes on the unicodePwd attribute)
     * @param {String} [encodingType=null] Optionally, specify special encoding that the LDAP may require (for example 'msad' for Active Directory)
     * @returns {Promise} Resolved when the change is complete
     */
    changeCredential(dn, credential, attributeName='userPassword', encodingType=null) {
        return this.modifySimple(
            dn,
            ['replace', attributeName, this.encodeCredential(credential, encodingType)]);
    }

    /**
     * Utilty function to take a named operation and one or more attributes
     * expressed as an object, and convert it into an Ldapjs.Change object prior
     * to executing an LDAP entry modification.
     * @param {String} operation Type of modification: add|replace|delete
     * @param {Object} attributes Key/values for the new attributes
     * @returns {Change}
     * @private
     */
    createLdapChange(operation, attributes) {
        return new Ldap.Change({
            operation: operation,
            modification: attributes
        });
    }

    /**
     * Delete an existing entry from the LDAP directory.
     * @param {String} dn Distinguished name for the existing entry
     * @param {Control|Control[]} controls Optional LDAP controls to decorate the request
     * @returns {Promise} Resolved when the delete is complete
     */
    delete(dn, controls) {
        return this
            .$performAsyncLdapOperation('delAsync', dn, controls)
            .tap(() => {
                EventLogger.log('Deleted entry from LDAP', {
                    controls: controls,
                    dn: dn,
                    url: this.ldapUrl
                });
            });
    }

    /**
     * Given a plain credential, perform a specical encoding process to
     * prepare the credential to be added to the directory. Defaults to
     * doing nothing and passes the credential back as it was received.
     *
     * Most LDAPs will not require anything special. As a peculiar example,
     * Microsoft AD requires a new credential to be UTF16LE encoded and
     * surrounded in double quotes. You can request this encoding to be
     * applied by specifying 'msad' for the encodingType parameter.
     * @param {String} credential New credential to encode
     * @param {String|Function} [encodingType=null] Identifier of an alternate encoding to apply, or a function if you want to provide your own
     * @example <caption>Encode a password for Active Directory</caption>
     * encodeCredential('newpw', 'msad')
     * @returns {String} Encoded credential, or nothing if there was no encoding specified
     */
    encodeCredential(credential, encodingType=null) {
        if(!(encodingType)) {
            return credential;
        }
        else if(encodingType === 'msad') {
            return new Buffer(`"${credential}"`, 'utf16le');
        }
        else if(typeof encodingType === 'function') {
            return encodingType(credential);
        }
    }

    /**
     * Get the latest statistics from the pool regarding available and used
     * connections. See [pool2 documentation](https://github.com/myndzi/pool2)
     * for more information on what this returns.
     * @returns {Object}
     */
    getPoolStatistics() {
        return this.pool.stats();
    }

    /**
     * Utility function to try and identify if an error object returned by 
     * Ldapjs when a credential bind indicates that the password itself
     * was incorrect. First checks for code 49, and if that does not work then
     * looks for known error messages from certain directory types.
     * @param {Error} error The error that was thrown
     */
    isCredentialFailedError(error) {
        // Did the LDAP return the standard code 49 for invalid credentials?
        if(error.code === 49) {
            return true;
        }
        // If not, try to examine the error message for more clues
        else if(error.lde_message) {
            // Does the message resemble Sun ONE?
            if(error.lde_message === 'Invalid Credentials') {
                return true;
            }
            // Does the message resemble Active Directory?
            else if(error.lde_message.indexOf('DSID-0C0903D9')) {
                return true;
            }
        }
        return false;
    }

    /**
     * Perform a modification on an existing entry in the LDAP directory.
     * @param {String} dn Distinguished name for the existing entry
     * @param {Change|Change[]} changes One or more Ldapjs change objects defining which attributes are changed
     * @param {Control|Control[]} controls Optional LDAP controls to decorate the request
     * @returns {Promise} Resolved when the modification is complete
     */
    modify(dn, changes, controls) {
        return this
            .$performAsyncLdapOperation('modifyAsync', dn, changes, controls)
            .tap(() => {
                EventLogger.log('Modified existing entry in LDAP', {
                    changes: changes,
                    controls: controls,
                    dn: dn,
                    url: this.ldapUrl
                });
            });
    }

    /**
     * Different method of making a modification on an existing entry in the
     * LDAP directory by allowing the specifying of operations and attribute
     * name/value pairs as function args. This promotes very high readability
     * of the function call, and automates much of the low-level object creation
     * required by Ldapjs.
     * @param {String} dn Distinguished name for the existing entry
     * @param {...String} operations Specify operations in 3-argument pairs: the operation, attribute name, and the value
     * @example
     * modifySimple(
     *     'cn=something,dc=somehost,dc=somedomain',
     *      'replace, 'displayName', 'John Doe')
     * @returns {Promise} Resolved when the modification is complete
     */
    modifySimple(dn, ...operations) {
        // Convert change requests into Ldapjs Change objects
        let changes = operations.map(operation => {
            return this.createLdapChange(operation[0], {
                [operation[1]]: operation[2]
            });
        });

        // Execute LDAP modification
        return this.modify(dn, changes);
    }

    /**
     * Search the LDAP directory for one or more entries based on the criteria
     * provided in the function arguments.
     * @param {String} baseDn The entry where the search should begin
     * @param {String} filter Attribute filter expression to limit results
     * @param {String[]} [attributes=[]] Array of attributes to return (defaults to all)
     * @param {String} [scope='one'] Search scope expressed as base|one|sub 
     * @example
     * search('ou=People,dc=somehost,dc=somedomain', '(cn=someid)')
     * @returns {Promise} Resolved with array of entries found
     */
    search(baseDn, filter, attributes=[], scope='one') {
        // Convert function args into object
        let searchOptions = {
            attributes: attributes,
            filter: filter,
            scope: scope
        };

        return new Promise((resolve, reject) => {
            return this.withConnection(client => {
                return client.searchAsync(baseDn, searchOptions).then(searchResult => {
                    let results = [];

                    // Attach event handler for capturing errors
                    searchResult.once('error', reject);

                    // Attach event handler for results
                    searchResult.on('searchEntry', entry => results.push(entry.object));

                    // Attach event handler for search completion
                    searchResult.once('end', () => resolve(results));
                })
                .catch(reject);
            });
        });
    }

    /**
     * Shutdown this connection pool and cleanup all open connections.
     * @returns {Promise} Resolved when the pool has disposed all of its resources
     */
    shutdown() {
        return this.pool.endAsync();
    }

    /**
     * Utility function to QA a credential by attempting to
     * perform a simple bind. The LDAP connection is always disposed
     * after binding because it is less complicated to get a new one from
     * the pool than constantly rebinding connections.
     * @param {String} dn Distinguished name for the account to test
     * @param {String} credential Credential/secret for the named account
     * @throws {Errors.CredentialValidationFailed} If the error returned from the directory appears to indicate credential failed
     * @returns {Promise} Resolved with `true` if the authentication passed, or rejected if not
     */
    testCredential(dn, credential) {
        let parent = this;

        return this.withConnection(client => {
            return client.bindAsync(dn, credential)
                .then(() => {
                    EventLogger.log(`Successfully validated credential for ${dn}`);
                    return Promise.resolve(true);
                })
                .catch(error => {
                    if(this.isCredentialFailedError(error)) {
                        EventLogger.log(`Credential for ${dn} failed to validate`, [error, {
                            url: parent.ldapUrl
                        }]);

                        return Promise.reject(new Errors.CredentialValidationFailed());
                    }
                    return Promise.reject(error);
                });
        }, true);
    }

    /**
     * Utility function to automatically acquire a connection from the pool,
     * execute a custom callback, and then ensure the connection is
     * returned back to the pool (or optionally removed from the pool through disposal)
     * @param {Boolean} [dispose=false] If true, the connection should be disposed instead of returned back to the pool
     * @returns {Promise} Resolved when entire operation is complete
     */
    withConnection(callback, dispose=false) {
        let clientInstance;

        return this.acquire().then(client => {
            // Retain reference to client for cleanup
            clientInstance = client;

            // Execute developer callback
            return callback(client);
        })
        .finally(() => {
            if(dispose) {
                // If requested, mark the connection for disposal
                clientInstance.dispose();
            }
            else {
                // Release the client back into the pool
                EventLogger.log(`withConnection(...) Releasing LDAP connection`);
                clientInstance.release();
            }
        });
    }

    /**
     * Private function to automatically acquire a connection from the pool,
     * execute a named function with the provided arguments, and then ensure the
     * connection is returned back to the pool. This exists primarily to
     * keep the other class functions adds, mods, etc. clean and tidy.
     * @returns {Promise}
     * @private
     */
    $performAsyncLdapOperation(functionName, ...args) {
        return this.withConnection(client => {
            // Drop any undefined elements
            let filteredArgs = args.filter(arg => arg !== undefined);

            // Execute named client function
            return client[functionName].apply(client, filteredArgs);
        });
    }

}

module.exports = PooledLdapConnection;