Files
cloudron-box/src/externalldap.js
2025-11-23 15:35:18 +01:00

548 lines
22 KiB
JavaScript

'use strict';
exports = module.exports = {
getConfig,
setConfig,
verifyPassword,
maybeCreateUser,
supports2FA,
startSyncer,
removePrivateFields,
sync
};
const assert = require('node:assert'),
AuditSource = require('./auditsource.js'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
cron = require('./cron.js'),
debug = require('debug')('box:externalldap'),
eventlog = require('./eventlog.js'),
groups = require('./groups.js'),
ldap = require('ldapjs'),
safe = require('safetydance'),
settings = require('./settings.js'),
tasks = require('./tasks.js'),
users = require('./users.js'),
util = require('node:util');
function removePrivateFields(ldapConfig) {
assert.strictEqual(typeof ldapConfig, 'object');
delete ldapConfig.bindPassword;
return ldapConfig;
}
function injectPrivateFields(newConfig, currentConfig) {
if (!Object.hasOwn(newConfig, 'bindPassword')) newConfig.bindPassword = currentConfig.bindPassword;
}
function translateUser(ldapConfig, ldapUser) {
assert.strictEqual(typeof ldapConfig, 'object');
// RFC: https://datatracker.ietf.org/doc/html/rfc2798
const user = {
username: typeof ldapUser[ldapConfig.usernameField] === 'string' ? ldapUser[ldapConfig.usernameField].toLowerCase() : '',
email: ldapUser.mail || ldapUser.mailPrimaryAddress,
twoFactorAuthenticationEnabled: !!ldapUser.twoFactorAuthenticationEnabled,
displayName: ldapUser.displayName || ldapUser.cn // user.giveName + ' ' + user.sn
};
if (!user.username || !user.email || !user.displayName) {
debug(`[Invalid LDAP user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
return null;
}
return user;
}
function supports2FA(config) {
return config.provider === 'cloudron';
}
// performs service bind if required
async function getClient(config, options) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof options, 'object');
// basic validation to not crash
try { ldap.parseDN(config.baseDn); } catch (e) { throw new BoxError(BoxError.BAD_FIELD, `invalid baseDn ${config.baseDn}: ${e.message}`); }
try { ldap.parseFilter(config.filter); } catch (e) { throw new BoxError(BoxError.BAD_FIELD, `invalid filter ${config.filter}: ${e.mssage}`); }
let client;
try {
const ldapConfig = {
url: config.url,
tlsOptions: {
rejectUnauthorized: config.acceptSelfSignedCerts ? false : true
},
// https://github.com/ldapjs/node-ldapjs/issues/486
timeout: 60000,
connectTimeout: 10000
};
client = ldap.createClient(ldapConfig);
} catch (e) {
if (e instanceof ldap.ProtocolError) throw new BoxError(BoxError.BAD_FIELD, 'url protocol is invalid');
throw new BoxError(BoxError.INTERNAL_ERROR, `Client creation error: ${e.message}`);
}
return await new Promise((resolve, reject) => {
// ensure we don't just crash
client.on('error', function (error) { // don't reject, we must have gotten a bind error
debug('getClient: ExternalLdap client error:', error);
});
// skip bind auth if none exist or if not wanted
if (!config.bindDn || !options.bind) return resolve(client);
client.bind(config.bindDn, config.bindPassword, function (error) {
if (error instanceof ldap.InvalidCredentialsError) return reject(new BoxError(BoxError.INVALID_CREDENTIALS, 'Incorrect bind password'));
if (error) return reject(new BoxError(BoxError.EXTERNAL_ERROR, `Bind error: ${error.message}`));
resolve(client);
});
});
}
async function clientSearch(client, dn, searchOptions) {
assert.strictEqual(typeof client, 'object');
assert.strictEqual(typeof dn, 'string');
assert.strictEqual(typeof searchOptions, 'object');
// basic validation to not crash
try { ldap.parseDN(dn); } catch (e) { throw new BoxError(BoxError.BAD_FIELD, `invalid dn ${dn}: ${e.message}`); }
return await new Promise((resolve, reject) => {
client.search(dn, searchOptions, function (error, result) {
if (error instanceof ldap.NoSuchObjectError) return reject(new BoxError(BoxError.NOT_FOUND, `dn not found ${dn}`));
if (error) return reject(new BoxError(BoxError.EXTERNAL_ERROR, `search error: ${error.message}`));
const ldapObjects = [];
result.on('searchEntry', entry => ldapObjects.push(entry.object));
result.on('error', error => reject(new BoxError(BoxError.EXTERNAL_ERROR, `search error: ${error.message}`)));
result.on('end', function (result) {
if (result.status !== 0) return reject(new BoxError(BoxError.EXTERNAL_ERROR, 'Server returned status ' + result.status));
resolve(ldapObjects);
});
});
});
}
async function supportsPagination(client) {
assert.strictEqual(typeof client, 'object');
const searchOptions = {
scope: 'base',
filter: '(objectClass=*)',
attributes: ['supportedControl', 'supportedExtension', 'supportedFeature']
};
const result = await clientSearch(client, '', searchOptions);
const controls = result.supportedControl;
if (!controls || !Array.isArray(controls)) {
debug('supportsPagination: no supportedControl attribute returned');
return false;
}
if (!controls.includes(ldap.PagedResultsControl.OID)) {
debug('supportsPagination: server does not support pagination. Available controls:', controls);
return false;
}
debug('supportsPagination: server supports pagination');
return true;
}
async function ldapGetByDN(config, dn) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof dn, 'string');
debug(`ldapGetByDN: Get object at ${dn}`);
const client = await getClient(config, { bind: true });
const paged = await supportsPagination(client);
const searchOptions = {
paged,
scope: 'sub'
};
const result = await clientSearch(client, dn, searchOptions);
client.unbind();
if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, `dn ${dn} not found`);
return result[0];
}
async function ldapUserSearch(config, options) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof options, 'object');
const client = await getClient(config, { bind: true });
const paged = await supportsPagination(client);
const searchOptions = {
paged,
filter: ldap.parseFilter(config.filter),
scope: 'sub'
};
if (options.filter) { // https://github.com/ldapjs/node-ldapjs/blob/master/docs/filters.md
const extraFilter = ldap.parseFilter(options.filter);
searchOptions.filter = new ldap.AndFilter({ filters: [ extraFilter, searchOptions.filter ] });
}
const result = await clientSearch(client, config.baseDn, searchOptions);
client.unbind();
return result;
}
async function ldapGroupSearch(config, options) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof options, 'object');
const searchOptions = {
paged: true,
scope: 'sub' // We may have to make this configurable
};
if (config.groupFilter) searchOptions.filter = ldap.parseFilter(config.groupFilter);
if (options.filter) { // https://github.com/ldapjs/node-ldapjs/blob/master/docs/filters.md
const extraFilter = ldap.parseFilter(options.filter);
searchOptions.filter = new ldap.AndFilter({ filters: [ extraFilter, searchOptions.filter ] });
}
const client = await getClient(config, { bind: true });
const result = await clientSearch(client, config.groupBaseDn, searchOptions);
client.unbind();
return result;
}
async function testConfig(config) {
assert.strictEqual(typeof config, 'object');
if (config.provider === 'noop') return null;
if (!config.url) return new BoxError(BoxError.BAD_FIELD, 'url must not be empty');
if (!config.url.startsWith('ldap://') && !config.url.startsWith('ldaps://')) return 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 new BoxError(BoxError.BAD_FIELD, 'basedn must not be empty');
try { ldap.parseDN(config.baseDn); } catch (e) { throw new BoxError(BoxError.BAD_FIELD, `invalid base ${config.baseDn}: ${e.message}`); }
if (!config.filter) return new BoxError(BoxError.BAD_FIELD, 'filter must not be empty');
try { ldap.parseFilter(config.filter); } catch (e) { return new BoxError(BoxError.BAD_FIELD, `invalid filter ${config.filter}: ${e.message}`); }
if ('syncGroups' in config && typeof config.syncGroups !== 'boolean') return new BoxError(BoxError.BAD_FIELD, 'syncGroups must be a boolean');
if ('acceptSelfSignedCerts' in config && typeof config.acceptSelfSignedCerts !== 'boolean') return new BoxError(BoxError.BAD_FIELD, 'acceptSelfSignedCerts must be a boolean');
if (config.syncGroups) {
if (!config.groupBaseDn) return new BoxError(BoxError.BAD_FIELD, 'groupBaseDn must not be empty');
try { ldap.parseDN(config.groupBaseDn); } catch (e) { return new BoxError(BoxError.BAD_FIELD, `invalid groupBaseDn ${config.groupBaseDn}: ${e.message}`); }
if (!config.groupFilter) return new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty');
try { ldap.parseFilter(config.groupFilter); } catch (e) { return new BoxError(BoxError.BAD_FIELD, `invalid groupFilter ${config.groupFilter}: ${e.message}`); }
if (!config.groupnameField || typeof config.groupnameField !== 'string') return new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty');
}
const [error, client] = await safe(getClient(config, { bind: true }));
if (error) return error;
const opts = {
filter: config.filter,
scope: 'sub'
};
const [searchError, ] = await safe(clientSearch(client, config.baseDn, opts));
client.unbind();
if (searchError) return searchError;
return null;
}
async function getConfig() {
const value = await settings.get(settings.EXTERNAL_LDAP_KEY);
if (value === null) return { provider: 'noop', autoCreate: false };
const config = JSON.parse(value);
if (!config.autoCreate) config.autoCreate = false; // ensure new keys
return config;
}
async function setConfig(newConfig, auditSource) {
assert.strictEqual(typeof newConfig, 'object');
assert(auditSource && typeof auditSource === 'object');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const currentConfig = await getConfig();
injectPrivateFields(newConfig, currentConfig);
const error = await testConfig(newConfig);
if (error) throw error;
await settings.setJson(settings.EXTERNAL_LDAP_KEY, newConfig);
if (newConfig.provider === 'noop') {
await users.resetSources(); // otherwise, the owner could be 'ldap' source and lock themselves out
await groups.resetSources();
}
await eventlog.add(eventlog.ACTION_EXTERNAL_LDAP_CONFIGURE, auditSource, { oldConfig: removePrivateFields(currentConfig), config: removePrivateFields(newConfig) });
await cron.handleExternalLdapChanged(newConfig);
}
async function maybeCreateUser(identifier) {
assert.strictEqual(typeof identifier, 'string');
const config = await getConfig();
if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');
if (!config.autoCreate) throw new BoxError(BoxError.BAD_STATE, 'auto create not enabled');
const ldapUsers = await ldapUserSearch(config, { filter: `${config.usernameField}=${identifier}` });
if (ldapUsers.length === 0) throw new BoxError(BoxError.NOT_FOUND, `no users found for filter ${config.usernameField}=${identifier}`);
if (ldapUsers.length > 1) throw new BoxError(BoxError.CONFLICT, `more than 1 user matches filter ${config.usernameField}=${identifier}`);
const user = translateUser(config, ldapUsers[0]);
if (!user) throw new BoxError(BoxError.BAD_FIELD, 'Failed to translate user');
return await users.add(user.email, { username: user.username, password: null, displayName: user.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP);
}
async function verifyPassword(username, password, options) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof options, 'object');
const config = await getConfig();
if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');
const ldapUsers = await ldapUserSearch(config, { filter: `${config.usernameField}=${username}` });
if (ldapUsers.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'no such user');
if (ldapUsers.length > 1) throw new BoxError(BoxError.CONFLICT, 'multiple users found');
const client = await getClient(config, { bind: false });
let userAuthDn;
if (!options.skipTotpCheck && supports2FA(config)) {
// inject totptoken into first attribute. in ldap, '+' is the attribute separator in a RDNS
const rdns = ldapUsers[0].dn.split(',');
userAuthDn = `${rdns[0]}+totptoken=${options.totpToken},` + rdns.slice(1).join(',');
} else {
userAuthDn = ldapUsers[0].dn;
}
const [error] = await safe(util.promisify(client.bind.bind(client))(userAuthDn, password));
client.unbind();
if (error instanceof ldap.InvalidCredentialsError) throw new BoxError(BoxError.INVALID_CREDENTIALS, error.lde_message);
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bind error: ${error.message}`);
const user = translateUser(config, ldapUsers[0]);
if (!user) throw new BoxError(BoxError.BAD_FIELD, 'could not translate user');
return user;
}
async function startSyncer() {
const config = await getConfig();
if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');
const taskId = await tasks.add(tasks.TASK_SYNC_EXTERNAL_LDAP, []);
safe(tasks.startTask(taskId, {}), { debug }); // background
return taskId;
}
async function syncUsers(config, progressCallback) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const ldapUsers = await ldapUserSearch(config, {});
debug(`syncUsers: Found ${ldapUsers.length} users`);
let percent = 10;
const step = 30/(ldapUsers.length+1); // ensure no divide by 0
// we ignore all errors here and just log them for now
for (let i = 0; i < ldapUsers.length; i++) {
const ldapUser = translateUser(config, ldapUsers[i]);
if (!ldapUser) continue;
percent += step;
progressCallback({ percent, message: `Syncing... ${ldapUser.username}` });
const user = await users.getByUsername(ldapUser.username);
if (!user) {
debug(`syncUsers: [adding user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);
const [userAddError] = await safe(users.add(ldapUser.email, { username: ldapUser.username, password: null, displayName: ldapUser.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP));
if (userAddError) debug('syncUsers: Failed to create user. %j %o', ldapUser, userAddError);
} else if (user.source !== 'ldap') {
debug(`syncUsers: [mapping user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);
const [userMappingError] = await safe(users.update(user, { email: ldapUser.email, fallbackEmail: ldapUser.email, displayName: ldapUser.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP));
if (userMappingError) debug('Failed to map user. %j %o', ldapUser, userMappingError);
} else if (user.email !== ldapUser.email || user.displayName !== ldapUser.displayName) {
debug(`syncUsers: [updating user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);
const [userUpdateError] = await safe(users.update(user, { email: ldapUser.email, fallbackEmail: ldapUser.email, displayName: ldapUser.displayName }, AuditSource.EXTERNAL_LDAP));
if (userUpdateError) debug('Failed to update user. %j %o', ldapUser, userUpdateError);
} else {
// user known and up-to-date
debug(`syncUsers: [up-to-date user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);
}
}
debug('syncUsers: done');
}
async function syncGroups(config, progressCallback) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof progressCallback, 'function');
if (!config.syncGroups) {
debug('syncGroups: Group sync is disabled');
progressCallback({ percent: 70, message: 'Skipping group sync...' });
return [];
}
const ldapGroups = await ldapGroupSearch(config, {});
debug(`syncGroups: Found ${ldapGroups.length} groups`);
let percent = 40;
const step = 30/(ldapGroups.length+1); // ensure no divide by 0
for (const ldapGroup of ldapGroups) {
let groupName = ldapGroup[config.groupnameField];
if (!groupName) return;
if (typeof groupName !== 'string') return; // some servers return empty array for unknown properties :-/
groupName = groupName.toLowerCase();
percent += step;
progressCallback({ percent, message: `Syncing... ${groupName}` });
const result = await groups.getByName(groupName);
if (!result) {
debug(`syncGroups: [adding group] groupname=${groupName}`);
const [error] = await safe(groups.add({ name: groupName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP));
if (error) debug('syncGroups: Failed to create group', groupName, error);
} else {
// convert local group to ldap group. 2 reasons:
// 1. we reset source flag when externalldap is disabled. if we renable, it automatically converts
// 2. externalldap connector usually implies user wants to user external users/groups.
groups.update(result.id, { source: 'ldap' });
debug(`syncGroups: [up-to-date group] groupname=${groupName}`);
}
}
debug('syncGroups: sync done');
}
async function syncGroupMembers(config, progressCallback) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof progressCallback, 'function');
if (!config.syncGroups) {
debug('syncGroupMembers: Group users sync is disabled');
progressCallback({ percent: 99, message: 'Skipping group users sync...' });
return [];
}
const allGroups = await groups.listWithMembers();
const ldapGroups = allGroups.filter(function (g) { return g.source === 'ldap'; });
debug(`syncGroupMembers: Found ${ldapGroups.length} groups to sync users`);
for (const ldapGroup of ldapGroups) {
debug(`syncGroupMembers: Sync users for group ${ldapGroup.name}`);
const result = await ldapGroupSearch(config, {});
if (!result || result.length === 0) {
debug(`syncGroupMembers: Unable to find group ${ldapGroup.name} ignoring for now.`);
continue;
}
// since our group names are lowercase we cannot use potentially case matching ldap filters
const found = result.find(function (r) {
if (!r[config.groupnameField]) return false;
return r[config.groupnameField].toLowerCase() === ldapGroup.name;
});
if (!found) {
debug(`syncGroupMembers: Unable to find group ${ldapGroup.name} ignoring for now.`);
continue;
}
let 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(`syncGroupMembers: Group ${ldapGroup.name} has ${ldapGroupMembers.length} members.`);
const userIds = [];
for (const memberDn of ldapGroupMembers) {
const [ldapError, result] = await safe(ldapGetByDN(config, memberDn));
if (ldapError) {
debug(`syncGroupMembers: Group ${ldapGroup.name} failed to get ${memberDn}: %o`, ldapError);
continue;
}
debug(`syncGroupMembers: Group ${ldapGroup.name} has member object ${memberDn}`);
const username = result[config.usernameField]?.toLowerCase();
if (!username) continue;
const [getError, userObject] = await safe(users.getByUsername(username));
if (getError || !userObject) {
debug(`syncGroupMembers: Failed to get user by username ${username}. %o`, getError ? getError : 'User not found');
continue;
}
userIds.push(userObject.id);
}
const membersChanged = ldapGroup.userIds.length !== userIds.length || ldapGroup.userIds.some(id => !userIds.includes(id));
if (membersChanged) {
debug(`syncGroupMembers: Group ${ldapGroup.name} changed.`);
const [setError] = await safe(groups.setMembers(ldapGroup, userIds, { skipSourceCheck: true }, AuditSource.EXTERNAL_LDAP));
if (setError) debug(`syncGroupMembers: Failed to set members of group ${ldapGroup.name}. %o`, setError);
} else {
debug(`syncGroupMembers: Group ${ldapGroup.name} is unchanged.`);
}
}
debug('syncGroupMembers: done');
}
async function sync(progressCallback) {
assert.strictEqual(typeof progressCallback, 'function');
progressCallback({ percent: 10, message: 'Starting ldap user sync' });
const config = await getConfig();
if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');
await syncUsers(config, progressCallback);
await syncGroups(config, progressCallback);
await syncGroupMembers(config, progressCallback);
progressCallback({ percent: 100, message: 'Done' });
debug('sync: done');
}