diff --git a/LesionTracker/client/routes.js b/LesionTracker/client/routes.js index c0b4a3a80..610e03f02 100644 --- a/LesionTracker/client/routes.js +++ b/LesionTracker/client/routes.js @@ -36,7 +36,7 @@ var routerOptions = { Router.route('/', function() { // Check user is logged in if(Meteor.user() && Meteor.userId()) { - if (!Meteor.user().emails[0].verified && verifyEmail) { + if (verifyEmail && Meteor.user().emails && !Meteor.user().emails[0].verified) { this.render('emailVerification', routerOptions); } else { this.render('worklist', routerOptions); @@ -50,7 +50,7 @@ Router.route('/', function() { Router.route('/worklist', function() { // Check user is logged in if(Meteor.user() && Meteor.userId()) { - if (!Meteor.user().emails[0].verified && verifyEmail) { + if (verifyEmail && Meteor.user().emails && !Meteor.user().emails[0].verified) { this.render('emailVerification', routerOptions); } else { this.render('worklist', routerOptions); diff --git a/Packages/accounts-ldap/ldap_client.js b/Packages/accounts-ldap/ldap_client.js new file mode 100644 index 000000000..c4c65e758 --- /dev/null +++ b/Packages/accounts-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/accounts-ldap/ldap_server.js b/Packages/accounts-ldap/ldap_server.js new file mode 100644 index 000000000..66a67b15c --- /dev/null +++ b/Packages/accounts-ldap/ldap_server.js @@ -0,0 +1,366 @@ +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 + }; + } + +}); \ No newline at end of file diff --git a/Packages/accounts-ldap/package.js b/Packages/accounts-ldap/package.js new file mode 100644 index 000000000..30fe0d7bf --- /dev/null +++ b/Packages/accounts-ldap/package.js @@ -0,0 +1,28 @@ +Package.describe({ + name: 'typ:accounts-ldap', + version: '1.0.1', + summary: 'Accounts login for LDAP using ldapjs. Supports anonymous DN search & LDAPS.', + git: 'https://github.com/typ90/meteor-accounts-ldap', + documentation: 'README.md' +}); + + +Package.onUse(function(api) { + api.versionsFrom('1.0.3.1'); + + api.use(['templating'], 'client'); + api.use(['typ:ldapjs@0.7.3'], 'server'); + + + api.use('accounts-base', 'server'); + api.imply('accounts-base'); + api.imply('accounts-password'); + + api.use('check'); + + api.addFiles(['ldap_client.js'], 'client'); + api.addFiles(['ldap_server.js'], 'server'); + + api.export('LDAP', 'server'); + api.export('LDAP_DEFAULTS', 'server'); +}); \ No newline at end of file diff --git a/Packages/active-entry/components/entrySignIn/entrySignIn.html b/Packages/active-entry/components/entrySignIn/entrySignIn.html index 4af315afb..b7cf8a26b 100755 --- a/Packages/active-entry/components/entrySignIn/entrySignIn.html +++ b/Packages/active-entry/components/entrySignIn/entrySignIn.html @@ -16,27 +16,47 @@

Sign In.

{{{getSignInMessage}}}

-
-
- - -
+ {{#if isLDAPSet}} + +
+ + +
+

+ +
+ + +
+
+ +

+ + {{else}} +
+
+ + +
+

+ +
+ + +
+
+ +

+ + + {{/if}}

+ -
- - -
- +

+ -

- -

- - -

- {{/if}} diff --git a/Packages/active-entry/components/entrySignIn/entrySignIn.js b/Packages/active-entry/components/entrySignIn/entrySignIn.js index 374bc121e..668223c0b 100755 --- a/Packages/active-entry/components/entrySignIn/entrySignIn.js +++ b/Packages/active-entry/components/entrySignIn/entrySignIn.js @@ -64,6 +64,10 @@ Template.entrySignIn.helpers({ } else { return "border: 1px solid gray"; } + }, + + isLDAPSet: function() { + return Session.get('isLDAPSet'); } }); @@ -134,6 +138,11 @@ Template.entrySignIn.events({ if(event.keyCode == 13) { $("#signInToAppButton").click(); } + }, + 'click #signInLDAPToAppButton': function(e, template) { + var username = template.$("#signInLDAPUsernameInput").val(); + var password = template.$("#signInLDAPPasswordInput").val(); + ActiveEntry.loginWithLDAP(username, password); } }); diff --git a/Packages/active-entry/lib/ActiveEntry.js b/Packages/active-entry/lib/ActiveEntry.js index 714d4e4cd..2518cd2dc 100755 --- a/Packages/active-entry/lib/ActiveEntry.js +++ b/Packages/active-entry/lib/ActiveEntry.js @@ -32,14 +32,8 @@ if (Meteor.isClient) { passwordExpirationDays: 90, inactivityPeriodDays: 180 } - }); -} -// requireRegexValidation toggles regex -// reqiureStrongPasswords toggles zxcvbn - -if (Meteor.isClient) { ActiveEntry.errorMessages = new ReactiveDict('errorMessages'); ActiveEntry.errorMessages.set('signInError', false); @@ -48,6 +42,15 @@ if (Meteor.isClient) { // Change password warning message according to whether zxcvbn is turned on Session.set('passwordWarning', 'Password must have at least 8 characters. It must contain at least 1 uppercase, 1 lowercase, 1 number and 1 special character.'); + + Meteor.call('isLDAPSet', function(error, isSet) { + Session.set('isLDAPSet', isSet); + }); +} + +if (Meteor.isServer) { + LDAP_DEFAULTS.url = Meteor.settings.ldap && Meteor.settings.ldap.url; + LDAP_DEFAULTS.port = Meteor.settings.ldap && Meteor.settings.ldap.port; } ActiveEntry.configure = function (configObject) { @@ -128,6 +131,7 @@ ActiveEntry.signIn = function (emailValue, passwordValue){ ActiveEntry.verifyPassword(passwordValue); ActiveEntry.verifyEmail(emailValue); + // TODO: Find a solution nested calling var ActiveEntryConfig = Session.get('Photonic.ActiveEntry'); var failedAttemptsLimit = ActiveEntryConfig && ActiveEntryConfig.passwordOptions && ActiveEntryConfig.passwordOptions.failedAttemptsLimit || 5; @@ -217,6 +221,44 @@ ActiveEntry.signIn = function (emailValue, passwordValue){ }; +ActiveEntry.loginWithLDAP = function(username, password) { + if (!username || !password) { + return; + } + + Meteor.loginWithLDAP(username, password, { + // The dn value depends on what you want to search/auth against + // The structure will depend on how your ldap server + // is configured or structured. + dn: "uid=" + username + ",ou=users,ou=system", + // The search value is optional. Set it if your search does not + // work with the bind dn. + searchResultsProfileMap: [ + { + resultKey: 'cn', + profileProperty: 'fullName' + }, + { + resultKey: 'mail', + profileProperty: 'email' + } + ] + }, function(error) { + if (error) { + ActiveEntry.errorMessages.set('signInError', error.errorType+" ["+error.error+"]"); + return; + } + ActiveEntry.errorMessages.set('signInError', null); + + // Update last login time + Meteor.call("updateLastLoginDate"); + + var ActiveEntryConfig = Session.get('Photonic.ActiveEntry'); + Router.go(ActiveEntryConfig.signIn.destination); + + }); +}; + ActiveEntry.signUp = function (emailValue, passwordValue, confirmPassword, fullName){ ActiveEntry.verifyEmail(emailValue); ActiveEntry.verifyPassword(passwordValue); @@ -235,14 +277,16 @@ ActiveEntry.signUp = function (emailValue, passwordValue, confirmPassword, fullN return; } + // Capitalize first letter of every word in fullName + var capitalizedFullName = fullName.replace(/[^\s]+/g, function(str){ + return str.substr(0,1).toUpperCase()+str.substr(1).toLowerCase(); + }); + Accounts.createUser({ email: emailValue, password: passwordValue, profile: { - fullName: fullName - }, - testCase: { - createdAt: new Date() + fullName: capitalizedFullName } }, function (error, result) { if (error) { diff --git a/Packages/active-entry/package.js b/Packages/active-entry/package.js index d8382c70a..9ff29b8dc 100755 --- a/Packages/active-entry/package.js +++ b/Packages/active-entry/package.js @@ -15,7 +15,7 @@ Package.onUse(function (api) { 'clinical:router@2.0.17', 'grove:less@0.1.1', 'session', - 'reactive-dict', + 'reactive-dict' //'codetheweb:zxcvbn' ], ['client']); @@ -25,7 +25,8 @@ Package.onUse(function (api) { ]); api.use([ - 'zuuk:stale-session@1.0.8' + 'zuuk:stale-session@1.0.8', + 'typ:accounts-ldap' ], ['client', 'server']); api.addFiles([ @@ -60,8 +61,7 @@ Package.onUse(function (api) { 'components/changePassword/changePassword.html', 'components/changePassword/changePassword.js', - 'components/changePassword/changePassword.less', - + 'components/changePassword/changePassword.less' ], ['client']); diff --git a/Packages/active-entry/server/methods.js b/Packages/active-entry/server/methods.js index de193e269..5b6cbb1c1 100644 --- a/Packages/active-entry/server/methods.js +++ b/Packages/active-entry/server/methods.js @@ -147,6 +147,14 @@ Meteor.methods({ return true; } + return false; + }, + + isLDAPSet: function() { + if (LDAP_DEFAULTS.url && LDAP_DEFAULTS.port) { + return true; + } + return false; } diff --git a/Packages/lesiontracker/client/components/userAccountMenu/userAccountMenu.js b/Packages/lesiontracker/client/components/userAccountMenu/userAccountMenu.js index 25fcf3671..6cbe6fbc4 100644 --- a/Packages/lesiontracker/client/components/userAccountMenu/userAccountMenu.js +++ b/Packages/lesiontracker/client/components/userAccountMenu/userAccountMenu.js @@ -18,6 +18,10 @@ Template.userAccountMenu.helpers({ return true; } + if (!Meteor.user().emails) { + return true; + } + if (Meteor.user().emails[0].verified) { return true; } diff --git a/config/siimDCM4CHEE.json b/config/siimDCM4CHEE.json index 81335de59..7630a07c2 100644 --- a/config/siimDCM4CHEE.json +++ b/config/siimDCM4CHEE.json @@ -31,6 +31,9 @@ "password": "", "server": "", "port": "" + }, + "ldap": { + "url": "ldap://localhost", + "port": "10389" } - } \ No newline at end of file