LT-103: Authenticate via LDAP

This commit is contained in:
Aysel Afsar 2016-03-22 15:43:06 -04:00
parent beaa0d4e65
commit 3c60a6dda1
11 changed files with 559 additions and 34 deletions

View File

@ -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);

View File

@ -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();
}
}
});
};

View File

@ -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
};
}
});

View File

@ -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');
});

View File

@ -16,27 +16,47 @@
<h1 id="signInPageTitle" class="title-auth">Sign In.</h1>
<p id="signInPageMessage" class="subtitle-auth" style="{{getSignInMessageColor}}">{{{getSignInMessage}}}</p>
<form>
<div class="input-symbol">
<input id="signInPageEmailInput" type="text" name="email" placeholder="Your Email" style="{{getEmailValidationStyling}}" />
<i class="fa fa-envelope-o" title="Your Email"></i>
</div>
{{#if isLDAPSet}}
<form>
<div class="input-symbol">
<input id="signInLDAPUsernameInput" type="text" name="email" placeholder="Username" style="{{getEmailValidationStyling}}" />
<i class="fa fa-envelope-o" title="Your Email"></i>
</div>
<br><br>
<div class="input-symbol">
<input id="signInLDAPPasswordInput" type="password" name="password" placeholder="Password" style={{getPasswordValidationStyling}} />
<span class="fa fa-lock" title="Password"></span>
</div>
</form>
<br><br>
<button id="signInLDAPToAppButton" class="btn-primary btn-main btn-large" style="{{getButtonColor}}">{{getButtonText}}</button>
{{else}}
<form>
<div class="input-symbol">
<input id="signInPageEmailInput" type="text" name="email" placeholder="Your Email" style="{{getEmailValidationStyling}}" />
<i class="fa fa-envelope-o" title="Your Email"></i>
</div>
<br><br>
<div class="input-symbol">
<input id="signInPagePasswordInput" type="password" name="password" placeholder="Password" style={{getPasswordValidationStyling}} />
<span class="fa fa-lock" title="Password"></span>
</div>
</form>
<br><br>
<button id="signInToAppButton" class="btn-primary btn-main btn-large disabledButton" disabled style="{{getButtonColor}}">{{getButtonText}}</button>
{{/if}}
<br><br>
<button id="needAnAccountButton" class="btn-gray btn-main btn-large">Need an account?</button>
<div class="input-symbol">
<input id="signInPagePasswordInput" type="password" name="password" placeholder="Password" style={{getPasswordValidationStyling}} />
<span class="fa fa-lock" title="Password"></span>
</div>
</form>
<br><br>
<button id="forgotPasswordButton" class="btn-gray btn-main btn-large">Forgot password?</button>
<br><br>
<button id="signInToAppButton" class="btn-primary btn-main btn-large disabledButton" disabled style="{{getButtonColor}}">{{getButtonText}}</button>
<br><br>
<button id="needAnAccountButton" class="btn-gray btn-main btn-large">Need an account?</button>
<br><br>
<button id="forgotPasswordButton" class="btn-gray btn-main btn-large">Forgot password?</button>
{{/if}}
</div>

View File

@ -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);
}
});

View File

@ -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) {

View File

@ -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']);

View File

@ -147,6 +147,14 @@ Meteor.methods({
return true;
}
return false;
},
isLDAPSet: function() {
if (LDAP_DEFAULTS.url && LDAP_DEFAULTS.port) {
return true;
}
return false;
}

View File

@ -18,6 +18,10 @@ Template.userAccountMenu.helpers({
return true;
}
if (!Meteor.user().emails) {
return true;
}
if (Meteor.user().emails[0].verified) {
return true;
}

View File

@ -31,6 +31,9 @@
"password": "",
"server": "",
"port": ""
},
"ldap": {
"url": "ldap://localhost",
"port": "10389"
}
}