Files
cloudron-box/src/ldap.js

685 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'),
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');
var gServer = null;
2021-07-07 12:59:17 -07:00
const NOOP = function () {};
2021-07-07 12:59:17 -07:00
const GROUP_USERS_DN = 'cn=users,ou=groups,dc=cloudron';
const GROUP_ADMINS_DN = 'cn=admins,ou=groups,dc=cloudron';
2015-08-12 15:31:44 +02:00
// 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();
}
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();
2021-09-21 10:00:47 -07:00
const allowedUsers = result.filter((user) => apps.canAccess(req.app, user));
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
var first = min;
if (cookie.length !== 0) {
first = parseInt(cookie.toString(), 10);
}
var last = sendPagedResults(first, first + pageSize);
var resultCookie;
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));
if (error) return next(new ldap.OperationsError(error.toString()));
2017-10-27 01:25:07 +02:00
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 memberof = [ GROUP_USERS_DN ];
if (users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) memberof.push(GROUP_ADMINS_DN);
2021-08-20 09:19:44 -07:00
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
2021-08-20 09:19:44 -07:00
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['user', 'inetorgperson', 'person' ],
objectcategory: 'person',
cn: user.id,
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: memberof
}
};
2021-08-20 09:19:44 -07:00
// 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;
2021-08-20 09:19:44 -07:00
// 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()));
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 [error, result] = await safe(getUsersWithAccessToApp(req));
if (error) return next(new ldap.OperationsError(error.toString()));
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
2021-08-20 09:19:44 -07:00
const groups = [{
name: 'users',
admin: false
}, {
name: 'admins',
admin: true
}];
2021-08-20 09:19:44 -07:00
groups.forEach(function (group) {
const dn = ldap.parseDN('cn=' + group.name + ',ou=groups,dc=cloudron');
const members = group.admin ? result.filter(function (user) { return users.compareRoles(user.role, users.ROLE_ADMIN) >= 0; }) : result;
2021-08-20 09:19:44 -07:00
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['group'],
cn: group.name,
memberuid: members.map(function(entry) { return entry.id; })
}
2021-08-20 09:19:44 -07:00
};
2021-08-20 09:19:44 -07:00
// 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);
}
});
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));
if (error) return next(new ldap.OperationsError(error.toString()));
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));
if (error) return next(new ldap.OperationsError(error.toString()));
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') {
var user = result.find(function (u) { return u.id === req.value; });
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 we only search for one mailbox specifically
if (req.dn.rdns[0].attrs.cn) {
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(req.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]));
if (error) return next(new ldap.OperationsError(error.toString()));
if (!mailbox) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!mailbox.active) return next(new ldap.NoSuchObjectError(req.dn.toString()));
const obj = {
dn: req.dn.toString(),
attributes: {
objectclass: ['mailbox'],
objectcategory: 'mailbox',
cn: `${mailbox.name}@${mailbox.domain}`,
uid: `${mailbox.name}@${mailbox.domain}`,
mail: `${mailbox.name}@${mailbox.domain}`
}
};
// 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 (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
} else {
res.end();
}
2020-03-06 13:05:31 -08:00
} else { // new sogo
2021-08-17 15:45:57 -07:00
let [error, mailboxes] = await safe(mail.listAllMailboxes(1, 1000));
if (error) return next(new ldap.OperationsError(error.toString()));
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
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}`,
mail: `${mailbox.name}@${mailbox.domain}`
}
};
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);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
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(req.dn.toString()));
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(req.dn.toString()));
2021-08-17 15:45:57 -07:00
const [error, alias] = await safe(mail.getAlias(parts[0], parts[1]));
if (error) return next(new ldap.OperationsError(error.toString()));
if (!alias) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!alias.active) return next(new ldap.NoSuchObjectError(req.dn.toString())); // there is no way to disable an alias. this is just here for completeness
// 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: `${alias.name}@${alias.domain}`,
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);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
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
2016-09-27 12:20:20 -07:00
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
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(req.dn.toString()));
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(req.dn.toString()));
if (error) return next(new ldap.OperationsError(error.toString()));
const { resolvedMembers, list } = result;
if (!list.active) return next(new ldap.NoSuchObjectError(req.dn.toString()));
// 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);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
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
// extract the common name which might have different attribute names
2021-07-15 09:50:11 -07:00
const attributeName = Object.keys(req.dn.rdns[0].attrs)[0];
const commonName = req.dn.rdns[0].attrs[attributeName].value;
2016-05-12 13:36:53 -07:00
if (!commonName) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2021-07-15 09:50:11 -07:00
let verifyFunc;
if (attributeName === 'mail') {
2021-07-15 09:50:11 -07:00
verifyFunc = users.verifyWithEmail;
} else if (commonName.indexOf('@') !== -1) { // if mail is specified, enforce mail check
2021-07-15 09:50:11 -07:00
verifyFunc = users.verifyWithEmail;
2016-05-12 13:36:53 -07:00
} else if (commonName.indexOf('uid-') === 0) {
2021-07-15 09:50:11 -07:00
verifyFunc = users.verify;
2016-05-12 13:36:53 -07:00
} else {
2021-07-15 09:50:11 -07:00
verifyFunc = users.verifyWithUsername;
2016-05-12 13:36:53 -07:00
}
2021-07-15 09:50:11 -07:00
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', req.app.id));
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));
2016-05-12 13:36:53 -07:00
2021-07-15 09:50:11 -07:00
req.user = user;
2016-05-12 13:36:53 -07:00
2021-07-15 09:50:11 -07:00
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
2021-09-21 10:00:47 -07:00
if (!canAccess) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2016-05-12 13:36:53 -07:00
2021-08-20 09:19:44 -07:00
await eventlog.upsertLoginEvent(eventlog.ACTION_USER_LOGIN, { authType: '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');
2021-07-15 09:50:11 -07:00
if (mailbox.ownerType === mail.OWNERTYPE_USER) return await users.verify(mailbox.ownerId, password, users.AP_MAIL /* identifier */);
2020-11-12 23:25:33 -08:00
2021-07-15 09:50:11 -07:00
const userIds = await groups.getMembers(mailbox.ownerId);
2020-11-12 23:25:33 -08:00
2021-06-28 15:15:28 -07:00
let verifiedUser = null;
2021-07-15 09:50:11 -07:00
for (const userId of userIds) {
const [error, result] = await safe(users.verify(userId, password, users.AP_MAIL /* identifier */));
if (error) continue; // try the next user
verifiedUser = result;
break; // found a matching validated user
}
if (!verifiedUser) throw new BoxError(BoxError.INVALID_CREDENTIALS);
return verifiedUser;
2020-11-12 23:25:33 -08:00
}
2021-08-17 15:45:57 -07:00
async function authenticateUserMailbox(req, res, next) {
debug('user mailbox auth: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
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(req.dn.toString()));
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(req.dn.toString()));
2021-08-17 15:45:57 -07:00
if (!domain.enabled) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2021-08-17 15:45:57 -07:00
const [getMailboxError, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
if (getMailboxError) return next(new ldap.OperationsError(getMailboxError.message));
if (!mailbox) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!mailbox.active) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2021-08-17 15:45:57 -07:00
const [verifyError, result] = await safe(verifyMailboxPassword(mailbox, req.credentials || ''));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
if (verifyError) return next(new ldap.OperationsError(verifyError.message));
2021-08-17 15:45:57 -07:00
eventlog.upsertLoginEvent(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', 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();
}
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);
2019-03-18 21:15:50 -07:00
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2021-07-15 09:50:11 -07:00
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
2019-03-18 21:15:50 -07:00
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
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(req.dn.toString()));
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
[error] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id));
if (error) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
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);
2019-03-18 21:15:50 -07:00
if (req.filter.attribute !== 'username' || !req.filter.value) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2021-08-20 09:19:44 -07:00
const parts = req.filter.value.split('@');
2019-03-18 21:15:50 -07:00
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
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));
if (error) return next(new ldap.OperationsError(error.toString()));
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));
if (userGetError) return next(new ldap.OperationsError(userGetError.toString()));
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: {
homeDirectory: app.dataDir ? `/mnt/${app.id}` : `/mnt/appsdata/${app.id}/data`,
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 authenticateMail(req, res, next) {
debug('authenticateMail: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
2018-02-08 18:49:27 -08:00
2020-12-03 13:35:50 -08:00
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2016-09-26 11:50:32 -07:00
const knownServices = [ 'msa', 'imap', 'pop3', 'sieve' ];
const serviceId = req.dn.rdns[1].attrs.ou.value.toLowerCase();
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(req.dn.toString()));
const serviceNeedsMailbox = serviceId === 'imap' || serviceId === 'sieve' || serviceId === 'pop3';
if (serviceNeedsMailbox && !domain.enabled) return next(new ldap.NoSuchObjectError(req.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 (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 || ''));
2021-10-03 23:59:06 -07:00
if (!appPasswordError) { // validated as app
if (serviceNeedsMailbox && (!mailbox || !mailbox.active)) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2021-10-03 23:59:06 -07:00
return res.end();
}
2021-08-17 15:45:57 -07:00
if (appPasswordError && appPasswordError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
if (appPasswordError && appPasswordError.reason !== BoxError.NOT_FOUND) return next(new ldap.OperationsError(appPasswordError.message));
// user password check requires an active mailbox
2021-08-17 15:45:57 -07:00
if (!mailbox) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!mailbox.active) return next(new ldap.NoSuchObjectError(req.dn.toString()));
2021-08-17 15:45:57 -07:00
const [verifyError, result] = await safe(verifyMailboxPassword(mailbox, req.credentials || ''));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
if (verifyError) return next(new ldap.OperationsError(verifyError.message));
2021-08-17 15:45:57 -07:00
eventlog.upsertLoginEvent(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', 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
}
2021-09-07 09:57:49 -07:00
async function start() {
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 ', 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', authenticateUserMailbox); // apps like sogo can use domain=${domain} to authenticate a mailbox
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();
});
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() {
if (gServer) gServer.close();
2015-09-14 10:59:05 -07:00
}