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.
416 lines
15 KiB
JavaScript
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();
|
|
}
|