'use strict'; exports = module.exports = { search: search, verifyPassword: verifyPassword, createAndVerifyUserIfNotExist: createAndVerifyUserIfNotExist, testConfig: testConfig, startSyncer: startSyncer, injectPrivateFields: injectPrivateFields, removePrivateFields: removePrivateFields, sync: sync }; var assert = require('assert'), async = require('async'), auditSource = require('./auditsource.js'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), debug = require('debug')('box:externalldap'), groups = require('./groups.js'), ldap = require('ldapjs'), once = require('once'), settings = require('./settings.js'), tasks = require('./tasks.js'), users = require('./users.js'); function injectPrivateFields(newConfig, currentConfig) { if (newConfig.bindPassword === constants.SECRET_PLACEHOLDER) newConfig.bindPassword = currentConfig.bindPassword; } function removePrivateFields(ldapConfig) { assert.strictEqual(typeof ldapConfig, 'object'); if (ldapConfig.bindPassword) ldapConfig.bindPassword = constants.SECRET_PLACEHOLDER; return ldapConfig; } function translateUser(ldapConfig, ldapUser) { assert.strictEqual(typeof ldapConfig, 'object'); return { username: ldapUser[ldapConfig.usernameField], email: ldapUser.mail || ldapUser.mailPrimaryAddress, displayName: ldapUser.cn // user.giveName + ' ' + user.sn }; } function validUserRequirements(user) { if (!user.username || !user.email || !user.displayName) { debug(`[Invalid LDAP user] username=${user.username} email=${user.email} displayName=${user.displayName}`); return false; } else { return true; } } // performs service bind if required function getClient(externalLdapConfig, doBindAuth, callback) { assert.strictEqual(typeof externalLdapConfig, 'object'); assert.strictEqual(typeof doBindAuth, 'boolean'); assert.strictEqual(typeof callback, 'function'); // ensure we only callback once since we also have to listen to client.error events callback = once(callback); // basic validation to not crash try { ldap.parseDN(externalLdapConfig.baseDn); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid baseDn')); } try { ldap.parseFilter(externalLdapConfig.filter); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid filter')); } var config = { url: externalLdapConfig.url, tlsOptions: { rejectUnauthorized: externalLdapConfig.acceptSelfSignedCerts ? false : true } }; var client; try { client = ldap.createClient(config); } catch (e) { if (e instanceof ldap.ProtocolError) return callback(new BoxError(BoxError.BAD_FIELD, 'url protocol is invalid')); return callback(new BoxError(BoxError.INTERNAL_ERROR, e)); } // ensure we don't just crash client.on('error', function (error) { callback(new BoxError(BoxError.EXTERNAL_ERROR, error)); }); // skip bind auth if none exist or if not wanted if (!externalLdapConfig.bindDn || !doBindAuth) return callback(null, client); client.bind(externalLdapConfig.bindDn, externalLdapConfig.bindPassword, function (error) { if (error instanceof ldap.InvalidCredentialsError) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error)); callback(null, client); }); } function ldapGetByDN(externalLdapConfig, dn, callback) { assert.strictEqual(typeof externalLdapConfig, 'object'); assert.strictEqual(typeof dn, 'string'); assert.strictEqual(typeof callback, 'function'); getClient(externalLdapConfig, true, function (error, client) { if (error) return callback(error); let searchOptions = { paged: true, scope: 'sub' // We may have to make this configurable }; debug(`Get object at ${dn}`); // basic validation to not crash try { ldap.parseDN(dn); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid DN')); } client.search(dn, searchOptions, function (error, result) { if (error instanceof ldap.NoSuchObjectError) return callback(new BoxError(BoxError.NOT_FOUND)); if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error)); let ldapObjects = []; result.on('searchEntry', entry => ldapObjects.push(entry.object)); result.on('error', error => callback(new BoxError(BoxError.EXTERNAL_ERROR, error))); result.on('end', function (result) { client.unbind(); if (result.status !== 0) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Server returned status ' + result.status)); if (ldapObjects.length === 0) return callback(new BoxError(BoxError.NOT_FOUND)); callback(null, ldapObjects[0]); }); }); }); } // TODO support search by email function ldapUserSearch(externalLdapConfig, options, callback) { assert.strictEqual(typeof externalLdapConfig, 'object'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); getClient(externalLdapConfig, true, function (error, client) { if (error) return callback(error); let searchOptions = { paged: true, filter: ldap.parseFilter(externalLdapConfig.filter), scope: 'sub' // We may have to make this configurable }; if (options.filter) { // https://github.com/ldapjs/node-ldapjs/blob/master/docs/filters.md let extraFilter = ldap.parseFilter(options.filter); searchOptions.filter = new ldap.AndFilter({ filters: [ extraFilter, searchOptions.filter ] }); } debug(`Listing users at ${externalLdapConfig.baseDn} with filter ${searchOptions.filter.toString()}`); client.search(externalLdapConfig.baseDn, searchOptions, function (error, result) { if (error instanceof ldap.NoSuchObjectError) return callback(new BoxError(BoxError.NOT_FOUND)); if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error)); let ldapUsers = []; result.on('searchEntry', entry => ldapUsers.push(entry.object)); result.on('error', error => callback(new BoxError(BoxError.EXTERNAL_ERROR, error))); result.on('end', function (result) { client.unbind(); if (result.status !== 0) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Server returned status ' + result.status)); callback(null, ldapUsers); }); }); }); } function ldapGroupSearch(externalLdapConfig, options, callback) { assert.strictEqual(typeof externalLdapConfig, 'object'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); getClient(externalLdapConfig, true, function (error, client) { if (error) return callback(error); let searchOptions = { paged: true, scope: 'sub' // We may have to make this configurable }; if (externalLdapConfig.groupFilter) searchOptions.filter = ldap.parseFilter(externalLdapConfig.groupFilter); if (options.filter) { // https://github.com/ldapjs/node-ldapjs/blob/master/docs/filters.md let extraFilter = ldap.parseFilter(options.filter); searchOptions.filter = new ldap.AndFilter({ filters: [ extraFilter, searchOptions.filter ] }); } debug(`Listing groups at ${externalLdapConfig.groupBaseDn} with filter ${searchOptions.filter.toString()}`); client.search(externalLdapConfig.groupBaseDn, searchOptions, function (error, result) { if (error instanceof ldap.NoSuchObjectError) return callback(new BoxError(BoxError.NOT_FOUND)); if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error)); let ldapGroups = []; result.on('searchEntry', entry => ldapGroups.push(entry.object)); result.on('error', error => callback(new BoxError(BoxError.EXTERNAL_ERROR, error))); result.on('end', function (result) { client.unbind(); if (result.status !== 0) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Server returned status ' + result.status)); callback(null, ldapGroups); }); }); }); } function testConfig(config, callback) { assert.strictEqual(typeof config, 'object'); assert.strictEqual(typeof callback, 'function'); if (config.provider === 'noop') return callback(); if (!config.url) return callback(new BoxError(BoxError.BAD_FIELD, 'url must not be empty')); if (!config.url.startsWith('ldap://') && !config.url.startsWith('ldaps://')) return callback(new BoxError(BoxError.BAD_FIELD, 'url is missing ldap:// or ldaps:// prefix')); if (!config.usernameField) config.usernameField = 'uid'; // bindDn may not be a dn! if (!config.baseDn) return callback(new BoxError(BoxError.BAD_FIELD, 'basedn must not be empty')); try { ldap.parseDN(config.baseDn); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid baseDn')); } if (!config.filter) return callback(new BoxError(BoxError.BAD_FIELD, 'filter must not be empty')); try { ldap.parseFilter(config.filter); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid filter')); } if ('syncGroups' in config && typeof config.syncGroups !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'syncGroups must be a boolean')); if ('acceptSelfSignedCerts' in config && typeof config.acceptSelfSignedCerts !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'acceptSelfSignedCerts must be a boolean')); if (config.syncGroups) { if (!config.groupBaseDn) return callback(new BoxError(BoxError.BAD_FIELD, 'groupBaseDn must not be empty')); try { ldap.parseDN(config.groupBaseDn); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid groupBaseDn')); } if (!config.groupFilter) return callback(new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty')); try { ldap.parseFilter(config.groupFilter); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid groupFilter')); } if (!config.groupnameField || typeof config.groupnameField !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty')); } getClient(config, true, function (error, client) { if (error) return callback(error); var opts = { filter: config.filter, scope: 'sub' }; client.search(config.baseDn, opts, function (error, result) { if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error)); result.on('searchEntry', function (/* entry */) {}); result.on('error', function (error) { client.unbind(); callback(new BoxError(BoxError.BAD_FIELD, `Unable to search directory: ${error.message}`)); }); result.on('end', function (/* result */) { client.unbind(); callback(); }); }); }); } function search(identifier, callback) { assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); settings.getExternalLdapConfig(function (error, externalLdapConfig) { if (error) return callback(error); if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled')); ldapUserSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${identifier}` }, function (error, ldapUsers) { if (error) return callback(error); // translate ldap properties to ours let users = ldapUsers.map(function (u) { return translateUser(externalLdapConfig, u); }); callback(null, users); }); }); } function createAndVerifyUserIfNotExist(identifier, password, callback) { assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof callback, 'function'); settings.getExternalLdapConfig(function (error, externalLdapConfig) { if (error) return callback(error); if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled')); if (!externalLdapConfig.autoCreate) return callback(new BoxError(BoxError.BAD_STATE, 'auto create not enabled')); ldapUserSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${identifier}` }, function (error, ldapUsers) { if (error) return callback(error); if (ldapUsers.length === 0) return callback(new BoxError(BoxError.NOT_FOUND)); if (ldapUsers.length > 1) return callback(new BoxError(BoxError.CONFLICT)); let user = translateUser(externalLdapConfig, ldapUsers[0]); if (!validUserRequirements(user)) return callback(new BoxError(BoxError.BAD_FIELD)); users.create(user.username, null /* password */, user.email, user.displayName, { source: 'ldap' }, auditSource.EXTERNAL_LDAP_AUTO_CREATE, function (error, user) { if (error) { debug(`createAndVerifyUserIfNotExist: Failed to auto create user ${user.username}`, error); return callback(new BoxError(BoxError.INTERNAL_ERROR)); } verifyPassword(user, password, function (error) { if (error) return callback(error); callback(null, user); }); }); }); }); } function verifyPassword(user, password, callback) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof callback, 'function'); settings.getExternalLdapConfig(function (error, externalLdapConfig) { if (error) return callback(error); if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled')); ldapUserSearch(externalLdapConfig, { filter: `${externalLdapConfig.usernameField}=${user.username}` }, function (error, ldapUsers) { if (error) return callback(error); if (ldapUsers.length === 0) return callback(new BoxError(BoxError.NOT_FOUND)); if (ldapUsers.length > 1) return callback(new BoxError(BoxError.CONFLICT)); getClient(externalLdapConfig, false, function (error, client) { if (error) return callback(error); client.bind(ldapUsers[0].dn, password, function (error) { if (error instanceof ldap.InvalidCredentialsError) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error)); callback(null, translateUser(externalLdapConfig, ldapUsers[0])); }); }); }); }); } function startSyncer(callback) { assert.strictEqual(typeof callback, 'function'); settings.getExternalLdapConfig(function (error, externalLdapConfig) { if (error) return callback(error); if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled')); tasks.add(tasks.TASK_SYNC_EXTERNAL_LDAP, [], function (error, taskId) { if (error) return callback(error); tasks.startTask(taskId, {}, function (error, result) { debug('sync: done', error, result); }); callback(null, taskId); }); }); } function syncUsers(externalLdapConfig, progressCallback, callback) { assert.strictEqual(typeof externalLdapConfig, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); ldapUserSearch(externalLdapConfig, {}, function (error, ldapUsers) { if (error) return callback(error); debug(`Found ${ldapUsers.length} users`); let percent = 10; let step = 30/(ldapUsers.length+1); // ensure no divide by 0 // we ignore all errors here and just log them for now async.eachSeries(ldapUsers, function (user, iteratorCallback) { user = translateUser(externalLdapConfig, user); if (!validUserRequirements(user)) return iteratorCallback(); percent += step; progressCallback({ percent, message: `Syncing... ${user.username}` }); users.getByUsername(user.username, function (error, result) { if (error && error.reason !== BoxError.NOT_FOUND) return iteratorCallback(error); if (!result) { debug(`[adding user] username=${user.username} email=${user.email} displayName=${user.displayName}`); users.create(user.username, null /* password */, user.email, user.displayName, { source: 'ldap' }, auditSource.EXTERNAL_LDAP_TASK, function (error) { if (error) debug('syncUsers: Failed to create user', user, error.message); iteratorCallback(); }); } else if (result.source !== 'ldap') { debug(`[conflicting user] username=${user.username} email=${user.email} displayName=${user.displayName}`); iteratorCallback(); } else if (result.email !== user.email || result.displayName !== user.displayName) { debug(`[updating user] username=${user.username} email=${user.email} displayName=${user.displayName}`); users.update(result, { email: user.email, fallbackEmail: user.email, displayName: user.displayName }, auditSource.EXTERNAL_LDAP_TASK, function (error) { if (error) debug('Failed to update user', user, error); iteratorCallback(); }); } else { // user known and up-to-date debug(`[up-to-date user] username=${user.username} email=${user.email} displayName=${user.displayName}`); iteratorCallback(); } }); }, callback); }); } function syncGroups(externalLdapConfig, progressCallback, callback) { assert.strictEqual(typeof externalLdapConfig, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); if (!externalLdapConfig.syncGroups) { debug('Group sync is disabled'); progressCallback({ percent: 70, message: 'Skipping group sync...' }); return callback(null, []); } ldapGroupSearch(externalLdapConfig, {}, function (error, ldapGroups) { if (error) return callback(error); debug(`Found ${ldapGroups.length} groups`); let percent = 40; let step = 30/(ldapGroups.length+1); // ensure no divide by 0 // we ignore all non internal errors here and just log them for now async.eachSeries(ldapGroups, function (ldapGroup, iteratorCallback) { var groupName = ldapGroup[externalLdapConfig.groupnameField]; if (!groupName) return iteratorCallback(); // some servers return empty array for unknown properties :-/ if (typeof groupName !== 'string') return iteratorCallback(); // groups are lowercase groupName = groupName.toLowerCase(); percent += step; progressCallback({ percent, message: `Syncing... ${groupName}` }); groups.getByName(groupName, function (error, result) { if (error && error.reason !== BoxError.NOT_FOUND) return iteratorCallback(error); if (!result) { debug(`[adding group] groupname=${groupName}`); groups.create(groupName, 'ldap', function (error) { if (error) debug('syncGroups: Failed to create group', groupName, error); iteratorCallback(); }); } else { debug(`[up-to-date group] groupname=${groupName}`); iteratorCallback(); } }); }, function (error) { if (error) return callback(error); debug('sync: ldap sync is done', error); callback(error); }); }); } function syncGroupUsers(externalLdapConfig, progressCallback, callback) { assert.strictEqual(typeof externalLdapConfig, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); if (!externalLdapConfig.syncGroups) { debug('Group users sync is disabled'); progressCallback({ percent: 99, message: 'Skipping group users sync...' }); return callback(null, []); } groups.getAll(function (error, result) { if (error) return callback(error); var ldapGroups = result.filter(function (g) { return g.source === 'ldap'; }); debug(`Found ${ldapGroups.length} groups to sync users`); async.eachSeries(ldapGroups, function (group, iteratorCallback) { debug(`Sync users for group ${group.name}`); ldapGroupSearch(externalLdapConfig, {}, function (error, result) { if (error) return callback(error); if (!result || result.length === 0) { debug(`syncGroupUsers: Unable to find group ${group.name} ignoring for now.`); return callback(); } // since our group names are lowercase we cannot use potentially case matching ldap filters let found = result.find(function (r) { if (!r[externalLdapConfig.groupnameField]) return false; return r[externalLdapConfig.groupnameField].toLowerCase() === group.name; }); if (!found) { debug(`syncGroupUsers: Unable to find group ${group.name} ignoring for now.`); return callback(); } var ldapGroupMembers = found.member || found.uniqueMember || []; // if only one entry is in the group ldap returns a string, not an array! if (typeof ldapGroupMembers === 'string') ldapGroupMembers = [ ldapGroupMembers ]; debug(`Group ${group.name} has ${ldapGroupMembers.length} members.`); async.eachSeries(ldapGroupMembers, function (memberDn, iteratorCallback) { ldapGetByDN(externalLdapConfig, memberDn, function (error, result) { if (error) { debug(`Failed to get ${memberDn}:`, error); return iteratorCallback(); } debug(`Found member object at ${memberDn} adding to group ${group.name}`); const username = result[externalLdapConfig.usernameField]; if (!username) return iteratorCallback(); users.getByUsername(username, function (error, result) { if (error) { debug(`syncGroupUsers: Failed to get user by username ${username}`, error); return iteratorCallback(); } groups.addMember(group.id, result.id, function (error) { if (error && error.reason !== BoxError.ALREADY_EXISTS) debug('syncGroupUsers: Failed to add member', error); iteratorCallback(); }); }); }); }, function (error) { if (error) debug('syncGroupUsers: ', error); iteratorCallback(); }); }); }, callback); }); } function sync(progressCallback, callback) { assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); progressCallback({ percent: 10, message: 'Starting ldap user sync' }); settings.getExternalLdapConfig(function (error, externalLdapConfig) { if (error) return callback(error); if (externalLdapConfig.provider === 'noop') return callback(new BoxError(BoxError.BAD_STATE, 'not enabled')); async.series([ syncUsers.bind(null, externalLdapConfig, progressCallback), syncGroups.bind(null, externalLdapConfig, progressCallback), syncGroupUsers.bind(null, externalLdapConfig, progressCallback) ], function (error) { if (error) return callback(error); progressCallback({ percent: 100, message: 'Done' }); debug('sync: ldap sync is done', error); callback(error); }); }); }