Files
cloudron-box/src/directoryserver.js
2023-08-04 14:13:30 +05:30

403 lines
14 KiB
JavaScript

'use strict';
exports = module.exports = {
getConfig,
setConfig,
start,
stop,
checkCertificate,
};
const assert = require('assert'),
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: '' // empty means allow all
};
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 start(); else await stop();
}
async function setConfig(directoryServerConfig) {
assert.strictEqual(typeof directoryServerConfig, 'object');
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
const config = {
enabled: directoryServerConfig.enabled,
secret: directoryServerConfig.secret,
// if list is empty, we allow all IPs
allowlist: directoryServerConfig.allowlist || ''
};
await validateConfig(config);
await settings.setJson(settings.DIRECTORY_SERVER_KEY, config);
await applyConfig(config);
}
// 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.toString()));
const [groupsError, allGroups] = await safe(groups.listWithMembers());
if (groupsError) return next(new ldap.OperationsError(error.toString()));
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 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: displayName,
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: 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;
// 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, allUsers] = await safe(users.list());
if (error) return next(new ldap.OperationsError(error.toString()));
const results = [];
let [errorGroups, allGroups] = await safe(groups.listWithMembers());
if (errorGroups) return next(new ldap.OperationsError(errorGroups.toString()));
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.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 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(req.dn.toString()));
const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken'; // This has to be in-sync with externalldap.js
const totpToken = req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME] ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
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 || '', '', { relaxedTotpCheck: true, totpToken }));
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();
}
async function start() {
if (gServer) return; // 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(req.dn.toString()));
if (req.credentials !== config.secret) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
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, { authType: 'directoryserver', id: req.connection.ldap.id }, { 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');
gServer.close();
gServer = null;
}
async function checkCertificate() {
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();
}