Files
cloudron-box/src/ldapserver.js

709 lines
29 KiB
JavaScript
Raw Normal View History

'use strict';
exports = module.exports = {
start,
2021-07-07 12:59:17 -07:00
stop,
_MOCK_APP: null
};
const addonConfigs = require('./addonconfigs.js'),
assert = require('assert'),
apps = require('./apps.js'),
AuditSource = require('./auditsource.js'),
BoxError = require('./boxerror.js'),
2019-10-24 13:41:41 -07:00
constants = require('./constants.js'),
debug = require('debug')('box:ldap'),
2016-04-30 23:16:37 -07:00
eventlog = require('./eventlog.js'),
2020-11-12 23:25:33 -08:00
groups = require('./groups.js'),
2016-05-29 17:25:23 -07:00
ldap = require('ldapjs'),
mail = require('./mail.js'),
2019-03-22 15:42:16 -07:00
safe = require('safetydance'),
2021-09-07 09:57:49 -07:00
users = require('./users.js'),
util = require('util');
2022-04-14 17:41:41 -05:00
let gServer = null;
2021-07-07 12:59:17 -07:00
const NOOP = function () {};
// Will attach req.app if successful
2021-08-20 09:19:44 -07:00
async function authenticateApp(req, res, next) {
2021-07-07 12:59:17 -07:00
const sourceIp = req.connection.ldap.id.split(':')[0];
if (sourceIp.split('.').length !== 4) return next(new ldap.InsufficientAccessRightsError('Missing source identifier'));
2021-07-07 12:59:17 -07:00
// this is only used by the ldap test. the apps tests still uses proper docker
if (constants.TEST && sourceIp === '127.0.0.1') {
req.app = exports._MOCK_APP;
return next();
}
2021-08-20 09:19:44 -07:00
const [error, app] = await safe(apps.getByIpAddress(sourceIp));
if (error) return next(new ldap.OperationsError(error.message));
if (!app) return next(new ldap.OperationsError('Could not detect app source'));
2021-08-20 09:19:44 -07:00
req.app = app;
2021-08-20 09:19:44 -07:00
next();
}
2022-01-07 14:06:13 +01:00
// Will attach req.user if successful
2021-12-23 21:31:48 +01:00
async function userAuthInternal(appId, req, res, next) {
// extract the common name which might have different attribute names
const attributeName = Object.keys(req.dn.rdns[0].attrs)[0];
const commonName = req.dn.rdns[0].attrs[attributeName].value;
if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));
2021-12-23 21:31:48 +01:00
let verifyFunc;
if (attributeName === '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 || '', appId || '', { skipTotpCheck: true }));
2024-01-03 14:51:00 +01:00
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));
2021-12-23 21:31:48 +01:00
if (error) return next(new ldap.OperationsError(error.message));
req.user = user;
next();
}
2021-08-20 09:19:44 -07:00
async function getUsersWithAccessToApp(req) {
assert.strictEqual(typeof req.app, 'object');
2021-07-15 09:50:11 -07:00
2021-08-20 09:19:44 -07:00
const result = await users.list();
2022-01-21 21:07:33 -08:00
const allowedUsers = result.filter((user) => user.active && apps.canAccess(req.app, user)); // do not list inactive users
2021-08-20 09:19:44 -07:00
return allowedUsers;
2016-05-12 13:36:53 -07:00
}
2017-10-27 01:25:07 +02:00
// helper function to deal with pagination
function finalSend(results, req, res, next) {
2021-08-20 09:19:44 -07:00
let min = 0;
let max = results.length;
let cookie = null;
let pageSize = 0;
2017-10-27 01:25:07 +02:00
// 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;
2021-08-20 09:19:44 -07:00
let i;
2017-10-27 01:25:07 +02:00
for (i = start; i < end; i++) {
res.send(results[i]);
}
return i;
}
if (cookie && Buffer.isBuffer(cookie)) {
// we have pagination
2022-04-14 17:41:41 -05:00
let first = min;
2017-10-27 01:25:07 +02:00
if (cookie.length !== 0) {
first = parseInt(cookie.toString(), 10);
}
2022-04-14 17:41:41 -05:00
const last = sendPagedResults(first, first + pageSize);
2017-10-27 01:25:07 +02:00
2022-04-14 17:41:41 -05:00
let resultCookie;
2017-10-27 01:25:07 +02:00
if (last < max) {
2019-03-21 20:06:14 -07:00
resultCookie = Buffer.from(last.toString());
2017-10-27 01:25:07 +02:00
} else {
2019-03-21 20:06:14 -07:00
resultCookie = Buffer.from('');
2017-10-27 01:25:07 +02:00
}
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();
}
2021-08-20 09:19:44 -07:00
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);
2021-08-20 09:19:44 -07:00
const [error, result] = await safe(getUsersWithAccessToApp(req));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.OperationsError(error.message));
2017-10-27 01:25:07 +02:00
const [groupsError, allGroups] = await safe(groups.listWithMembers());
2024-01-03 14:51:00 +01:00
if (groupsError) return next(new ldap.OperationsError(groupsError.message));
2021-08-20 09:19:44 -07:00
let results = [];
2021-08-20 09:19:44 -07:00
// send user objects
result.forEach(function (user) {
// skip entries with empty username. Some apps like owncloud can't deal with this
if (!user.username) return;
2021-08-20 09:19:44 -07:00
const dn = ldap.parseDN('cn=' + user.id + ',ou=users,dc=cloudron');
2021-08-20 09:19:44 -07:00
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
2021-08-20 09:19:44 -07:00
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['user', 'inetorgperson', 'person', 'organizationalperson', 'top' ],
2021-08-20 09:19:44 -07:00
objectcategory: 'person',
cn: displayName,
2021-08-20 09:19:44 -07:00
uid: user.id,
entryuuid: user.id, // to support OpenLDAP clients
mail: user.email,
mailAlternateAddress: user.fallbackEmail,
displayname: displayName,
givenName: firstName,
sn: lastName,
middleName: middleName,
2021-08-20 09:19:44 -07:00
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`; })
2021-08-20 09:19:44 -07:00
}
};
2021-08-20 09:19:44 -07:00
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
2024-01-03 14:51:00 +01:00
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
2021-08-20 09:19:44 -07:00
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
results.push(obj);
}
});
2021-08-20 09:19:44 -07:00
finalSend(results, req, res, next);
}
2021-08-20 09:19:44 -07:00
async function groupSearch(req, res, next) {
2016-05-16 14:14:58 -07:00
debug('group search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
2016-05-12 13:36:53 -07:00
2021-08-20 09:19:44 -07:00
const results = [];
2017-10-27 01:25:07 +02:00
2024-01-03 14:51:00 +01:00
let [groupsListError, resultGroups] = await safe(groups.listWithMembers());
if (groupsListError) return next(new ldap.OperationsError(groupsListError.message));
2021-12-09 15:07:30 +01:00
2021-12-09 17:23:14 +01:00
if (req.app.accessRestriction && req.app.accessRestriction.groups) {
resultGroups = resultGroups.filter(function (g) { return req.app.accessRestriction.groups.indexOf(g.id) !== -1; });
}
2021-12-09 15:07:30 +01:00
2021-12-09 17:23:14 +01:00
resultGroups.forEach(function (group) {
const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`);
2021-12-09 15:07:30 +01:00
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['group'],
cn: group.name,
gidnumber: group.id,
memberuid: group.userIds
2021-12-09 15:07:30 +01:00
}
};
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
2024-01-03 14:51:00 +01:00
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
2021-12-09 15:07:30 +01:00
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
results.push(obj);
}
});
2021-08-20 09:19:44 -07:00
finalSend(results, req, res, next);
2016-05-12 13:36:53 -07:00
}
2021-08-20 09:19:44 -07:00
async function groupUsersCompare(req, res, next) {
2017-10-24 01:35:35 +02:00
debug('group users compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
2021-08-20 09:19:44 -07:00
const [error, result] = await safe(getUsersWithAccessToApp(req));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.OperationsError(error.message));
2017-10-24 01:35:35 +02:00
2021-08-20 09:19:44 -07:00
// we only support memberuid here, if we add new group attributes later add them here
if (req.attribute === 'memberuid') {
const found = result.find(function (u) { return u.id === req.value; });
if (found) return res.end(true);
}
2017-10-24 01:35:35 +02:00
2021-08-20 09:19:44 -07:00
res.end(false);
2017-10-24 01:35:35 +02:00
}
2021-08-20 09:19:44 -07:00
async function groupAdminsCompare(req, res, next) {
2017-10-24 01:35:35 +02:00
debug('group admins compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
2021-08-20 09:19:44 -07:00
const [error, result] = await safe(getUsersWithAccessToApp(req));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.OperationsError(error.message));
2017-10-24 01:35:35 +02:00
2021-08-20 09:19:44 -07:00
// we only support memberuid here, if we add new group attributes later add them here
if (req.attribute === 'memberuid') {
2022-04-14 17:41:41 -05:00
const user = result.find(function (u) { return u.id === req.value; });
2021-08-20 09:19:44 -07:00
if (user && users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) return res.end(true);
}
2017-10-24 01:35:35 +02:00
2021-08-20 09:19:44 -07:00
res.end(false);
2017-10-24 01:35:35 +02:00
}
2021-08-17 15:45:57 -07:00
async function mailboxSearch(req, res, next) {
2016-09-26 10:18:58 -07:00
debug('mailbox search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
2016-05-29 18:24:54 -07:00
// if cn is set OR filter is mail= we only search for one mailbox specifically
let email, dn;
if (req.dn.rdns[0].attrs.cn) {
email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
dn = req.dn.toString();
} else if (req.filter instanceof ldap.EqualityFilter && req.filter.attribute === 'mail') {
email = req.filter.value.toLowerCase();
dn = `cn=${email},${req.dn.toString()}`;
}
if (email) {
2021-08-17 15:45:57 -07:00
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));
2017-10-27 01:25:07 +02:00
2021-08-17 15:45:57 -07:00
const [error, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.OperationsError(error.message));
if (!mailbox) return next(new ldap.NoSuchObjectError(dn.toString()));
2024-01-03 14:51:00 +01:00
if (!mailbox.active) return next(new ldap.NoSuchObjectError('Mailbox is not active'));
2021-08-17 15:45:57 -07:00
const obj = {
dn: dn.toString(),
2021-08-17 15:45:57 -07:00
attributes: {
objectclass: ['mailbox'],
objectcategory: 'mailbox',
cn: `${mailbox.name}@${mailbox.domain}`,
uid: `${mailbox.name}@${mailbox.domain}`,
2022-08-17 23:18:38 +02:00
mail: `${mailbox.name}@${mailbox.domain}`,
storagequota: mailbox.storageQuota,
messagesquota: mailbox.messagesQuota,
2021-08-17 15:45:57 -07:00
}
};
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
2024-01-03 14:51:00 +01:00
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
2021-08-17 15:45:57 -07:00
if (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
} else {
res.end();
}
} else { // new sogo and dovecot listing (doveadm -A)
// TODO figure out how proper pagination here could work
let [error, mailboxes] = await safe(mail.listAllMailboxes(1, 100000));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.OperationsError(error.message));
2021-08-17 15:45:57 -07:00
mailboxes = mailboxes.filter(m => m.active);
2021-08-17 15:45:57 -07:00
let results = [];
2021-08-17 15:45:57 -07:00
for (const mailbox of mailboxes) {
const dn = ldap.parseDN(`cn=${mailbox.name}@${mailbox.domain},ou=mailboxes,dc=cloudron`);
2021-07-15 09:50:11 -07:00
if (mailbox.ownerType === mail.OWNERTYPE_APP) continue; // cannot login with app mailbox anyway
2021-08-17 15:45:57 -07:00
const [error, ownerObject] = await safe(mailbox.ownerType === mail.OWNERTYPE_USER ? users.get(mailbox.ownerId) : groups.get(mailbox.ownerId));
if (error || !ownerObject) continue; // skip mailboxes with unknown user
2021-07-15 09:50:11 -07:00
2021-08-17 15:45:57 -07:00
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['mailbox'],
objectcategory: 'mailbox',
displayname: mailbox.ownerType === mail.OWNERTYPE_USER ? ownerObject.displayName : ownerObject.name,
cn: `${mailbox.name}@${mailbox.domain}`,
uid: `${mailbox.name}@${mailbox.domain}`,
2022-08-17 23:18:38 +02:00
mail: `${mailbox.name}@${mailbox.domain}`,
storagequota: mailbox.storageQuota,
messagesquota: mailbox.messagesQuota,
2021-08-17 15:45:57 -07:00
}
};
2021-08-17 15:45:57 -07:00
mailbox.aliases.forEach(function (a, idx) {
obj.attributes['mail' + idx] = `${a.name}@${a.domain}`;
});
2021-08-17 15:45:57 -07:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
2024-01-03 14:51:00 +01:00
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
2021-07-15 09:50:11 -07:00
2021-08-17 15:45:57 -07:00
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
results.push(obj);
2021-07-15 09:50:11 -07:00
}
2021-08-17 15:45:57 -07:00
}
2021-07-15 09:50:11 -07:00
2021-08-17 15:45:57 -07:00
finalSend(results, req, res, next);
}
2016-09-25 18:59:11 -07:00
}
2021-08-17 15:45:57 -07:00
async function mailAliasSearch(req, res, next) {
2016-09-25 18:59:11 -07:00
debug('mail alias get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
2017-10-27 01:25:07 +02:00
2021-08-17 15:45:57 -07:00
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
const [error, alias] = await safe(mail.searchAlias(parts[0], parts[1]));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.OperationsError(error.message));
if (!alias) return next(new ldap.NoSuchObjectError('No such alias'));
2021-08-17 15:45:57 -07:00
2024-01-03 14:51:00 +01:00
if (!alias.active) return next(new ldap.NoSuchObjectError('Mailbox is not active')); // there is no way to disable an alias. this is just here for completeness
2021-08-17 15:45:57 -07:00
// https://wiki.debian.org/LDAP/MigrationTools/Examples
// https://docs.oracle.com/cd/E19455-01/806-5580/6jej518pp/index.html
// member is fully qualified - https://docs.oracle.com/cd/E19957-01/816-6082-10/chap4.doc.html#43314
const obj = {
dn: req.dn.toString(),
attributes: {
objectclass: ['nisMailAlias'],
objectcategory: 'nisMailAlias',
cn: `${parts[0]}@${alias.domain}`, // alias.name can contain wildcard character
2021-08-17 15:45:57 -07:00
rfc822MailMember: `${alias.aliasName}@${alias.aliasDomain}`
}
};
2016-09-25 18:59:11 -07:00
2021-08-17 15:45:57 -07:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
2024-01-03 14:51:00 +01:00
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
2016-09-25 18:59:11 -07:00
2021-08-17 15:45:57 -07:00
if (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
} else {
res.end();
}
2016-09-25 18:59:11 -07:00
}
2021-08-17 15:45:57 -07:00
async function mailingListSearch(req, res, next) {
2016-09-27 12:20:20 -07:00
debug('mailing list get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
2016-09-25 18:59:11 -07:00
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
2017-10-27 01:25:07 +02:00
2019-11-06 16:45:44 -08:00
let email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
let parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
2019-11-06 16:45:44 -08:00
const name = parts[0], domain = parts[1];
2021-08-17 15:45:57 -07:00
const [error, result] = await safe(mail.resolveList(parts[0], parts[1]));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError('No such list'));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.OperationsError(error.message));
2021-08-17 15:45:57 -07:00
const { resolvedMembers, list } = result;
2024-01-03 14:51:00 +01:00
if (!list.active) return next(new ldap.NoSuchObjectError('List is not active'));
2021-08-17 15:45:57 -07:00
// http://ldapwiki.willeke.com/wiki/Original%20Mailgroup%20Schema%20From%20Netscape
// members are fully qualified (https://docs.oracle.com/cd/E19444-01/816-6018-10/groups.htm#13356)
const obj = {
dn: req.dn.toString(),
attributes: {
objectclass: ['mailGroup'],
objectcategory: 'mailGroup',
cn: `${name}@${domain}`, // fully qualified
mail: `${name}@${domain}`,
membersOnly: list.membersOnly, // ldapjs only supports strings and string array. so this is not a bool!
mgrpRFC822MailMember: resolvedMembers // fully qualified
}
};
2021-08-17 15:45:57 -07:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
2024-01-03 14:51:00 +01:00
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
2016-05-29 18:24:54 -07:00
2021-08-17 15:45:57 -07:00
if (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
} else {
res.end();
}
2016-05-29 18:24:54 -07:00
}
// Will attach req.user if successful
2021-07-15 09:50:11 -07:00
async function authenticateUser(req, res, next) {
2016-05-16 14:14:58 -07:00
debug('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
2016-05-12 13:36:53 -07:00
2021-12-23 21:31:48 +01:00
const appId = req.app.id;
2016-05-12 13:36:53 -07:00
2021-12-23 21:31:48 +01:00
await userAuthInternal(appId, req, res, next);
}
2021-08-20 09:19:44 -07:00
async function authorizeUserForApp(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
assert.strictEqual(typeof req.app, 'object');
2021-09-21 10:00:47 -07:00
const canAccess = apps.canAccess(req.app, req.user);
2021-08-20 09:19:44 -07:00
// we return no such object, to avoid leakage of a users existence
if (!canAccess) return next(new ldap.NoSuchObjectError('Invalid user or insufficient previleges'));
2016-05-12 13:36:53 -07:00
await eventlog.upsertLoginEvent(req.user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.LDAP, { appId: req.app.id, userId: req.user.id, user: users.removePrivateFields(req.user) });
2016-05-12 13:36:53 -07:00
2021-08-20 09:19:44 -07:00
res.end();
2016-05-12 13:36:53 -07:00
}
2021-07-15 09:50:11 -07:00
async function verifyMailboxPassword(mailbox, password) {
2020-11-12 23:25:33 -08:00
assert.strictEqual(typeof mailbox, 'object');
assert.strictEqual(typeof password, 'string');
if (mailbox.ownerType === mail.OWNERTYPE_USER) {
2024-01-07 22:01:57 +01:00
return await users.verify(mailbox.ownerId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true });
} else if (mailbox.ownerType === mail.OWNERTYPE_GROUP) {
const userIds = await groups.getMembers(mailbox.ownerId);
let verifiedUser = null;
for (const userId of userIds) {
2024-01-07 22:01:57 +01:00
const [error, result] = await safe(users.verify(userId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true }));
if (error) continue; // try the next user
verifiedUser = result;
break; // found a matching validated user
}
2020-11-12 23:25:33 -08:00
if (!verifiedUser) throw new BoxError(BoxError.INVALID_CREDENTIALS);
return verifiedUser;
} else {
throw new BoxError(BoxError.INVALID_CREDENTIALS);
2021-07-15 09:50:11 -07:00
}
2020-11-12 23:25:33 -08:00
}
2021-08-20 09:19:44 -07:00
async function authenticateSftp(req, res, next) {
2019-04-04 20:46:01 -07:00
debug('sftp auth: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
2019-03-18 21:15:50 -07:00
2021-07-15 09:50:11 -07:00
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
let [error, app] = await safe(apps.getByFqdn(parts[1]));
if (error || !app) return next(new ldap.InvalidCredentialsError());
2019-03-18 21:15:50 -07:00
2024-01-07 22:01:57 +01:00
[error] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id, { skipTotpCheck: true }));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.InvalidCredentialsError(error.message));
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
debug('sftp auth: success');
2020-03-26 21:50:25 -07:00
2021-08-20 09:19:44 -07:00
res.end();
2019-03-18 21:15:50 -07:00
}
2021-08-20 09:19:44 -07:00
async function userSearchSftp(req, res, next) {
2019-04-04 20:46:01 -07:00
debug('sftp user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
if (req.filter.attribute !== 'username' || !req.filter.value) return next(new ldap.NoSuchObjectError());
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
const parts = req.filter.value.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError());
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
const username = parts[0];
const appFqdn = parts[1];
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
const [error, app] = await safe(apps.getByFqdn(appFqdn));
2024-01-03 14:51:00 +01:00
if (error) return next(new ldap.OperationsError(error.message));
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
// only allow apps which specify "ftp" support in the localstorage addon
if (!safe.query(app.manifest.addons, 'localstorage.ftp.uid')) return next(new ldap.UnavailableError('Not supported'));
if (typeof app.manifest.addons.localstorage.ftp.uid !== 'number') return next(new ldap.UnavailableError('Bad uid, must be a number'));
2021-08-20 09:19:44 -07:00
const uidNumber = app.manifest.addons.localstorage.ftp.uid;
2021-08-20 09:19:44 -07:00
const [userGetError, user] = await safe(users.getByUsername(username));
2024-01-03 14:51:00 +01:00
if (userGetError) return next(new ldap.OperationsError(userGetError.message));
2021-08-20 09:19:44 -07:00
if (!user) return next(new ldap.OperationsError('Invalid username'));
2019-03-18 21:15:50 -07:00
if (!apps.isOperator(app, user)) return next(new ldap.InsufficientAccessRightsError('Not authorized'));
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
const obj = {
dn: ldap.parseDN(`cn=${username}@${appFqdn},ou=sftp,dc=cloudron`).toString(),
attributes: {
2022-06-01 22:44:52 -07:00
homeDirectory: app.storageVolumeId ? `/mnt/app-${app.id}` : `/mnt/appsdata/${app.id}/data`, // see also sftp.js
2021-08-20 09:19:44 -07:00
objectclass: ['user'],
objectcategory: 'person',
cn: user.id,
uid: `${username}@${appFqdn}`, // for bind after search
uidNumber: uidNumber, // unix uid for ftp access
gidNumber: uidNumber // unix gid for ftp access
}
};
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
finalSend([ obj ], req, res, next);
2019-03-18 21:15:50 -07:00
}
async function verifyAppMailboxPassword(serviceId, username, password) {
assert.strictEqual(typeof serviceId, 'string');
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
const pattern = serviceId === 'msa' ? 'MAIL_SMTP' : 'MAIL_IMAP';
const addonId = serviceId === 'msa' ? 'sendmail' : 'recvmail';
const appId = await addonConfigs.getAppIdByValue(addonId, `%${pattern}_PASSWORD`, password); // search by password because this is unique for each app
if (!appId) throw new BoxError(BoxError.NOT_FOUND);
const result = await addonConfigs.get(appId, addonId);
if (!result.some(r => r.name.endsWith(`${pattern}_USERNAME`) && r.value === username)) throw new BoxError(BoxError.INVALID_CREDENTIALS);
}
async function authenticateService(serviceId, dn, req, res, next) {
debug(`authenticateService: ${req.dn.toString()} (from ${req.connection.ldap.id})`);
2018-02-08 18:49:27 -08:00
if (!dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(dn.toString()));
2020-12-03 13:35:50 -08:00
const email = dn.rdns[0].attrs.cn.value.toLowerCase();
2020-12-03 13:35:50 -08:00
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));
2016-09-26 11:50:32 -07:00
const knownServices = [ 'msa', 'imap', 'pop3', 'sieve', 'sogo' ];
if (!knownServices.includes(serviceId)) return next(new ldap.OperationsError('Invalid DN. Unknown service'));
rework how app mailboxes are allocated Our current setup had a mailbox allocated for an app during app install (into the mailboxes table). This has many issues: * When set to a custom mailbox location, there was no way to access this mailbox even via IMAP. Even when using app credentials, we cannot use IMAP since the ldap logic was testing on the addon type (most of our apps only use sendmail addon and thus cannot recvmail). * The mailboxes table was being used to add hidden 'app' type entries. This made it very hard for the user to understand why a mailbox conflicts. For example, if you set an app to use custom mailbox 'blog', this is hidden from all views. The solution is to let an app send email as whatever mailbox name is allocated to it (which we now track in the apps table. the default is in the db already so that REST response contains it). When not using Cloudron email, it will just send mail as that mailbox and the auth checks the "app password" in the addons table. Any replies to that mailbox will end up in the domain's mail server (not our problem). When using cloudron email, the app can send mail like above. Any responses will not end anywhere and bounce since there is no 'mailbox'. This is the expected behavior. If user wants to access this mailbox name, he can create a concrete mailbox and set himself as owner OR set this as an alias. For apps using the recvmail addon, the workflow is to actually create a mailbox at some point. Currently, we have no UI for this 'flow'. It's fine because we have only meemo using it. Intuitive much!
2018-12-06 21:08:19 -08:00
2021-08-17 15:45:57 -07:00
const [error, domain] = await safe(mail.getDomain(parts[1]));
if (error) return next(new ldap.OperationsError(error.message));
if (!domain) return next(new ldap.NoSuchObjectError(dn.toString()));
const serviceNeedsMailbox = serviceId === 'imap' || serviceId === 'sieve' || serviceId === 'pop3' || serviceId === 'sogo';
if (serviceNeedsMailbox && !domain.enabled) return next(new ldap.NoSuchObjectError(dn.toString()));
2021-10-03 23:59:06 -07:00
const [getMailboxError, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
if (getMailboxError) return next(new ldap.OperationsError(getMailboxError.message));
if (serviceNeedsMailbox) {
if (!mailbox || !mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString()));
if (serviceId === 'pop3' && !mailbox.enablePop3) return next(new ldap.OperationsError('POP3 is not enabled'));
2021-10-03 23:59:06 -07:00
}
const [appPasswordError] = await safe(verifyAppMailboxPassword(serviceId, email, req.credentials || ''));
if (!appPasswordError) return res.end(); // validated as app
2024-01-03 14:51:00 +01:00
if (appPasswordError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(appPasswordError.message));
if (appPasswordError.reason !== BoxError.NOT_FOUND) return next(new ldap.OperationsError(appPasswordError.message));
if (!mailbox || !mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString())); // user auth requires active mailbox
2021-08-17 15:45:57 -07:00
const [verifyError, result] = await safe(verifyMailboxPassword(mailbox, req.credentials || ''));
2024-01-03 14:51:00 +01:00
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(verifyError.message));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(verifyError.message));
2021-08-17 15:45:57 -07:00
if (verifyError) return next(new ldap.OperationsError(verifyError.message));
eventlog.upsertLoginEvent(result.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.MAIL, { mailboxId: email, userId: result.id, user: users.removePrivateFields(result) });
2021-06-01 09:35:20 -07:00
2021-08-17 15:45:57 -07:00
res.end();
2016-05-29 17:25:23 -07:00
}
async function authenticateMail(req, res, next) {
if (!req.dn.rdns[1].attrs.ou) return next(new ldap.NoSuchObjectError());
await authenticateService(req.dn.rdns[1].attrs.ou.value.toLowerCase(), req.dn, req, res, next);
}
// https://ldapwiki.com/wiki/RootDSE / RFC 4512 - ldapsearch -x -h "${CLOUDRON_LDAP_SERVER}" -p "${CLOUDRON_LDAP_PORT}" -b "" -s base
2022-03-31 21:18:56 -07:00
// 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
2022-03-31 21:18:56 -07:00
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();
}
2021-09-07 09:57:49 -07:00
async function start() {
2023-10-01 13:26:43 +05:30
assert(gServer === null, 'Already started');
2021-08-20 09:19:44 -07:00
const logger = {
2016-09-25 16:11:54 -07:00
trace: NOOP,
debug: NOOP,
info: debug,
warn: debug,
error: debug,
fatal: debug
2016-09-25 16:11:54 -07:00
};
gServer = ldap.createServer({ log: logger });
2016-05-12 13:36:53 -07:00
2019-04-25 13:10:52 +02:00
gServer.on('error', function (error) {
debug('start: server error. %o', error);
2019-04-25 13:10:52 +02:00
});
gServer.search('ou=users,dc=cloudron', authenticateApp, userSearch);
gServer.search('ou=groups,dc=cloudron', authenticateApp, groupSearch);
gServer.bind('ou=users,dc=cloudron', authenticateApp, authenticateUser, authorizeUserForApp);
2016-09-25 18:59:11 -07:00
// http://www.ietf.org/proceedings/43/I-D/draft-srivastava-ldap-mail-00.txt
2020-11-12 23:25:33 -08:00
gServer.search('ou=mailboxes,dc=cloudron', mailboxSearch); // haraka (address translation), dovecot (LMTP), sogo (mailbox search)
gServer.bind('ou=mailboxes,dc=cloudron', async function (req, res, next) { // used for sogo only. this route happens only at sogo login time. after that it will use imap ldap route
await authenticateService('sogo', req.dn, req, res, next);
});
gServer.search('ou=mailaliases,dc=cloudron', mailAliasSearch); // haraka
gServer.search('ou=mailinglists,dc=cloudron', mailingListSearch); // haraka
2016-09-25 18:59:11 -07:00
gServer.bind('ou=imap,dc=cloudron', authenticateMail); // dovecot (IMAP auth)
gServer.bind('ou=msa,dc=cloudron', authenticateMail); // haraka (MSA auth)
gServer.bind('ou=sieve,dc=cloudron', authenticateMail); // dovecot (sieve auth)
gServer.bind('ou=pop3,dc=cloudron', authenticateMail); // dovecot (pop3 auth)
2016-05-29 17:25:23 -07:00
2019-04-04 20:46:01 -07:00
gServer.bind('ou=sftp,dc=cloudron', authenticateSftp); // sftp
gServer.search('ou=sftp,dc=cloudron', userSearchSftp);
2019-03-18 21:15:50 -07:00
gServer.compare('cn=users,ou=groups,dc=cloudron', authenticateApp, groupUsersCompare);
gServer.compare('cn=admins,ou=groups,dc=cloudron', authenticateApp, groupAdminsCompare);
2017-10-24 01:35:35 +02:00
2016-05-12 13:20:57 -07:00
// this is the bind for addons (after bind, they might search and authenticate)
gServer.bind('ou=addons,dc=cloudron', function(req, res /*, next */) {
2016-05-12 13:20:57 -07:00
debug('addons bind: %s', req.dn.toString()); // note: cn can be email or id
2016-05-11 14:26:34 -07:00
res.end();
});
2016-05-12 13:20:57 -07:00
// this is the bind for apps (after bind, they might search and authenticate user)
gServer.bind('ou=apps,dc=cloudron', function(req, res /*, next */) {
// TODO: validate password
debug('application bind: %s', req.dn.toString());
res.end();
});
// directus looks for the "DN" of the bind user
gServer.search('ou=apps,dc=cloudron', function(req, res, next) {
const obj = {
dn: req.dn.toString(),
};
finalSend([obj], req, res, next);
});
2022-03-31 21:18:56 -07:00
gServer.search('', maybeRootDSE); // when '', it seems the callback is called for everything else
2021-02-13 18:56:36 +01:00
// 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();
});
2021-09-07 09:57:49 -07:00
await util.promisify(gServer.listen.bind(gServer))(constants.LDAP_PORT, '0.0.0.0');
}
2015-09-14 10:59:05 -07:00
2021-09-07 09:57:49 -07:00
async function stop() {
2021-12-10 17:48:36 +01:00
if (!gServer) return;
2024-01-23 11:44:55 +01:00
await util.promisify(gServer.close.bind(gServer))();
2021-12-10 17:48:36 +01:00
gServer = null;
2015-09-14 10:59:05 -07:00
}