notifying this in ensureCertificate does not work if provider changed in the middle anyway. might as well get them to be in sync in the cronjob. this change also resulted in tls addon getting restarted non-stop if you change from wildcard to non-wildcard since ensureCertificate notifies the change.
380 lines
14 KiB
JavaScript
380 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
start,
|
|
stop,
|
|
|
|
checkCertificate,
|
|
|
|
validateConfig,
|
|
applyConfig
|
|
};
|
|
|
|
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'),
|
|
speakeasy = require('speakeasy'),
|
|
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 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();
|
|
}
|
|
|
|
// 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
|
|
}
|
|
};
|
|
|
|
// 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 || '', ''));
|
|
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));
|
|
|
|
// currently this is only optional if totpToken is provided and user has 2fa enabled
|
|
if (totpToken && user.twoFactorAuthenticationEnabled) {
|
|
const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 });
|
|
if (!verified) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
|
|
}
|
|
|
|
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 ', 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.getDirectoryServerConfig();
|
|
|
|
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: '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();
|
|
}
|