activedirectory
Version:
ActiveDirectory is an ldapjs client for authN (authentication) and authZ (authorization) for Microsoft Active Directory with range retrieval support for large Active Directory installations.
1,421 lines (1,274 loc) • 67.5 kB
JavaScript
var events = require('events');
var util = require('util');
var ldap = require('ldapjs');
var async = require('async');
var _ = require('underscore');
var bunyan = require('bunyan');
var Url = require('url');
var User = require('./models/user');
var Group = require('./models/group');
var RangeRetrievalSpecifierAttribute = require('./client/rangeretrievalspecifierattribute');
var isPasswordLoggingEnabled = false;
var maxOutputLength = 256;
var log = bunyan.createLogger({
name: 'ActiveDirectory',
streams: [
{ level: 'fatal',
stream: process.stdout }
]
});
var defaultPageSize = 1000; // The maximum number of results that AD will return in a single call. Default=1000
var defaultAttributes, originalDefaultAttributes;
defaultAttributes = originalDefaultAttributes = {
user: [
'dn',
'userPrincipalName', 'sAMAccountName', /*'objectSID',*/ 'mail',
'lockoutTime', 'whenCreated', 'pwdLastSet', 'userAccountControl',
'employeeID', 'sn', 'givenName', 'initials', 'cn', 'displayName',
'comment', 'description'
],
group: [
'dn', 'cn', 'description'
]
};
var defaultReferrals, originalDefaultReferrals;
defaultReferrals = originalDefaultReferrals = {
enabled: false,
// Active directory returns the following partitions as default referrals which we don't want to follow
exclude: [
'ldaps?://ForestDnsZones\\..*/.*',
'ldaps?://DomainDnsZones\\..*/.*',
'ldaps?://.*/CN=Configuration,.*'
]
};
// Precompile some common, frequently used regular expressions.
var re = {
'isDistinguishedName': /(([^=]+=.+),?)+/gi,
'isUserResult': /CN=Person,CN=Schema,CN=Configuration,.*/i,
'isGroupResult': /CN=Group,CN=Schema,CN=Configuration,.*/i
};
/**
* Agent for retrieving ActiveDirectory user & group information.
*
* @public
* @constructor
* @param {Object|String} url The url of the ldap server (i.e. ldap://domain.com). Optionally, all of the parameters can be specified as an object. { url: 'ldap://domain.com', baseDN: 'dc=domain,dc=com', username: 'admin@domain.com', password: 'supersecret', { referrals: { enabled: true }, attributes: { user: [ 'attributes to include in response' ], group: [ 'attributes to include in response' ] } } }. 'attributes' & 'referrals' parameter is optional and only necesary if overriding functionality.
* @param {String} baseDN The default base container where all LDAP queries originate from. (i.e. dc=domain,dc=com)
* @param {String} username The administrative username or dn of the user for retrieving user & group information. (i.e. Must be a DN or a userPrincipalName (email))
* @param {String} password The administrative password of the specified user.
* @param {Object} defaults Allow for default options to be overridden. { attributes: { user: [ 'attributes to include in response' ], group: [ 'attributes to include in response' ] } }
* @returns {ActiveDirectory}
*/
var ActiveDirectory = function(url, baseDN, username, password, defaults) {
if (this instanceof ActiveDirectory) {
this.opts = {};
if (typeof(url) === 'string') {
this.opts.url = url;
this.baseDN = baseDN;
this.opts.bindDN = username;
this.opts.bindCredentials = password;
if (typeof((defaults || {}).entryParser) === 'function') {
this.opts.entryParser = defaults.entryParser;
}
}
else {
this.opts = _.defaults({}, url);
this.baseDN = this.opts.baseDN;
if (! this.opts.bindDN) this.opts.bindDN = this.opts.username;
if (! this.opts.bindCredentials) this.opts.bindCredentials = this.opts.password;
if (this.opts.logging) {
log = bunyan.createLogger(_.defaults({}, this.opts.logging));
delete(this.opts.logging);
}
}
defaultAttributes = _.extend({}, originalDefaultAttributes, (this.opts || {}).attributes || {}, (defaults || {}).attributes || {});
defaultReferrals = _.extend({}, originalDefaultReferrals, (this.opts || {}).referrals || {}, (defaults || {}).referrals || {});
log.info('Using username/password (%s/%s) to bind to ActiveDirectory (%s).', this.opts.bindDN,
isPasswordLoggingEnabled ? this.opts.bindCredentials : '********', this.opts.url);
log.info('Referrals are %s', defaultReferrals.enabled ? 'enabled. Exclusions: '+JSON.stringify(defaultReferrals.exclude): 'disabled');
log.info('Default user attributes: %j', defaultAttributes.user || []);
log.info('Default group attributes: %j', defaultAttributes.group || []);
// Enable connection pooling
// TODO: To be disabled / removed in future release of ldapjs > 0.7.1
if (typeof(this.opts.maxConnections) === 'undefined') {
this.opts.maxConnections = 20;
}
events.EventEmitter.call(this);
}
else {
return(new ActiveDirectory(url, baseDN, username, password, defaults));
}
};
util.inherits(ActiveDirectory, events.EventEmitter);
/**
* Expose ldapjs filters to avoid TypeErrors for filters
* @static
*/
ActiveDirectory.filters = ldap.filters;
/**
* Truncates the specified output to the specified length if exceeded.
* @param {String} output The output to truncate if too long
* @param {Number} [maxLength] The maximum length. If not specified, then the global value maxOutputLength is used.
*/
function truncateLogOutput(output, maxLength) {
if (typeof(maxLength) === 'undefined') maxLength = maxOutputLength;
if (! output) return(output);
if (typeof(output) !== 'string') output = output.toString();
var length = output.length;
if ((! length) || (length < (maxLength + 3))) return(output);
var prefix = Math.ceil((maxLength - 3)/2);
var suffix = Math.floor((maxLength - 3)/2);
return(output.slice(0, prefix)+ '...' +
output.slice(length-suffix));
}
/**
* Checks to see if there are any event emitters defined for the
* specified event name.
* @param {String} event The name of the event to inspect.
* @returns {Boolean} True if there are events defined, false if otherwise.
*/
function hasEvents(event) {
return(events.EventEmitter.listenerCount(this, event) > 0);
}
/**
* Checks to see if the value is a distinguished name.
*
* @private
* @param {String} value The value to check to see if it's a distinguished name.
* @returns {Boolean}
*/
function isDistinguishedName(value) {
log.trace('isDistinguishedName(%s)', value);
if ((! value) || (value.length === 0)) return(false);
re.isDistinguishedName.lastIndex = 0; // Reset the regular expression
return(re.isDistinguishedName.test(value));
}
/**
* Parses the distinguishedName (dn) to remove any invalid characters or to
* properly escape the request.
*
* @private
* @param dn {String} The dn to parse.
* @returns {String}
*/
function parseDistinguishedName(dn) {
log.trace('parseDistinguishedName(%s)', dn);
if (! dn) return(dn);
dn = dn.replace(/"/g, '\\"');
return(dn.replace('\\,', '\\\\,'));
}
/**
* Gets the ActiveDirectory LDAP query string for a user search.
*
* @private
* @param {String} username The samAccountName or userPrincipalName (email) of the user.
* @returns {String}
*/
function getUserQueryFilter(username) {
log.trace('getUserQueryFilter(%s)', username);
var self = this;
if (! username) return('(objectCategory=User)');
if (isDistinguishedName.call(self, username)) {
return('(&(objectCategory=User)(distinguishedName='+parseDistinguishedName(username)+'))');
}
return('(&(objectCategory=User)(|(sAMAccountName='+username+')(userPrincipalName='+username+')))');
}
/**
* Gets a properly formatted LDAP compound filter. This is a very simple approach to ensure that the LDAP
* compound filter is wrapped with an enclosing () if necessary. It does not handle parsing of an existing
* compound ldap filter.
* @param {String} filter The LDAP filter to inspect.
* @returns {String}
*/
function getCompoundFilter(filter) {
log.trace('getCompoundFilter(%s)', filter);
if (! filter) return(false);
if ((filter.charAt(0) === '(') && (filter.charAt(filter.length - 1) === ')')) {
return(filter);
}
return('('+filter+')');
}
/**
* Gets the ActiveDirectory LDAP query string for a group search.
*
* @private
* @param {String} groupName The name of the group
* @returns {String}
*/
function getGroupQueryFilter(groupName) {
log.trace('getGroupQueryFilter(%s)', groupName);
var self = this;
if (! groupName) return('(objectCategory=Group)');
if (isDistinguishedName.call(self, groupName)) {
return('(&(objectCategory=Group)(distinguishedName='+parseDistinguishedName(groupName)+'))');
}
return('(&(objectCategory=Group)(cn='+groupName+'))');
}
/**
* Checks to see if the LDAP result describes a group entry.
* @param {Object} item The LDAP result to inspect.
* @returns {Boolean}
*/
function isGroupResult(item) {
log.trace('isGroupResult(%j)', item);
if (! item) return(false);
if (item.groupType) return(true);
if (item.objectCategory) {
re.isGroupResult.lastIndex = 0; // Reset the regular expression
return(re.isGroupResult.test(item.objectCategory));
}
if ((item.objectClass) && (item.objectClass.length > 0)) {
return(_.any(item.objectClass, function(c) { return(c.toLowerCase() === 'group'); }));
}
return(false);
}
/**
* Checks to see if the LDAP result describes a user entry.
* @param {Object} item The LDAP result to inspect.
* @returns {Boolean}
*/
function isUserResult(item) {
log.trace('isUserResult(%j)', item);
if (! item) return(false);
if (item.userPrincipalName) return(true);
if (item.objectCategory) {
re.isUserResult.lastIndex = 0; // Reset the regular expression
return(re.isUserResult.test(item.objectCategory));
}
if ((item.objectClass) && (item.objectClass.length > 0)) {
return(_.any(item.objectClass, function(c) { return(c.toLowerCase() === 'user'); }));
}
return(false);
}
/**
* Factory to create the LDAP client object.
*
* @private
* @param {String} url The url to use when creating the LDAP client.
* @param {object} opts The optional LDAP client options.
*/
function createClient(url, opts) {
// Attempt to get Url from this instance.
url = url || this.url || (this.opts || {}).url || (opts || {}).url;
if (! url) {
throw 'No url specified for ActiveDirectory client.';
}
log.trace('createClient(%s)', url);
var opts = getLdapClientOpts(_.defaults({}, { url: url }, opts, this.opts));
log.debug('Creating ldapjs client for %s. Opts: %j', opts.url, _.omit(opts, 'url', 'bindDN', 'bindCredentials'));
var client = ldap.createClient(opts);
return(client);
}
/**
* Checks to see if the specified referral or "chase" is allowed.
* @param {String} referral The referral to inspect.
* @returns {Boolean} True if the referral should be followed, false if otherwise.
*/
function isAllowedReferral(referral) {
log.trace('isAllowedReferral(%j)', referral);
if (! defaultReferrals.enabled) return(false);
if (! referral) return(false);
return(! _.any(defaultReferrals.exclude, function(exclusion) {
var re = new RegExp(exclusion, "i");
return(re.test(referral));
}));
}
/**
* From the list of options, retrieves the ldapjs specific options.
*
* @param {Object} opts The opts to parse.
* @returns {Object} The ldapjs opts.
*/
function getLdapOpts(opts) {
return(_.defaults({}, getLdapClientOpts(opts), getLdapSearchOpts(opts)));
}
/**
* From the list of options, retrieves the ldapjs client specific options.
*
* @param {Object} opts The opts to parse.
* @returns {Object} The ldapjs opts.
*/
function getLdapClientOpts(opts) {
return(_.pick(opts || {},
// Client
'url',
'host', 'port', 'secure', 'tlsOptions',
'socketPath', 'log', 'timeout', 'idleTimeout',
'reconnect', 'queue', 'queueSize', 'queueTimeout',
'queueDisable', 'bindDN', 'bindCredentials',
'maxConnections'
));
}
/**
* From the list of options, retrieves the ldapjs search specific options.
*
* @param {Object} opts The opts to parse.
* @returns {Object} The ldapjs opts.
*/
function getLdapSearchOpts(opts) {
return(_.pick(opts || {},
// Search
'filter', 'scope', 'attributes', 'controls',
'paged', 'sizeLimit', 'timeLimit', 'typesOnly',
'derefAliases'
));
}
/**
* Performs a search on the LDAP tree.
*
* @private
* @param {String} [baseDN] The optional base directory where the LDAP query is to originate from. If not specified, then starts at the root.
* @param {Object} [opts] LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {Function} callback The callback to execure when completed. callback(err: {Object}, results: {Array[Object]}})
*/
function search(baseDN, opts, callback) {
var self = this;
if (typeof(opts) === 'function') {
callback = opts;
opts = baseDN;
baseDN = undefined;
}
if (typeof(baseDN) === 'object') {
opts = baseDN;
baseDN = undefined;
}
opts || (opts = {});
baseDN || (baseDN = opts.baseDN) || (baseDN = self.baseDN);
log.trace('search(%s,%j)', baseDN, opts);
var isDone = false;
var pendingReferrals = [];
var pendingRangeRetrievals = 0;
var client = createClient.call(self, null, opts);
client.on('error', onClientError);
/**
* Call to remove the specified referral client.
* @param {Object} client The referral client to remove.
*/
function removeReferral(client) {
if (! client) return;
client.unbind();
var indexOf = pendingReferrals.indexOf(client);
if (indexOf >= 0) {
pendingReferrals.splice(indexOf, 1);
}
}
/**
* The default entry parser to use. Does not modifications.
* @params {Object} entry The original / raw ldapjs entry to augment
* @params {Function} callback The callback to execute when complete.
*/
var entryParser = (opts || {}).entryParser || (self.opts || {}).entryParser || function onEntryParser(item, raw, callback) {
callback(item);
};
/**
* Occurs when a search entry is received. Cleans up the search entry and pushes it to the result set.
* @param {Object} entry The entry received.
*/
function onSearchEntry(entry) {
log.trace('onSearchEntry(%j)', entry);
var result = entry.object;
delete result.controls; // Remove the controls array returned as part of the SearchEntry
// Some attributes can have range attributes (paging). Execute the query
// again to get additional items.
pendingRangeRetrievals++;
parseRangeAttributes.call(self, result, opts, function(err, item) {
pendingRangeRetrievals--;
if (err) item = entry.object;
entryParser(item, entry.raw, function(item) {
if (item) results.push(item);
if ((! pendingRangeRetrievals) && (isDone)) {
onSearchEnd();
}
});
});
}
/**
* Occurs when a search reference / referral is received. Follows the referral chase if
* enabled.
* @param {Object} ref The referral.
*/
function onReferralChase(ref) {
var index = 0;
var referralUrl;
// Loop over the referrals received.
while (referralUrl = (ref.uris || [])[index++]) {
if (isAllowedReferral(referralUrl)) {
log.debug('Following LDAP referral chase at %s', referralUrl);
var referralClient = createClient.call(self, referralUrl, opts);
pendingReferrals.push(referralClient);
var referral = Url.parse(referralUrl);
var referralBaseDn = (referral.pathname || '/').substring(1);
referralClient.search(referralBaseDn, getLdapOpts(opts), controls, function(err, res) {
/**
* Occurs when a error is encountered with the referral client.
* @param {Object} err The error object or string.
*/
function onReferralError(err) {
log.error(err, '[%s] An error occurred chasing the LDAP referral on %s (%j)',
(err || {}).errno, referralBaseDn, opts);
removeReferral(referralClient);
}
// If the referral chase / search failed, fail silently.
if (err) {
onReferralError(err);
return;
}
res.on('searchEntry', onSearchEntry);
res.on('searchReference', onReferralChase);
res.on('error', onReferralError);
res.on('end', function(result) {
removeReferral(referralClient);
onSearchEnd();
});
});
}
}
}
/**
* Occurs when a client / search error occurs.
* @param {Object} err The error object or string.
* @param {Object} res The optional server response.
*/
function onClientError(err, res) {
if ((err || {}).name === 'SizeLimitExceededError') {
onSearchEnd(res);
return;
}
client.unbind();
log.error(err, '[%s] An error occurred performing the requested LDAP search on %s (%j)',
(err || {}).errno || 'UNKNOWN', baseDN, opts);
if (callback) callback(err);
}
/**
* Occurs when a search results have all been processed.
* @param {Object} result
*/
function onSearchEnd(result) {
if ((! pendingRangeRetrievals) && (pendingReferrals.length <= 0)) {
client.unbind();
log.info('Active directory search (%s) for "%s" returned %d entries.',
baseDN, truncateLogOutput(opts.filter),
(results || []).length);
if (callback) callback(null, results);
}
}
var results = [];
var controls = opts.controls || (opts.controls = []);
// Add paging results control by default if not already added.
if (!_.any(controls, function(control) { return(control instanceof ldap.PagedResultsControl); })) {
log.debug('Adding PagedResultControl to search (%s) with filter "%s" for %j',
baseDN, truncateLogOutput(opts.filter), _.any(opts.attributes) ? opts.attributes : '[*]');
controls.push(new ldap.PagedResultsControl({ value: { size: defaultPageSize } }));
}
if (opts.includeDeleted) {
if (!_.any(controls, function(control) { return(control.type === '1.2.840.113556.1.4.417'); })) {
log.debug('Adding ShowDeletedOidControl(1.2.840.113556.1.4.417) to search (%s) with filter "%s" for %j',
baseDN, truncateLogOutput(opts.filter), _.any(opts.attributes) ? opts.attributes : '[*]');
controls.push(new ldap.Control({ type: '1.2.840.113556.1.4.417', criticality: true }));
}
}
log.debug('Querying active directory (%s) with filter "%s" for %j',
baseDN, truncateLogOutput(opts.filter), _.any(opts.attributes) ? opts.attributes : '[*]');
client.search(baseDN, getLdapOpts(opts), controls, function onSearch(err, res) {
if (err) {
if (callback) callback(err);
return;
}
res.on('searchEntry', onSearchEntry);
res.on('searchReference', onReferralChase);
res.on('error', function(err) { onClientError(err, res); });
res.on('end', function(result) {
isDone = true; // Flag that the primary query is complete
onSearchEnd(result);
});
});
}
/**
* Handles any attributes that might have been returned with a range= specifier.
*
* @private
* @param {Object} result The entry returned from the query.
* @param {Object} opts The original LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, result: {Object}})
*/
function parseRangeAttributes(result, opts, callback) {
log.trace('parseRangeAttributes(%j,%j)', result, opts);
var self = this;
// Check to see if any of the result attributes have range= attributes.
// If not, return immediately.
if (! RangeRetrievalSpecifierAttribute.prototype.hasRangeAttributes(result)) {
callback(null, result);
return;
}
// Parse the range attributes that were provided. If the range attributes are null
// or indicate that the range is complete, return the result.
var rangeAttributes = RangeRetrievalSpecifierAttribute.prototype.getRangeAttributes(result);
if ((! rangeAttributes) || (rangeAttributes.length <= 0)) {
callback(null, result);
return;
}
// Parse each of the range attributes. Merge the range attributes into
// the properly named property.
var queryAttributes = [];
_.each(rangeAttributes, function(rangeAttribute, index) {
// Merge existing range into the properly named property.
if (! result[rangeAttribute.attributeName]) result[rangeAttribute.attributeName] = [];
Array.prototype.push.apply(result[rangeAttribute.attributeName], result[rangeAttribute.toString()]);
delete(result[rangeAttribute.toString()]);
// Build our ldap query attributes with the proper attribute;range= tags to
// get the next sequence of data.
var queryAttribute = rangeAttribute.next();
if ((queryAttribute) && (! queryAttribute.isComplete())) {
queryAttributes.push(queryAttribute.toString());
}
});
// If we're at the end of the range (i.e. all items retrieved), return the result.
if (queryAttributes.length <= 0) {
log.debug('All attribute ranges %j retrieved for %s', rangeAttributes, result.dn);
callback(null, result);
return;
}
log.debug('Attribute range retrieval specifiers %j found for "%s". Next range: %j',
rangeAttributes, result.dn, queryAttributes);
// Execute the query again with the query attributes updated.
opts = _.defaults({ filter: '(distinguishedName='+parseDistinguishedName(result.dn)+')',
attributes: queryAttributes }, opts);
search.call(self, opts, function onSearch(err, results) {
if (err) {
callback(err);
return;
}
// Should be only one result
var item = (results || [])[0];
for(var property in item) {
if (item.hasOwnProperty(property)) {
if (! result[property]) result[property] = [];
if (_.isArray(result[property])) {
Array.prototype.push.apply(result[property], item[property]);
}
}
}
callback(null, result);
});
}
/**
* Checks to see if any of the specified attributes are the wildcard
* '*" attribute.
* @private
* @params {Array} attributes - The attributes to inspect.
* @returns {Boolean}
*/
function shouldIncludeAllAttributes(attributes) {
return((typeof(attributes) !== 'undefined') &&
((attributes.length === 0) ||
_.any(attributes || [], function(attribute) {
return(attribute === '*');
}))
);
}
/**
* Gets the required ldap attributes for group related queries in order to
* do recursive queries, etc.
*
* @private
* @params {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
*/
function getRequiredLdapAttributesForGroup(opts) {
if (shouldIncludeAllAttributes((opts || {}).attributes)) {
return([ ]);
}
return(_.union([ 'dn', 'objectCategory', 'groupType', 'cn' ],
includeGroupMembershipFor(opts, 'group') ? [ 'member' ] : []));
}
/**
* Gets the required ldap attributes for user related queries in order to
* do recursive queries, etc.
*
* @private
* @params {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
*/
function getRequiredLdapAttributesForUser(opts) {
if (shouldIncludeAllAttributes((opts || {}).attributes)) {
return([ ]);
}
return(_.union([ 'dn', 'cn' ],
includeGroupMembershipFor(opts, 'user') ? [ 'member' ] : []));
}
/**
* Retrieves / merges the attributes for the query.
*/
function joinAttributes() {
for (var index = 0, length = arguments.length; index < length; index++){
if (shouldIncludeAllAttributes(arguments[index])) {
return([ ]);
}
}
return(_.union.apply(this, arguments));
}
/**
* Picks only the requested attributes from the ldap result. If a wildcard or
* empty result is specified, then all attributes are returned.
* @private
* @params {Object} result The ldap result
* @params {Array} attributes The desired or wanted attributes
* @returns {Object} A copy of the object with only the requested attributes
*/
function pickAttributes(result, attributes) {
if (shouldIncludeAllAttributes(attributes)) {
attributes = function() {
return(true);
};
}
return(_.pick(result, attributes));
}
/**
* Gets all of the groups that the specified distinguishedName (DN) belongs to.
*
* @private
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} dn The distinguishedName (DN) to find membership of.
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, groups: {Array[Group]})
*/
function getGroupMembershipForDN(opts, dn, stack, callback) {
var self = this;
if (typeof(stack) === 'function') {
callback = stack;
stack = undefined;
}
if (typeof(dn) === 'function') {
callback = dn;
dn = opts;
opts = undefined;
}
if (typeof(opts) === 'string') {
stack = dn;
dn = opts;
opts = undefined;
}
log.trace('getGroupMembershipForDN(%j,%s,stack:%j)', opts, dn, (stack || []).length);
// Ensure that a valid DN was provided. Otherwise abort the search.
if (! dn) {
var error = new Error('No distinguishedName (dn) specified for group membership retrieval.');
log.error(error);
if (hasEvents('error')) self.emit('error', error);
return(callback(error));
}
// Note: Microsoft provides a 'Transitive Filter' for querying nested groups.
// i.e. (member:1.2.840.113556.1.4.1941:=<userDistinguishedName>)
// However this filter is EXTREMELY slow. Recursively querying ActiveDirectory
// is typically 10x faster.
opts = _.defaults(_.omit(opts || {}, 'filter', 'scope', 'attributes'), {
filter: '(member='+parseDistinguishedName(dn)+')',
scope: 'sub',
attributes: joinAttributes((opts || {}).attributes || defaultAttributes.group, [ 'groupType' ])
});
search.call(self, opts, function(err, results) {
if (err) {
callback(err);
return;
}
var groups = [];
async.forEach(results, function(group, asyncCallback) {
// accumulates discovered groups
if (typeof(stack) !== 'undefined') {
if (!_.findWhere(stack, { cn: group.cn })) {
stack.push(new Group(group));
} else {
// ignore groups already found
return(asyncCallback());
}
_.each(stack,function(s) {
if (!_.findWhere(groups, { cn: s.cn })) {
groups.push(s);
}
});
}
if (isGroupResult(group)) {
log.debug('Adding group "%s" to %s"', group.dn, dn);
groups.push(new Group(group));
// Get the groups that this group may be a member of.
log.debug('Retrieving nested group membership for group "%s"', group.dn);
getGroupMembershipForDN.call(self, opts, group.dn, groups, function(err, nestedGroups) {
if (err) {
asyncCallback(err);
return;
}
nestedGroups = _.map(nestedGroups, function(nestedGroup) {
if (isGroupResult(nestedGroup)) {
return(new Group(nestedGroup));
}
});
log.debug('Group "%s" which is a member of group "%s" has %d nested group(s). Nested: %j',
group.dn, dn, nestedGroups.length, _.map(nestedGroups, function(group) {
return(group.dn);
}));
Array.prototype.push.apply(groups, nestedGroups);
asyncCallback();
});
}
else asyncCallback();
}, function(err) {
if (err) {
callback(err);
return;
}
// Remove the duplicates from the list.
groups = _.uniq(_.sortBy(groups, function(group) { return(group.cn || group.dn); }), false, function(group) {
return(group.dn);
});
log.info('Group "%s" has %d group(s). Groups: %j', dn, groups.length, _.map(groups, function(group) {
return(group.dn);
}));
callback(err, groups);
});
});
}
/**
* For the specified filter, return the distinguishedName (dn) of all the matched entries.
*
* @private
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @params {Object|String} filter The LDAP filter to execute. Optionally a custom LDAP query object can be specified. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, dns: {Array[String]})
*/
function getDistinguishedNames(opts, filter, callback) {
var self = this;
if (typeof(filter) === 'function') {
callback = filter;
filter = opts;
opts = undefined;
}
if (typeof(opts) === 'string') {
filter = opts;
opts = undefined;
}
log.trace('getDistinguishedNames(%j,%j)', opts, filter);
opts = _.defaults(_.omit(opts || {}, 'attributes'), {
filter: filter,
scope: 'sub',
attributes: joinAttributes((opts || {}).attributes || [], [ 'dn' ])
});
search.call(self, opts, function(err, results) {
if (err) {
if (callback) callback(err);
return;
}
// Extract just the DN from the results
var dns = _.map(results, function(result) {
return(result.dn);
});
log.info('%d distinguishedName(s) found for LDAP query: "%s". Results: %j',
results.length, truncateLogOutput(opts.filter), results);
callback(null, dns);
});
}
/**
* Gets the distinguished name for the specified user (userPrincipalName/email or sAMAccountName).
*
* @private
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} username The name of the username to retrieve the distinguishedName (dn).
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, dn: {String})
*/
function getUserDistinguishedName(opts, username, callback) {
var self = this;
if (typeof(username) === 'function') {
callback = username;
username = opts;
opts = undefined;
}
log.trace('getDistinguishedName(%j,%s)', opts, username);
// Already a dn?
if (isDistinguishedName.call(self, username)) {
log.debug('"%s" is already a distinguishedName. NOT performing query.', username);
callback(null, username);
return;
}
getDistinguishedNames.call(self, opts, getUserQueryFilter(username), function(err, dns) {
if (err) {
callback(err);
return;
}
log.info('%d distinguishedName(s) found for user: "%s". Returning first dn: "%s"',
(dns || []).length, username, (dns || [])[0]);
callback(null, (dns || [])[0]);
});
}
/**
* Gets the distinguished name for the specified group (cn).
*
* @private
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} groupName The name of the group to retrieve the distinguishedName (dn).
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, dn: {String})
*/
function getGroupDistinguishedName(opts, groupName, callback) {
var self = this;
if (typeof(groupName) === 'function') {
callback = groupName;
groupName = opts;
opts = undefined;
}
log.trace('getGroupDistinguishedName(%j,%s)', opts, groupName);
// Already a dn?
if (isDistinguishedName.call(self, groupName)) {
log.debug('"%s" is already a distinguishedName. NOT performing query.', groupName);
callback(null, groupName);
return;
}
getDistinguishedNames.call(self, opts, getGroupQueryFilter(groupName), function(err, dns) {
if (err) {
callback(err);
return;
}
log.info('%d distinguishedName(s) found for group "%s". Returning first dn: "%s"',
(dns || []).length, groupName, (dns || [])[0]);
callback(null, (dns || [])[0]);
});
}
/**
* Gets the currently configured default attributes
*
* @private
*/
ActiveDirectory.prototype._getDefaultAttributes = function _getDefaultAttributes() {
return(_.defaults({}, defaultAttributes));
};
/**
* Gets the currently configured default user attributes
*
* @private
*/
ActiveDirectory.prototype._getDefaultUserAttributes = function _getDefaultUserAttributes() {
return(_.defaults({}, (defaultAttributes || {}).user));
};
/**
* Gets the currently configured default group attributes
*
* @private
*/
ActiveDirectory.prototype._getDefaultGroupAttributes = function _getDefaultGroupAttributes() {
return(_.defaults({}, (defaultAttributes || {}).group));
};
/**
* For the specified group, retrieve all of the users that belong to the group.
*
* @public
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} groupName The name of the group to retrieve membership from.
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, users: {Array[User]})
*/
ActiveDirectory.prototype.getUsersForGroup = function getUsersForGroup(opts, groupName, callback) {
var self = this;
if (typeof(groupName) === 'function') {
callback = groupName;
groupName = opts;
opts = undefined;
}
log.trace('getUsersForGroup(%j,%s)', opts, groupName);
var users = [];
var groups = [];
self.findGroup(_.defaults({}, _.omit(opts || {}, 'attributes'), {
attributes: joinAttributes((opts || {}).attributes || defaultAttributes.group, [ 'member' ])
}),
groupName, function(err, group) {
if (err) {
if (callback) callback(err);
return;
}
// Group not found
if (! group) {
if (callback) callback(null, group);
return;
}
// If only one result found, encapsulate result into array.
if (typeof(group.member) === 'string') {
group.member = [ group.member ];
}
/**
* Breaks the large array into chucks of the specified size.
* @param {Array} arr The array to break into chunks
* @param {Number} chunkSize The size of each chunk.
* @returns {Array} The resulting array containing each chunk
*/
function chunk(arr, chunkSize) {
var result = [];
for (var index = 0, length = arr.length; index < length; index += chunkSize) {
result.push(arr.slice(index,index + chunkSize));
}
return(result);
}
// We need to break this into the default size queries so
// we can have them running concurrently.
var chunks = chunk(group.member || [], defaultPageSize);
if (chunks.length > 1) {
log.debug('Splitting %d member(s) of "%s" into %d parallel chunks',
(group.member || []).length, groupName, chunks.length);
}
var chunksProcessed = 0;
async.each(chunks, function getUsersForGroup_ChunkItem(members, asyncChunkCallback) {
// We're going to build up a bulk LDAP query so we can reduce
// the number of round trips to the server. We need to get
// additional details about each 'member' to determine if
// it is a group or another user. If it's a group, we need
// to recursively retrieve the members of that group.
var filter = _.reduce(members || [], function(memo, member, index) {
return(memo+'(distinguishedName='+parseDistinguishedName(member)+')');
}, '');
filter = '(&(|(objectCategory=User)(objectCategory=Group))(|'+filter+'))';
var localOpts = {
filter: filter,
scope: 'sub',
attributes: joinAttributes((opts || {}).attributes || defaultAttributes.user || [],
getRequiredLdapAttributesForUser(opts), [ 'groupType' ])
};
search.call(self, localOpts, function onSearch(err, members) {
if (err) {
asyncChunkCallback(err);
return;
}
// Parse the results in parallel.
async.forEach(members, function(member, asyncCallback) {
// If a user, no groupType will be specified.
if (! member.groupType) {
var user = new User(pickAttributes(member, (opts || {}).attributes || defaultAttributes.user));
self.emit(user);
users.push(user);
asyncCallback();
}
else {
// We have a group, recursively get the users belonging to this group.
self.getUsersForGroup(opts, member.cn, function(err, nestedUsers) {
users.push.apply(users, nestedUsers);
asyncCallback();
});
}
}, function(err) {
if (chunks.length > 1) {
log.debug('Finished processing chunk %d/%d', ++chunksProcessed, chunks.length);
}
asyncChunkCallback(err);
});
});
}, function getUsersForGroup_ChunkComplete(err) {
// Remove duplicates
users = _.uniq(users, function(user) {
return(user.dn || user);
});
/*
// Remove the dn that was added for duplicate detection if not requested.
if (! _.any((opts || {}).attributes || defaultAttributes.user, function(attribute) {
return(attribute === 'dn');
})) {
users = _.each(users, function(user) {
delete(users.dn);
});
}
*/
log.info('%d user(s) belong in the group "%s"', users.length, groupName);
if (callback) callback(null, users);
});
});
};
/**
* For the specified username, get all of the groups that the user is a member of.
*
* @public
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} username The username to retrieve membership information about.
* @param {Function} [callback] The callback to execute when completed. callback(err: {Object}, groups: {Array[Group]})
*/
ActiveDirectory.prototype.getGroupMembershipForUser = function getGroupMembershipForUser(opts, username, callback) {
var self = this;
if (typeof(username) === 'function') {
callback = username;
username = opts;
opts = undefined;
}
log.trace('getGroupMembershipForUser(%j,%s)', opts, username);
getUserDistinguishedName.call(self, opts, username, function(err, dn) {
if (err) {
if (callback) callback(err);
return;
}
if (! dn) {
log.warn('Could not find a distinguishedName for the specified username: "%s"', username);
if (callback) callback();
return;
}
getGroupMembershipForDN.call(self, opts, dn, function(err, groups) {
if (err) {
if (callback) callback(err);
return;
}
var results = [];
_.each(groups, function(group) {
var result = new Group(pickAttributes(group, (opts || {}).attributes || defaultAttributes.group));
self.emit(result);
results.push(result);
});
if (callback) callback(err, results);
});
});
};
/**
* For the specified group, get all of the groups that the group is a member of.
*
* @public
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} groupName The group to retrieve membership information about.
* @param {Function} [callback] The callback to execute when completed. callback(err: {Object}, groups: {Array[Group]})
*/
ActiveDirectory.prototype.getGroupMembershipForGroup = function getGroupMembershipForGroup(opts, groupName, callback) {
var self = this;
if (typeof(groupName) === 'function') {
callback = groupName;
groupName = opts;
opts = undefined;
}
log.trace('getGroupMembershipForGroup(%j,%s)', opts, groupName);
getGroupDistinguishedName.call(self, opts, groupName, function(err, dn) {
if (err) {
if (callback) callback(err);
return;
}
if (! dn) {
log.warn('Could not find a distinguishedName for the specified group name: "%s"', groupName);
if (callback) callback();
return;
}
getGroupMembershipForDN.call(self, opts, dn, function(err, groups) {
if (err) {
if (callback) callback(err);
return;
}
var results = [];
_.each(groups, function(group) {
var result = new Group(pickAttributes(group, (opts || {}).attributes || defaultAttributes.group));
self.emit(result);
results.push(result);
});
if (callback) callback(err, results);
});
});
};
/**
* Checks to see if the specified username exists.
*
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} username The username to check to see if it exits.
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, result: {Boolean})
*/
ActiveDirectory.prototype.userExists = function userExists(opts, username, callback) {
var self = this;
if (typeof(username) === 'function') {
callback = username;
username = opts;
opts = undefined;
}
log.trace('userExists(%j,%s)', opts, username);
self.findUser(opts, username, function(err, user) {
if (err) {
callback(err);
return;
}
log.info('"%s" %s exist.', username, (user != null) ? 'DOES' : 'DOES NOT');
callback(null, user != null);
});
};
/**
* Checks to see if the specified group exists.
*
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} groupName The group to check to see if it exists.
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, result: {Boolean})
*/
ActiveDirectory.prototype.groupExists = function groupExists(opts, groupName, callback) {
var self = this;
if (typeof(groupName) === 'function') {
callback = groupName;
groupName = opts;
opts = undefined;
}
log.trace('groupExists(%j,%s)', opts, groupName);
self.findGroup(opts, groupName, function(err, result) {
if (err) {
callback(err);
return;
}
log.info('"%s" %s exist.', groupName, (result != null) ? 'DOES' : 'DOES NOT');
callback(null, result != null);
});
};
/**
* Checks to see if the specified user is a member of the specified group.
*
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }
* @param {String} username The username to check for membership.
* @param {String} groupName The group to check for membership.
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, result: {Boolean})
*/
ActiveDirectory.prototype.isUserMemberOf = function isUserMemberOf(opts, username, groupName, callback) {
var self = this;
if (typeof(groupName) === 'function') {
callback = groupName;
groupName = username;
username = opts;
opts = undefined;
}
log.trace('isUserMemberOf(%j,%s,%s)', opts, username, groupName);
opts = _.defaults(_.omit(opts || {}, 'attributes'), {
attributes: [ 'cn', 'dn' ]
});
self.getGroupMembershipForUser(opts, username, function(err, groups) {
if (err) {
callback(err);
return;
}
if ((! groups) || (groups.length === 0)) {
log.info('"%s" IS NOT a member of "%s". No groups found for user.', username, groupName);
callback(null, false);
return;
}
// Check to see if the group.distinguishedName or group.cn matches the list of
// retrieved groups.
var lowerCaseGroupName = (groupName || '').toLowerCase();
var result = _.any(groups, function(item) {
return(((item.dn || '').toLowerCase() === lowerCaseGroupName) ||
((item.cn || '').toLowerCase() === lowerCaseGroupName));
});
log.info('"%s" %s a member of "%s"', username, result ? 'IS' : 'IS NOT', groupName);
callback(null, result);
});
};
/**
* Checks to see if group membership for the specified type is enabled.
*
* @param {Object} [opts] The options to inspect. If not specified, uses this.opts.
* @param {String} name The name of the membership value to inspect. Values: (all|user|group)
* @returns {Boolean} True if the specified membership is enabled.
*/
function includeGroupMembershipFor(opts, name) {
if (typeof(opts) === 'string') {
name = opts;
opts = this.opts;
}
var lowerCaseName = (name || '').toLowerCase();
return(_.any(((opts || this.opts || {}).includeMembership || []), function(i) {
i = i.toLowerCase();
return((i === 'all') || (i === lowerCaseName));
}));
}
/**
* Perform a generic search for the specified LDAP query filter. This function will return both
* groups and users that match the specified filter. Any results not recognized as a user or group
* (i.e. computer accounts, etc.) can be found in the 'other' attribute / array of the result.
*
* @public
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }. Optionally, if only a string is provided, then the string is assumed to be an LDAP filter.
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, { users: [ User ], groups: [ Group ], other: [ ] )
*/
ActiveDirectory.prototype.find = function find(opts, callback) {
var self = this;
if (typeof(opts) === 'function') {
callback = opts;
opts = undefined;
}
if (typeof(opts) === 'string') {
opts = {
filter: opts
};
}
log.trace('find(%j)', opts);
var localOpts = _.defaults(_.omit(opts || {}, 'attributes'), {
scope: 'sub',
attributes: joinAttributes((opts || {}).attributes || [], defaultAttributes.group || [], defaultAttributes.user || [],
getRequiredLdapAttributesForGroup(opts), getRequiredLdapAttributesForUser(opts),
[ 'objectCategory' ])
});
search.call(self, localOpts, function onFind(err, results) {
if (err) {
if (callback) callback(err);
return;
}
if ((! results) || (results.length === 0)) {
log.warn('No results found for query "%s"', truncateLogOutput(localOpts.filter));
if (callback) callback();
self.emit('done');
return;
}
var result = {
users: [],
groups: [],
other: []
};
// Parse the results in parallel.
async.forEach(results, function(item, asyncCallback) {
if (isGroupResult(item)) {
var group = new Group(pickAttributes(item, (opts || {}).attributes || defaultAttributes.group));
result.groups.push(group);
// Also retrieving user group memberships?
if (includeGroupMembershipFor(opts, 'group')) {
getGroupMembershipForDN.call(self, opts, group.dn, function(err, groups) {
if (err) return(asyncCallback(err));
group.groups = groups;
self.emit('group', group);
asyncCallback();
});
} else {
self.emit('group', group);
asyncCallback();
}
}
else if (isUserResult(item)) {
var user = new User(pickAttributes(item, (opts || {}).attributes || defaultAttributes.user));
result.users.push(user);
// Also retrieving user group memberships?
if (includeGroupMembershipFor(opts, 'user')) {
getGroupMembershipForDN.call(self, opts, user.dn, function(err, groups) {
if (err) return(asyncCallback(err));
user.groups = groups;
self.emit('user', user);
asyncCallback();
});
} else {
self.emit('user', user);
asyncCallback();
}
}
else {
var other = pickAttributes(item, (opts || {}).attributes || _.union(defaultAttributes.user, defaultAttributes.group));
result.other.push(other);
self.emit('other', other);
asyncCallback();
}
}, function(err) {
if (err) {
if (callback) callback(err);
return;
}
log.info('%d group(s), %d user(s), %d other found for query "%s". Results: %j',
result.groups.length, result.users.length, result.other.length,
truncateLogOutput(opts.filter), result);
self.emit('groups', result.groups);
self.emit('users', result.users);
if (callback) callback(null, result);
});
});
};
/**
* Perform a generic search on the Deleted Objects container for active directory. For this function
* to work correctly, the tombstone feature for active directory must be enabled. A tombstoned object
* has most of the attributes stripped from the object.
*
* @public
* @param {Object} [opts] Optional LDAP query string parameters to execute. { scope: '', filter: '', attributes: [ '', '', ... ], sizeLimit: 0, timelimit: 0 }. Optionally, if only a string is provided, then the string is assumed to be an LDAP filter.
* @param {Function} callback The callback to execute when completed. callback(err: {Object}, result: [ ])
*/
ActiveDirectory.prototype.findDeletedObjects = function find(opts, callback) {
var self = this;
if (typeof(opts) === 'function') {
callback = opts;
opts = undefined;
}
if (typeof(opts) ===