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.
709 lines
29 KiB
JavaScript
709 lines
29 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
start,
|
|
stop,
|
|
|
|
_MOCK_APP: null
|
|
};
|
|
|
|
const addonConfigs = require('./addonconfigs.js'),
|
|
assert = require('assert'),
|
|
apps = require('./apps.js'),
|
|
AuditSource = require('./auditsource.js'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
debug = require('debug')('box:ldap'),
|
|
eventlog = require('./eventlog.js'),
|
|
groups = require('./groups.js'),
|
|
ldap = require('ldapjs'),
|
|
mail = require('./mail.js'),
|
|
safe = require('safetydance'),
|
|
users = require('./users.js'),
|
|
util = require('util');
|
|
|
|
let gServer = null;
|
|
|
|
const NOOP = function () {};
|
|
|
|
// Will attach req.app if successful
|
|
async function authenticateApp(req, res, next) {
|
|
const sourceIp = req.connection.ldap.id.split(':')[0];
|
|
if (sourceIp.split('.').length !== 4) return next(new ldap.InsufficientAccessRightsError('Missing source identifier'));
|
|
|
|
// 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();
|
|
}
|
|
|
|
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'));
|
|
|
|
req.app = app;
|
|
|
|
next();
|
|
}
|
|
|
|
// Will attach req.user if successful
|
|
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'));
|
|
|
|
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 }));
|
|
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 getUsersWithAccessToApp(req) {
|
|
assert.strictEqual(typeof req.app, 'object');
|
|
|
|
const result = await users.list();
|
|
const allowedUsers = result.filter((user) => user.active && apps.canAccess(req.app, user)); // do not list inactive users
|
|
return allowedUsers;
|
|
}
|
|
|
|
// 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 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, result] = await safe(getUsersWithAccessToApp(req));
|
|
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
|
|
result.forEach(function (user) {
|
|
// skip entries with empty username. Some apps like owncloud can't deal with this
|
|
if (!user.username) return;
|
|
|
|
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', 'organizationalperson', 'top' ],
|
|
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`; })
|
|
}
|
|
};
|
|
|
|
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 results = [];
|
|
|
|
let [groupsListError, resultGroups] = await safe(groups.listWithMembers());
|
|
if (groupsListError) return next(new ldap.OperationsError(groupsListError.message));
|
|
|
|
if (req.app.accessRestriction && req.app.accessRestriction.groups) {
|
|
resultGroups = resultGroups.filter(function (g) { return req.app.accessRestriction.groups.indexOf(g.id) !== -1; });
|
|
}
|
|
|
|
resultGroups.forEach(function (group) {
|
|
const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`);
|
|
|
|
const obj = {
|
|
dn: dn.toString(),
|
|
attributes: {
|
|
objectclass: ['group'],
|
|
cn: group.name,
|
|
gidnumber: group.id,
|
|
memberuid: group.userIds
|
|
}
|
|
};
|
|
|
|
// 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 groupUsersCompare(req, res, next) {
|
|
debug('group users compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
|
|
|
|
const [error, result] = await safe(getUsersWithAccessToApp(req));
|
|
if (error) return next(new ldap.OperationsError(error.message));
|
|
|
|
// 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);
|
|
}
|
|
|
|
res.end(false);
|
|
}
|
|
|
|
async function groupAdminsCompare(req, res, next) {
|
|
debug('group admins compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
|
|
|
|
const [error, result] = await safe(getUsersWithAccessToApp(req));
|
|
if (error) return next(new ldap.OperationsError(error.message));
|
|
|
|
// we only support memberuid here, if we add new group attributes later add them here
|
|
if (req.attribute === 'memberuid') {
|
|
const user = result.find(function (u) { return u.id === req.value; });
|
|
if (user && users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) return res.end(true);
|
|
}
|
|
|
|
res.end(false);
|
|
}
|
|
|
|
async function mailboxSearch(req, res, next) {
|
|
debug('mailbox search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
|
|
|
|
// 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) {
|
|
const parts = email.split('@');
|
|
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));
|
|
|
|
const [error, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
|
|
if (error) return next(new ldap.OperationsError(error.message));
|
|
if (!mailbox) return next(new ldap.NoSuchObjectError(dn.toString()));
|
|
if (!mailbox.active) return next(new ldap.NoSuchObjectError('Mailbox is not active'));
|
|
|
|
const obj = {
|
|
dn: dn.toString(),
|
|
attributes: {
|
|
objectclass: ['mailbox'],
|
|
objectcategory: 'mailbox',
|
|
cn: `${mailbox.name}@${mailbox.domain}`,
|
|
uid: `${mailbox.name}@${mailbox.domain}`,
|
|
mail: `${mailbox.name}@${mailbox.domain}`,
|
|
storagequota: mailbox.storageQuota,
|
|
messagesquota: mailbox.messagesQuota,
|
|
}
|
|
};
|
|
|
|
// 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 (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));
|
|
if (error) return next(new ldap.OperationsError(error.message));
|
|
|
|
mailboxes = mailboxes.filter(m => m.active);
|
|
|
|
let results = [];
|
|
|
|
for (const mailbox of mailboxes) {
|
|
const dn = ldap.parseDN(`cn=${mailbox.name}@${mailbox.domain},ou=mailboxes,dc=cloudron`);
|
|
|
|
if (mailbox.ownerType === mail.OWNERTYPE_APP) continue; // cannot login with app mailbox anyway
|
|
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
|
|
|
|
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}`,
|
|
storagequota: mailbox.storageQuota,
|
|
messagesquota: mailbox.messagesQuota,
|
|
}
|
|
};
|
|
|
|
mailbox.aliases.forEach(function (a, idx) {
|
|
obj.attributes['mail' + idx] = `${a.name}@${a.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.message));
|
|
|
|
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
|
|
results.push(obj);
|
|
}
|
|
}
|
|
|
|
finalSend(results, req, res, next);
|
|
}
|
|
}
|
|
|
|
async function mailAliasSearch(req, res, next) {
|
|
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'));
|
|
|
|
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]));
|
|
if (error) return next(new ldap.OperationsError(error.message));
|
|
if (!alias) return next(new ldap.NoSuchObjectError('No such alias'));
|
|
|
|
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
|
|
|
|
// 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
|
|
rfc822MailMember: `${alias.aliasName}@${alias.aliasDomain}`
|
|
}
|
|
};
|
|
|
|
// 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 (lowerCaseFilter.matches(obj.attributes)) {
|
|
finalSend([ obj ], req, res, next);
|
|
} else {
|
|
res.end();
|
|
}
|
|
}
|
|
|
|
async function mailingListSearch(req, res, next) {
|
|
debug('mailing list 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'));
|
|
|
|
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'));
|
|
const name = parts[0], domain = parts[1];
|
|
|
|
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'));
|
|
if (error) return next(new ldap.OperationsError(error.message));
|
|
|
|
const { resolvedMembers, list } = result;
|
|
|
|
if (!list.active) return next(new ldap.NoSuchObjectError('List is not active'));
|
|
|
|
// 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
|
|
}
|
|
};
|
|
|
|
// 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 (lowerCaseFilter.matches(obj.attributes)) {
|
|
finalSend([ obj ], req, res, next);
|
|
} else {
|
|
res.end();
|
|
}
|
|
}
|
|
|
|
// Will attach req.user if successful
|
|
async function authenticateUser(req, res, next) {
|
|
debug('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
|
|
|
|
const appId = req.app.id;
|
|
|
|
await userAuthInternal(appId, req, res, next);
|
|
}
|
|
|
|
async function authorizeUserForApp(req, res, next) {
|
|
assert.strictEqual(typeof req.user, 'object');
|
|
assert.strictEqual(typeof req.app, 'object');
|
|
|
|
const canAccess = apps.canAccess(req.app, req.user);
|
|
// we return no such object, to avoid leakage of a users existence
|
|
if (!canAccess) return next(new ldap.NoSuchObjectError('Invalid user or insufficient previleges'));
|
|
|
|
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) });
|
|
|
|
res.end();
|
|
}
|
|
|
|
async function verifyMailboxPassword(mailbox, password) {
|
|
assert.strictEqual(typeof mailbox, 'object');
|
|
assert.strictEqual(typeof password, 'string');
|
|
|
|
if (mailbox.ownerType === mail.OWNERTYPE_USER) {
|
|
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) {
|
|
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
|
|
}
|
|
|
|
if (!verifiedUser) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
|
return verifiedUser;
|
|
} else {
|
|
throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
|
}
|
|
}
|
|
|
|
async function authenticateSftp(req, res, next) {
|
|
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'));
|
|
|
|
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'));
|
|
|
|
let [error, app] = await safe(apps.getByFqdn(parts[1]));
|
|
if (error || !app) return next(new ldap.InvalidCredentialsError());
|
|
|
|
[error] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id, { skipTotpCheck: true }));
|
|
if (error) return next(new ldap.InvalidCredentialsError(error.message));
|
|
|
|
debug('sftp auth: success');
|
|
|
|
res.end();
|
|
}
|
|
|
|
async function userSearchSftp(req, res, next) {
|
|
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());
|
|
|
|
const parts = req.filter.value.split('@');
|
|
if (parts.length !== 2) return next(new ldap.NoSuchObjectError());
|
|
|
|
const username = parts[0];
|
|
const appFqdn = parts[1];
|
|
|
|
const [error, app] = await safe(apps.getByFqdn(appFqdn));
|
|
if (error) return next(new ldap.OperationsError(error.message));
|
|
|
|
// 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'));
|
|
|
|
const uidNumber = app.manifest.addons.localstorage.ftp.uid;
|
|
|
|
const [userGetError, user] = await safe(users.getByUsername(username));
|
|
if (userGetError) return next(new ldap.OperationsError(userGetError.message));
|
|
if (!user) return next(new ldap.OperationsError('Invalid username'));
|
|
|
|
if (!apps.isOperator(app, user)) return next(new ldap.InsufficientAccessRightsError('Not authorized'));
|
|
|
|
const obj = {
|
|
dn: ldap.parseDN(`cn=${username}@${appFqdn},ou=sftp,dc=cloudron`).toString(),
|
|
attributes: {
|
|
homeDirectory: app.storageVolumeId ? `/mnt/app-${app.id}` : `/mnt/appsdata/${app.id}/data`, // see also sftp.js
|
|
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
|
|
}
|
|
};
|
|
|
|
finalSend([ obj ], req, res, next);
|
|
}
|
|
|
|
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})`);
|
|
|
|
if (!dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(dn.toString()));
|
|
|
|
const email = dn.rdns[0].attrs.cn.value.toLowerCase();
|
|
const parts = email.split('@');
|
|
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));
|
|
|
|
const knownServices = [ 'msa', 'imap', 'pop3', 'sieve', 'sogo' ];
|
|
if (!knownServices.includes(serviceId)) return next(new ldap.OperationsError('Invalid DN. Unknown service'));
|
|
|
|
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()));
|
|
|
|
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'));
|
|
}
|
|
|
|
const [appPasswordError] = await safe(verifyAppMailboxPassword(serviceId, email, req.credentials || ''));
|
|
if (!appPasswordError) return res.end(); // validated as app
|
|
|
|
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
|
|
const [verifyError, result] = await safe(verifyMailboxPassword(mailbox, req.credentials || ''));
|
|
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));
|
|
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) });
|
|
|
|
res.end();
|
|
}
|
|
|
|
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
|
|
// 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 start() {
|
|
assert(gServer === null, 'Already started');
|
|
|
|
const logger = {
|
|
trace: NOOP,
|
|
debug: NOOP,
|
|
info: debug,
|
|
warn: debug,
|
|
error: debug,
|
|
fatal: debug
|
|
};
|
|
|
|
gServer = ldap.createServer({ log: logger });
|
|
|
|
gServer.on('error', function (error) {
|
|
debug('start: server error. %o', error);
|
|
});
|
|
|
|
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);
|
|
|
|
// http://www.ietf.org/proceedings/43/I-D/draft-srivastava-ldap-mail-00.txt
|
|
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
|
|
|
|
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)
|
|
|
|
gServer.bind('ou=sftp,dc=cloudron', authenticateSftp); // sftp
|
|
gServer.search('ou=sftp,dc=cloudron', userSearchSftp);
|
|
|
|
gServer.compare('cn=users,ou=groups,dc=cloudron', authenticateApp, groupUsersCompare);
|
|
gServer.compare('cn=admins,ou=groups,dc=cloudron', authenticateApp, groupAdminsCompare);
|
|
|
|
// this is the bind for addons (after bind, they might search and authenticate)
|
|
gServer.bind('ou=addons,dc=cloudron', function(req, res /*, next */) {
|
|
debug('addons bind: %s', req.dn.toString()); // note: cn can be email or id
|
|
res.end();
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
await util.promisify(gServer.listen.bind(gServer))(constants.LDAP_PORT, '0.0.0.0');
|
|
}
|
|
|
|
async function stop() {
|
|
if (!gServer) return;
|
|
|
|
await util.promisify(gServer.close.bind(gServer))();
|
|
gServer = null;
|
|
}
|