Future = Npm.require('fibers/future'); // At a minimum, set up LDAP_DEFAULTS.url and .dn according to // your needs. url should appear as 'ldap://your.url.here' // dn should appear in normal ldap format of comma separated attribute=value // e.g. 'uid=someuser,cn=users,dc=somevalue' LDAP_DEFAULTS = { url: false, port: '389', dn: false, searchDN: false, searchCredentials: false, createNewUser: true, defaultDomain: false, searchResultsProfileMap: false, base: null, search: '(objectclass=*)', ldapsCertificate: false }; /** @class LDAP @constructor */ var LDAP = function (options) { // Set options this.options = _.defaults(options, LDAP_DEFAULTS); // Make sure options have been set try { check(this.options.url, String); //check(this.options.dn, String); } catch (e) { throw new Meteor.Error('Bad Defaults', 'Options not set. Make sure to set LDAP_DEFAULTS.url and LDAP_DEFAULTS.dn!'); } // Because NPM ldapjs module has some binary builds, // We had to create a wraper package for it and build for // certain architectures. The package typ:ldap-js exports // 'MeteorWrapperLdapjs' which is a wrapper for the npm module this.ldapjs = MeteorWrapperLdapjs; }; /** * Attempt to bind (authenticate) ldap * and perform a dn search if specified * * @method ldapCheck * * @param {Object} options Object with username, ldapPass and overrides for LDAP_DEFAULTS object. * Additionally the searchBeforeBind parameter can be specified, which is used to search for the DN * if not provided. */ LDAP.prototype.ldapCheck = function (options) { var self = this; options = options || {}; if (options.hasOwnProperty('username') && options.hasOwnProperty('ldapPass')) { var ldapAsyncFut = new Future(); // Create ldap client var fullUrl = self.options.url + ':' + self.options.port; var client = null; if (self.options.url.indexOf('ldaps://') === 0) { client = self.ldapjs.createClient({ url: fullUrl, tlsOptions: { ca: [self.options.ldapsCertificate] } }); } else { client = self.ldapjs.createClient({ url: fullUrl }); } // Slide @xyz.whatever from username if it was passed in // and replace it with the domain specified in defaults var emailSliceIndex = options.username.indexOf('@'); var username; var domain = self.options.defaultDomain; // If user appended email domain, strip it out // And use the defaults.defaultDomain if set if (emailSliceIndex !== -1) { username = options.username.substring(0, emailSliceIndex); domain = domain || options.username.substring((emailSliceIndex + 1), options.username.length); } else { username = options.username; } // If DN is provided, use it to bind if (self.options.dn) { // Attempt to bind to ldap server with provided info client.bind(self.options.searchDN || self.options.dn, self.options.searchCredentials || options.ldapPass, function (err) { try { if (err) { // Bind failure, return error throw new Meteor.Error(err.code, err.message); } var handleSearchProfile = function (retObject, bindAfterSearch) { retObject.emptySearch = true; // construct list of ldap attributes to fetch var attributes = []; if (self.options.searchResultsProfileMap) { self.options.searchResultsProfileMap.map(function (item) { attributes.push(item.resultKey); }); } // use base if given, else the dn for the ldap search var searchBase = self.options.base || self.options.dn; var searchOptions = { scope: 'sub', sizeLimit: 1, attributes: attributes, filter: self.options.search }; client.search(searchBase, searchOptions, function (err, res) { if (err) { ldapAsyncFut.return({ error: err }); } res.on('searchEntry', function (entry) { retObject.emptySearch = false; // Add entry results to return object retObject.searchResults = entry.object; if (bindAfterSearch) { client.bind(retObject.searchResults.dn, options.ldapPass, function (err) { try { if (err) { throw new Meteor.Error(err.code, err.message); } ldapAsyncFut.return(retObject); } catch (e) { ldapAsyncFut.return({ error: e }); } }) } else ldapAsyncFut.return(retObject); }); // This call causes Future issue: "Future resolved more than once" // Because it is already called in searchEntry handler /*res.on('end', function () { ldapAsyncFut.return(retObject); });*/ }); }; var retObject = { username: username, searchResults: null, email: domain ? username + '@' + domain : false }; if (self.options.searchDN) { handleSearchProfile(retObject, true); } else if (self.options.searchResultsProfileMap) { handleSearchProfile(retObject, false); } else { ldapAsyncFut.return(retObject); } } catch (e) { ldapAsyncFut.return({ error: e }); } }); } // DN not provided, search for DN and use result to bind else if (typeof self.options.searchBeforeBind !== undefined) { // initialize result var retObject = { username: username, email: domain ? username + '@' + domain : false, emptySearch: true, searchResults: {} }; // compile attribute list to return var searchAttributes = ['dn']; self.options.searchResultsProfileMap.map(function (item) { searchAttributes.push(item.resultKey); }); var filter = self.options.search; Object.keys(options.ldapOptions.searchBeforeBind).forEach(function (searchKey) { filter = '&' + filter + '(' + searchKey + '=' + options.ldapOptions.searchBeforeBind[searchKey] + ')'; }); var searchOptions = { scope: 'sub', sizeLimit: 1, filter: filter }; // perform LDAP search to determine DN client.search(self.options.base, searchOptions, function (err, res) { retObject.emptySearch = true; res.on('searchEntry', function (entry) { retObject.dn = entry.objectName; retObject.username = retObject.dn; retObject.emptySearch = false; // Return search results if specified if (self.options.searchResultsProfileMap) { // construct list of ldap attributes to fetch self.options.searchResultsProfileMap.map(function (item) { retObject.searchResults[item.resultKey] = entry.object[item.resultKey]; }); } // use the determined DN to bind client.bind(entry.objectName, options.ldapPass, function (err) { try { if (err) { throw new Meteor.Error(err.code, err.message); } else { ldapAsyncFut.return(retObject); } } catch (e) { ldapAsyncFut.return({ error: e }); } }); }); // If no dn is found, return as is. res.on('end', function (result) { if (retObject.dn === undefined) { ldapAsyncFut.return(retObject); } }); }); } return ldapAsyncFut.wait(); } else { throw new Meteor.Error(403, 'Missing LDAP Auth Parameter'); } }; // Register login handler with Meteor // Here we create a new LDAP instance with options passed from // Meteor.loginWithLDAP on client side // @param {Object} loginRequest will consist of username, ldapPass, ldap, and ldapOptions Accounts.registerLoginHandler('ldap', function (loginRequest) { // If 'ldap' isn't set in loginRequest object, // then this isn't the proper handler (return undefined) if (!loginRequest.ldap) { return undefined; } // Instantiate LDAP with options var userOptions = loginRequest.ldapOptions || {}; var ldapObj = new LDAP(userOptions); // Call ldapCheck and get response var ldapResponse = ldapObj.ldapCheck(loginRequest); if (ldapResponse.error) { return { userId: null, error: ldapResponse.error }; } else if (ldapResponse.emptySearch) { return { userId: null, error: new Meteor.Error(403, 'User not found in LDAP') }; } else { // Set initial userId and token vals var userId = null; var stampedToken = { token: null }; // Look to see if user already exists var user = Meteor.users.findOne({ username: ldapResponse.username }); // Login user if they exist if (user) { userId = user._id; // Create hashed token so user stays logged in stampedToken = Accounts._generateStampedLoginToken(); var hashStampedToken = Accounts._hashStampedToken(stampedToken); // Update the user's token in mongo Meteor.users.update(userId, { $push: { 'services.resume.loginTokens': hashStampedToken } }); } // Otherwise create user if option is set else if (ldapObj.options.createNewUser) { var userObject = { username: ldapResponse.username }; // Set email if (ldapResponse.email) userObject.email = ldapResponse.email; // Set profile values if specified in searchResultsProfileMap if (ldapResponse.searchResults && ldapObj.options.searchResultsProfileMap.length > 0) { var profileMap = ldapObj.options.searchResultsProfileMap; var profileObject = {}; // Loop through profileMap and set values on profile object for (var i = 0; i < profileMap.length; i++) { var resultKey = profileMap[i].resultKey; // If our search results have the specified property, set the profile property to its value if (ldapResponse.searchResults.hasOwnProperty(resultKey)) { profileObject[profileMap[i].profileProperty] = ldapResponse.searchResults[resultKey]; } } // Set userObject profile userObject.profile = profileObject; } userId = Accounts.createUser(userObject); } else { // Ldap success, but no user created console.log('LDAP Authentication succeeded for ' + ldapResponse.username + ', but no user exists in Meteor. Either create the user manually or set LDAP_DEFAULTS.createNewUser to true'); return { userId: null, error: new Meteor.Error(403, 'User found in LDAP but not in application') }; } return { userId: userId, token: stampedToken.token }; } });