'use strict'; exports = module.exports = { getConfig, setConfig, verifyPassword, maybeCreateUser, supports2FA, startSyncer, removePrivateFields, sync }; const assert = require('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('util'); 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'); // 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; } 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); } 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, e); } 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, error)); 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, error)); const ldapObjects = []; result.on('searchEntry', entry => ldapObjects.push(entry.object)); result.on('error', error => reject(new BoxError(BoxError.EXTERNAL_ERROR, error))); 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 ldapGetByDN(config, dn) { assert.strictEqual(typeof config, 'object'); assert.strictEqual(typeof dn, 'string'); const searchOptions = { paged: true, scope: 'sub' // We may have to make this configurable }; debug(`ldapGetByDN: Get object at ${dn}`); const client = await getClient(config, { bind: true }); 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]; } // TODO support search by email async function ldapUserSearch(config, options) { assert.strictEqual(typeof config, 'object'); assert.strictEqual(typeof options, 'object'); const searchOptions = { paged: true, filter: ldap.parseFilter(config.filter), scope: 'sub' // We may have to make this configurable }; 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.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 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, error); 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, []); tasks.startTask(taskId, {}, function (error, result) { debug('sync: done. %o %j', error, result); }); 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 coverts // 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.list(); const ldapGroups = allGroups.filter(function (g) { return g.source === 'ldap'; }); debug(`syncGroupMembers: Found ${ldapGroups.length} groups to sync users`); for (const group of ldapGroups) { debug(`syncGroupMembers: Sync users for group ${group.name}`); const result = await ldapGroupSearch(config, {}); if (!result || result.length === 0) { debug(`syncGroupMembers: Unable to find group ${group.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() === group.name; }); if (!found) { debug(`syncGroupMembers: Unable to find group ${group.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 ${group.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 ${group.name} failed to get ${memberDn}: %o`, ldapError); continue; } debug(`syncGroupMembers: Group ${group.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 [setError] = await safe(groups.setMembers(group, userIds, { skipSourceCheck: true }, AuditSource.EXTERNAL_LDAP)); if (setError) debug(`syncGroupMembers: Failed to set members of group ${group.name}. %o`, setError); } 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'); }