'use strict'; exports = module.exports = { start, stop }; const assert = require('assert'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), debug = require('debug')('box:userdirectory'), dns = require('./dns.js'), domains = require('./domains.js'), eventlog = require('./eventlog.js'), fs = require('fs'), groups = require('./groups.js'), ldap = require('ldapjs'), reverseproxy = require('./reverseproxy.js'), safe = require('safetydance'), settings = require('./settings.js'), users = require('./users.js'), util = require('util'); var gServer = null; const NOOP = function () {}; const GROUP_USERS_DN = 'cn=users,ou=groups,dc=cloudron'; const GROUP_ADMINS_DN = 'cn=admins,ou=groups,dc=cloudron'; // helper function to deal with pagination function finalSend(results, req, res, next) { let min = 0; let max = results.length; let cookie = null; let pageSize = 0; // check if this is a paging request, if so get the cookie for session info req.controls.forEach(function (control) { if (control.type === ldap.PagedResultsControl.OID) { pageSize = control.value.size; cookie = control.value.cookie; } }); function sendPagedResults(start, end) { start = (start < min) ? min : start; end = (end > max || end < min) ? max : end; let i; for (i = start; i < end; i++) { res.send(results[i]); } return i; } if (cookie && Buffer.isBuffer(cookie)) { // we have pagination var first = min; if (cookie.length !== 0) { first = parseInt(cookie.toString(), 10); } var last = sendPagedResults(first, first + pageSize); var resultCookie; if (last < max) { resultCookie = Buffer.from(last.toString()); } else { resultCookie = Buffer.from(''); } res.controls.push(new ldap.PagedResultsControl({ value: { size: pageSize, // correctness not required here cookie: resultCookie } })); } else { // no pagination simply send all results.forEach(function (result) { res.send(result); }); } // all done res.end(); next(); } async function authorize(req, res, next) { debug('authorize: ', req.connection.ldap.bindDN.toString()); // this is for connection attempts without previous bind if (req.connection.ldap.bindDN.equals('cn=anonymous')) return next(new ldap.InsufficientAccessRightsError()); // we only allow this one DN to pass if (!req.connection.ldap.bindDN.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InsufficientAccessRightsError()); return next(); } async function userSearch(req, res, next) { debug('user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id); const [error, result] = await safe(users.list()); if (error) return next(new ldap.OperationsError(error.toString())); let results = []; // send user objects result.forEach(function (user) { // skip entries with empty username. Some apps like owncloud can't deal with this if (!user.username) return; const dn = ldap.parseDN('cn=' + user.id + ',ou=users,dc=cloudron'); const memberof = [ GROUP_USERS_DN ]; if (users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) memberof.push(GROUP_ADMINS_DN); const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null const nameParts = displayName.split(' '); const firstName = nameParts[0]; const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists const obj = { dn: dn.toString(), attributes: { objectclass: ['user', 'inetorgperson', 'person' ], objectcategory: 'person', cn: user.id, uid: user.id, entryuuid: user.id, // to support OpenLDAP clients mail: user.email, mailAlternateAddress: user.fallbackEmail, displayname: displayName, givenName: firstName, username: user.username, samaccountname: user.username, // to support ActiveDirectory clients memberof: memberof } }; // http://www.zytrax.com/books/ldap/ape/core-schema.html#sn has 'name' as SUP which is a DirectoryString // which is required to have atleast one character if present if (lastName.length !== 0) obj.attributes.sn = lastName; // ensure all filter values are also lowercase const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null); if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString())); if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) { results.push(obj); } }); finalSend(results, req, res, next); } async function groupSearch(req, res, next) { debug('group search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id); const [error, result] = await safe(users.list()); if (error) return next(new ldap.OperationsError(error.toString())); const results = []; // those are the old virtual groups for backwards compat const virtualGroups = [{ name: 'users', admin: false }, { name: 'admins', admin: true }]; virtualGroups.forEach(function (group) { const dn = ldap.parseDN('cn=' + group.name + ',ou=groups,dc=cloudron'); const members = group.admin ? result.filter(function (user) { return users.compareRoles(user.role, users.ROLE_ADMIN) >= 0; }) : result; const obj = { dn: dn.toString(), attributes: { objectclass: ['group'], cn: group.name, memberuid: members.map(function(entry) { return entry.id; }).sort() } }; // ensure all filter values are also lowercase const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null); if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString())); if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) { results.push(obj); } }); let [errorGroups, resultGroups] = await safe(groups.listWithMembers()); if (errorGroups) return next(new ldap.OperationsError(errorGroups.toString())); resultGroups.forEach(function (group) { const dn = ldap.parseDN('cn=' + group.name + ',ou=groups,dc=cloudron'); const members = group.userIds.filter(function (uid) { return result.map(function (u) { return u.id; }).indexOf(uid) !== -1; }); const obj = { dn: dn.toString(), attributes: { objectclass: ['group'], cn: group.name, memberuid: members } }; // ensure all filter values are also lowercase const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null); if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString())); if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) { results.push(obj); } }); finalSend(results, req, res, next); } // Will attach req.user if successful async function userAuth(req, res, next) { // extract the common name which might have different attribute names const attributeName = Object.keys(req.dn.rdns[0].attrs)[0]; const commonName = req.dn.rdns[0].attrs[attributeName].value; if (!commonName) return next(new ldap.NoSuchObjectError(req.dn.toString())); let verifyFunc; if (attributeName === 'mail') { verifyFunc = users.verifyWithEmail; } else if (commonName.indexOf('@') !== -1) { // if mail is specified, enforce mail check verifyFunc = users.verifyWithEmail; } else if (commonName.indexOf('uid-') === 0) { verifyFunc = users.verify; } else { verifyFunc = users.verifyWithUsername; } const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', '')); if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString())); if (error) return next(new ldap.OperationsError(error.message)); req.user = user; next(); } // FIXME this needs to be restarted if settings changes or dashboard cert got renewed async function start() { if (gServer) return; // already running const logger = { trace: NOOP, debug: NOOP, info: debug, warn: debug, error: debug, fatal: debug }; const domainObject = await domains.get(settings.dashboardDomain()); const dashboardFqdn = dns.fqdn(constants.DASHBOARD_LOCATION, domainObject); const bundle = await reverseproxy.getCertificatePath(dashboardFqdn, domainObject.domain); gServer = ldap.createServer({ certificate: fs.readFileSync(bundle.certFilePath, 'utf8'), key: fs.readFileSync(bundle.keyFilePath, 'utf8'), log: logger }); gServer.on('error', function (error) { debug('server startup error ', error); }); gServer.bind('ou=system,dc=cloudron', async function(req, res, next) { debug('system bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id); const tmp = await settings.getUserDirectoryConfig(); if (!req.dn.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InvalidCredentialsError(req.dn.toString())); if (req.credentials !== tmp.secret) return next(new ldap.InvalidCredentialsError(req.dn.toString())); req.user = { user: 'userDirectoryAdmin' }; res.end(); // if we use next in the callback, ldapjs requires this after res.end(); return next(); }); gServer.search('ou=users,dc=cloudron', authorize, userSearch); gServer.search('ou=groups,dc=cloudron', authorize, groupSearch); gServer.bind('ou=users,dc=cloudron', userAuth, async function (req, res) { assert.strictEqual(typeof req.user, 'object'); await eventlog.upsertLoginEvent(eventlog.ACTION_USER_LOGIN, { authType: 'userdirectory', id: req.connection.ldap.id }, { userId: req.user.id, user: users.removePrivateFields(req.user) }); res.end(); }); // just log that an attempt was made to unknown route, this helps a lot during app packaging gServer.use(function(req, res, next) { debug('not handled: dn %s, scope %s, filter %s (from %s)', req.dn ? req.dn.toString() : '-', req.scope, req.filter ? req.filter.toString() : '-', req.connection.ldap.id); return next(); }); debug(`starting server on port ${constants.USER_DIRECTORY_LDAPS_PORT}`); await util.promisify(gServer.listen.bind(gServer))(constants.USER_DIRECTORY_LDAPS_PORT, '::'); } async function stop() { if (!gServer) return; debug('stopping server'); gServer.close(); gServer = null; }