diff --git a/.meteor/packages b/.meteor/packages index 3af499b7df9e..cc3e62f67c64 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -45,6 +45,7 @@ rocketchat:file rocketchat:highlight #rocketchat:hubot #rocketchat:irc +rocketchat:ldap rocketchat:lib rocketchat:markdown rocketchat:me @@ -56,4 +57,4 @@ tmeasday:crypto-md5 tmeasday:errors todda00:friendly-slugs underscorestring:underscore.string -yasaricli:slugify +yasaricli:slugify \ No newline at end of file diff --git a/.meteor/versions b/.meteor/versions index bb80d772ebc5..215f5b2a6224 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -98,6 +98,7 @@ rocketchat:autolinker@0.0.1 rocketchat:emojione@0.0.1 rocketchat:file@0.0.1 rocketchat:highlight@0.0.1 +rocketchat:ldap@0.0.1 rocketchat:lib@0.0.1 rocketchat:markdown@0.0.1 rocketchat:me@0.0.1 diff --git a/packages/rocketchat-ldap/.npm/package/.gitignore b/packages/rocketchat-ldap/.npm/package/.gitignore new file mode 100644 index 000000000000..3c3629e647f5 --- /dev/null +++ b/packages/rocketchat-ldap/.npm/package/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/packages/rocketchat-ldap/.npm/package/README b/packages/rocketchat-ldap/.npm/package/README new file mode 100644 index 000000000000..3d492553a438 --- /dev/null +++ b/packages/rocketchat-ldap/.npm/package/README @@ -0,0 +1,7 @@ +This directory and the files immediately inside it are automatically generated +when you change this package's NPM dependencies. Commit the files in this +directory (npm-shrinkwrap.json, .gitignore, and this README) to source control +so that others run the same versions of sub-dependencies. + +You should NOT check in the node_modules directory that Meteor automatically +creates; if you are using git, the .gitignore file tells git to ignore it. diff --git a/packages/rocketchat-ldap/.npm/package/npm-shrinkwrap.json b/packages/rocketchat-ldap/.npm/package/npm-shrinkwrap.json new file mode 100644 index 000000000000..7e02a6823096 --- /dev/null +++ b/packages/rocketchat-ldap/.npm/package/npm-shrinkwrap.json @@ -0,0 +1,69 @@ +{ + "dependencies": { + "ldapjs": { + "version": "0.7.1", + "dependencies": { + "asn1": { + "version": "0.2.1" + }, + "assert-plus": { + "version": "0.1.5" + }, + "bunyan": { + "version": "0.22.1", + "dependencies": { + "mv": { + "version": "0.0.5" + } + } + }, + "nopt": { + "version": "2.1.1", + "dependencies": { + "abbrev": { + "version": "1.0.7" + } + } + }, + "pooling": { + "version": "0.4.6", + "dependencies": { + "once": { + "version": "1.3.0" + }, + "vasync": { + "version": "1.4.0", + "dependencies": { + "jsprim": { + "version": "0.3.0", + "dependencies": { + "extsprintf": { + "version": "1.0.0" + }, + "json-schema": { + "version": "0.2.2" + }, + "verror": { + "version": "1.3.3" + } + } + }, + "verror": { + "version": "1.1.0", + "dependencies": { + "extsprintf": { + "version": "1.0.0" + } + } + } + } + } + } + }, + "dtrace-provider": { + "version": "0.2.8" + } + } + } + } +} diff --git a/packages/rocketchat-ldap/ldap_client.js b/packages/rocketchat-ldap/ldap_client.js new file mode 100644 index 000000000000..18b377f630c6 --- /dev/null +++ b/packages/rocketchat-ldap/ldap_client.js @@ -0,0 +1,43 @@ +// Pass in username, password as normal +// customLdapOptions should be passed in if you want to override LDAP_DEFAULTS +// on any particular call (if you have multiple ldap servers you'd like to connect to) +// You'll likely want to set the dn value here {dn: "..."} +Meteor.loginWithLDAP = function(user, password, customLdapOptions, callback) { + // Retrieve arguments as array + var args = []; + for (var i = 0; i < arguments.length; i++) { + args.push(arguments[i]); + } + // Pull username and password + user = args.shift(); + password = args.shift(); + + // Check if last argument is a function + // if it is, pop it off and set callback to it + if (typeof args[args.length-1] == 'function') callback = args.pop(); else callback = null; + + // if args still holds options item, grab it + if (args.length > 0) customLdapOptions = args.shift(); else customLdapOptions = {}; + + // Set up loginRequest object + var loginRequest = _.defaults({ + username: user, + ldapPass: password + }, { + ldap: true, + ldapOptions: customLdapOptions + }); + + Accounts.callLoginMethod({ + // Call login method with ldap = true + // This will hook into our login handler for ldap + methodArguments: [loginRequest], + userCallback: function(error, result) { + if (error) { + callback && callback(error); + } else { + callback && callback(); + } + } + }); +} \ No newline at end of file diff --git a/packages/rocketchat-ldap/ldap_server.js b/packages/rocketchat-ldap/ldap_server.js new file mode 100644 index 000000000000..1e29bcbb2b77 --- /dev/null +++ b/packages/rocketchat-ldap/ldap_server.js @@ -0,0 +1,224 @@ +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, + createNewUser: true, + defaultDomain: false, + searchResultsProfileMap: 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 + */ +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 = 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; + } + + + //Attempt to bind to ldap server with provided info + client.bind(self.options.dn, options.ldapPass, function(err) { + try { + if (err) { + // Bind failure, return error + throw new Meteor.Error(err.code, err.message); + } else { + // Bind auth successful + // Create return object + var retObject = { + username: username, + searchResults: null + }; + // Set email on return object + retObject.email = domain ? username + '@' + domain : false; + + // Return search results if specified + if (self.options.searchResultsProfileMap) { + client.search(self.options.dn, {}, function(err, res) { + + res.on('searchEntry', function(entry) { + // Add entry results to return object + retObject.searchResults = entry.object; + + ldapAsyncFut.return(retObject); + }); + + }); + } + // No search results specified, return username and email object + else { + ldapAsyncFut.return(retObject); + } + } + } catch (e) { + ldapAsyncFut.return({ + error: e + }); + } + }); + + 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 { + // 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 + return { + userId: null, + error: "LDAP Authentication succeded, but no user exists in Mongo. Either create a user for this email or set LDAP_DEFAULTS.createNewUser to true" + }; + } + + return { + userId: userId, + token: stampedToken.token + }; + } + + return undefined; +}); \ No newline at end of file diff --git a/packages/rocketchat-ldap/lib/ldapjs.js b/packages/rocketchat-ldap/lib/ldapjs.js new file mode 100644 index 000000000000..bbec7c392bbe --- /dev/null +++ b/packages/rocketchat-ldap/lib/ldapjs.js @@ -0,0 +1 @@ +MeteorWrapperLdapjs = Npm.require('ldapjs'); \ No newline at end of file diff --git a/packages/rocketchat-ldap/package.js b/packages/rocketchat-ldap/package.js new file mode 100644 index 000000000000..176369acc478 --- /dev/null +++ b/packages/rocketchat-ldap/package.js @@ -0,0 +1,28 @@ +Package.describe({ + name: 'rocketchat:ldap', + version: '0.0.1', + summary: 'Accounts login handler for LDAP using ldapjs from npm', + git: 'https://github.com/rocketchat/rocketchat-ldap' +}); + +Npm.depends({ + ldapjs: "0.7.1", +}); + +Package.onUse(function(api) { + api.versionsFrom('1.0.3.1'); + + api.use(['templating'], 'client'); + + api.use(['accounts-base', 'accounts-password'], 'server'); + + api.addFiles(['ldap_client.js'], 'client'); + api.addFiles(['ldap_server.js', 'lib/ldapjs.js'], 'server'); + + api.export('LDAP', 'server'); + api.export('LDAP_DEFAULTS', 'server'); + api.export([ + 'MeteorWrapperLdapjs' + ]); +}); + diff --git a/server/lib/ldap.coffee b/server/lib/ldap.coffee new file mode 100644 index 000000000000..e350fa221005 --- /dev/null +++ b/server/lib/ldap.coffee @@ -0,0 +1 @@ +LDAP_DEFAULTS.url = "ldap://ldap.forumsys.com" \ No newline at end of file