418 lines
15 KiB
JavaScript
418 lines
15 KiB
JavaScript
import assert from 'node:assert';
|
|
import AuditSource from './auditsource.js';
|
|
import BoxError from './boxerror.js';
|
|
import constants from './constants.js';
|
|
import debugModule from 'debug';
|
|
import eventlog from './eventlog.js';
|
|
import ipaddr from './ipaddr.js';
|
|
import groups from './groups.js';
|
|
import ldap from 'ldapjs';
|
|
import path from 'node:path';
|
|
import paths from './paths.js';
|
|
import reverseProxy from './reverseproxy.js';
|
|
import safe from 'safetydance';
|
|
import settings from './settings.js';
|
|
import shellModule from './shell.js';
|
|
import users from './users.js';
|
|
import util from 'node:util';
|
|
|
|
const debug = debugModule('box:directoryserver');
|
|
const shell = shellModule('directoryserver');
|
|
|
|
|
|
let gServer = null, gCertificate = null;
|
|
|
|
const NOOP = function () {};
|
|
|
|
const SET_LDAP_ALLOWLIST_CMD = path.join(import.meta.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();
|
|
if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(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 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 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',
|
|
supportedControl: [ ldap.PagedResultsControl.OID ],
|
|
supportedExtension: []
|
|
}
|
|
});
|
|
res.end();
|
|
}
|
|
|
|
// helper function to deal with pagination
|
|
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.verifyWithId;
|
|
} 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 stop() {
|
|
if (!gServer) return;
|
|
|
|
debug('stopping server');
|
|
|
|
await util.promisify(gServer.close.bind(gServer))();
|
|
gServer = null;
|
|
}
|
|
|
|
function finalSend(results, req, res, next) {
|
|
const min = 0, 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(pageStart, end) {
|
|
pageStart = (pageStart < min) ? min : pageStart;
|
|
end = (end > max || end < min) ? max : end;
|
|
let i;
|
|
|
|
for (i = pageStart; 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();
|
|
}
|
|
|
|
// Will attach req.user if successful
|
|
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));
|
|
|
|
const 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 = [];
|
|
|
|
const [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);
|
|
}
|
|
|
|
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, '::');
|
|
}
|
|
|
|
// 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 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.sudo([ 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 });
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
export default {
|
|
getConfig,
|
|
setConfig,
|
|
|
|
start,
|
|
stop,
|
|
|
|
checkCertificate,
|
|
};
|