Files
cloudron-box/src/externalldap.js
Girish Ramakrishnan 1b307632ab Use debug instead of console.* everywhere
No need to patch up console.* anymore

also removes supererror
2020-08-02 12:04:55 -07:00

589 lines
25 KiB
JavaScript

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