Files
cloudron-box/src/directoryserver.js
Girish Ramakrishnan 8a63f0368e Fix parsing of displayName
Currently, we only have one field for the name. The first part is
first name. The rest is last name. Obviously, this won't work in all
cases but is the best we can do for the moment.
2024-02-06 16:53:03 +01:00

416 lines
15 KiB
JavaScript

'use strict';
exports = module.exports = {
getConfig,
setConfig,
start,
stop,
checkCertificate,
};
const assert = require('assert'),
AuditSource = require('./auditsource.js'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:directoryserver'),
eventlog = require('./eventlog.js'),
groups = require('./groups.js'),
ldap = require('ldapjs'),
path = require('path'),
paths = require('./paths.js'),
reverseProxy = require('./reverseproxy.js'),
safe = require('safetydance'),
settings = require('./settings.js'),
shell = require('./shell.js'),
users = require('./users.js'),
util = require('util'),
validator = require('validator');
let gServer = null, gCertificate = null;
const NOOP = function () {};
const SET_LDAP_ALLOWLIST_CMD = path.join(__dirname, 'scripts/setldapallowlist.sh');
async function getConfig() {
const value = await settings.get(settings.DIRECTORY_SERVER_KEY);
if (value === null) return {
enabled: false,
secret: '',
allowlist: ''
};
return JSON.parse(value);
}
async function validateConfig(config) {
const { enabled, secret, allowlist } = config;
if (!enabled) return;
if (!secret) throw new BoxError(BoxError.BAD_FIELD, 'secret cannot be empty');
let gotOne = false;
for (const line of allowlist.split('\n')) {
if (!line || line.startsWith('#')) continue;
const rangeOrIP = line.trim();
// this checks for IPv4 and IPv6
if (!validator.isIP(rangeOrIP) && !validator.isIPRange(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} is not a valid IP or range`);
gotOne = true;
}
// only allow if we at least have one allowed IP/range
if (!gotOne) throw new BoxError(BoxError.BAD_FIELD, 'allowlist must at least contain one IP or range');
}
async function applyConfig(config) {
assert.strictEqual(typeof config, 'object');
// this is done only because it's easier for the shell script and the firewall service to get the value
if (config.enabled) {
if (!safe.fs.writeFileSync(paths.LDAP_ALLOWLIST_FILE, config.allowlist + '\n', 'utf8')) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
} else {
safe.fs.unlinkSync(paths.LDAP_ALLOWLIST_FILE);
}
const [error] = await safe(shell.promises.sudo('setLdapAllowlist', [ SET_LDAP_ALLOWLIST_CMD ], {}));
if (error) throw new BoxError(BoxError.IPTABLES_ERROR, `Error setting ldap allowlist: ${error.message}`);
if (!config.enabled) {
await stop();
return;
}
if (!gServer) await start();
}
async function setConfig(directoryServerConfig, auditSource) {
assert.strictEqual(typeof directoryServerConfig, 'object');
assert(auditSource && typeof auditSource === 'object');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const oldConfig = await getConfig();
const config = {
enabled: directoryServerConfig.enabled,
secret: directoryServerConfig.secret,
allowlist: directoryServerConfig.allowlist
};
await validateConfig(config);
await settings.setJson(settings.DIRECTORY_SERVER_KEY, config);
await applyConfig(config);
await eventlog.add(eventlog.ACTION_DIRECTORY_SERVER_CONFIGURE, auditSource, { fromEnabled: oldConfig.enabled, toEnabled: config.enabled });
}
// 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
let first = min;
if (cookie.length !== 0) {
first = parseInt(cookie.toString(), 10);
}
const last = sendPagedResults(first, first + pageSize);
let 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();
}
// https://ldapwiki.com/wiki/RootDSE / RFC 4512 - ldapsearch -x -h "${CLOUDRON_LDAP_SERVER}" -p "${CLOUDRON_LDAP_PORT}" -b "" -s base
// ldapjs seems to call this handler for everything when search === ''
async function maybeRootDSE(req, res, next) {
debug(`maybeRootDSE: requested with scope:${req.scope} dn:${req.dn.toString()}`);
if (req.scope !== 'base') return next(new ldap.NoSuchObjectError()); // per the spec, rootDSE search require base scope
if (!req.dn || req.dn.toString() !== '') return next(new ldap.NoSuchObjectError());
res.send({
dn: '',
attributes: {
objectclass: [ 'RootDSE', 'top', 'OpenLDAProotDSE' ],
supportedLDAPVersion: '3',
vendorName: 'Cloudron LDAP',
vendorVersion: '1.0.0'
}
});
res.end();
}
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, allUsers] = await safe(users.list());
if (error) return next(new ldap.OperationsError(error.message));
const [groupsError, allGroups] = await safe(groups.listWithMembers());
if (groupsError) return next(new ldap.OperationsError(groupsError.message));
let results = [];
// send user objects
for (const user of allUsers) {
// skip entries with empty username. Some apps like owncloud can't deal with this
if (!user.username) continue;
const dn = ldap.parseDN(`cn=${user.id},ou=users,dc=cloudron`);
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
// https://datatracker.ietf.org/doc/html/rfc2798
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['user', 'inetorgperson', 'person' ],
objectcategory: 'person',
cn: displayName,
uid: user.id,
entryuuid: user.id, // to support OpenLDAP clients
mail: user.email,
mailAlternateAddress: user.fallbackEmail,
displayname: displayName,
givenName: firstName,
sn: lastName,
middleName: middleName,
username: user.username,
samaccountname: user.username, // to support ActiveDirectory clients
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
}
};
if (user.twoFactorAuthenticationEnabled) obj.attributes.twoFactorAuthenticationEnabled = true;
// 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.message));
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, allUsers] = await safe(users.list());
if (error) return next(new ldap.OperationsError(error.message));
const results = [];
let [errorGroups, allGroups] = await safe(groups.listWithMembers());
if (errorGroups) return next(new ldap.OperationsError(errorGroups.message));
for (const group of allGroups) {
const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`);
const members = group.userIds.filter(function (uid) { return allUsers.map(function (u) { return u.id; }).indexOf(uid) !== -1; });
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['group'],
cn: group.name,
gidnumber: group.id,
memberuid: members,
member: members.map((userId) => `cn=${userId},ou=users,dc=cloudron`),
uniquemember: members.map((userId) => `cn=${userId},ou=users,dc=cloudron`)
}
};
// 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.message));
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 cnAttributeName = Object.keys(req.dn.rdns[0].attrs)[0];
const commonName = req.dn.rdns[0].attrs[cnAttributeName].value;
if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));
// totptoken is passed as the "attribute" using the '+' separator in the first RDNS of the request DN
// when totptoken attribute is present, it signals that we must enforce totp check
// totp check is currently requested by the client. this is the only way to auth against external cloudron dashboard, external cloudron app and external apps
const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken'; // This has to be in-sync with externalldap.js
const totpToken = TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
const skipTotpCheck = !(TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs);
let verifyFunc;
if (cnAttributeName === '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 || '', '', { totpToken, skipTotpCheck }));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(error.message));
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(error.message));
if (error) return next(new ldap.OperationsError(error.message));
req.user = user;
next();
}
async function start() {
assert(gServer === null, 'Already running');
const logger = {
trace: NOOP,
debug: NOOP,
info: debug,
warn: debug,
error: debug,
fatal: debug
};
gCertificate = await reverseProxy.getDirectoryServerCertificate();
gServer = ldap.createServer({
certificate: gCertificate.cert,
key: gCertificate.key,
log: logger
});
gServer.on('error', function (error) {
debug('server startup error: %o', 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 config = await getConfig();
if (!req.dn.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InvalidCredentialsError('Invalid DN'));
if (req.credentials !== config.secret) return next(new ldap.InvalidCredentialsError('Invalid Secret'));
req.user = { user: 'directoryServerAdmin' };
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(req.user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.fromDirectoryServerRequest(req), { userId: req.user.id, user: users.removePrivateFields(req.user) });
res.end();
});
gServer.search('', maybeRootDSE); // when '', it seems the callback is called for everything else
// 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');
await util.promisify(gServer.close.bind(gServer))();
gServer = null;
}
async function checkCertificate() {
assert(gServer !== null, 'Directory server is not running');
const certificate = await reverseProxy.getDirectoryServerCertificate();
if (certificate.cert === gCertificate.cert) {
debug('checkCertificate: certificate has not changed');
return;
}
debug('checkCertificate: certificate changed. restarting');
await stop();
await start();
}