diff --git a/src/ldap.js b/src/ldap.js index f9c12755c..59858256a 100644 --- a/src/ldap.js +++ b/src/ldap.js @@ -283,6 +283,134 @@ async function groupAdminsCompare(req, res, next) { res.end(false); } +async function userSearchExposed(req, res, next) { + debug('exposed 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(users.list()); + if (error) return next(new ldap.OperationsError(error.toString())); + + 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 memberof = [ GROUP_USERS_DN ]; + if (users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) memberof.push(GROUP_ADMINS_DN); + + 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 + + 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 + } + }; + + // 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; + + // 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); + } + }); + + finalSend(results, req, res, next); +} + +async function groupSearchExposed(req, res, next) { + debug('exposed group 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(users.list()); + if (error) return next(new ldap.OperationsError(error.toString())); + + const results = []; + + // those are the old virtual groups for backwards compat + const virtualGroups = [{ + name: 'users', + admin: false + }, { + name: 'admins', + admin: true + }]; + + virtualGroups.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; + + const obj = { + dn: dn.toString(), + attributes: { + objectclass: ['group'], + cn: group.name, + memberuid: members.map(function(entry) { return entry.id; }).sort() + } + }; + + // 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); + } + }); + + let [errorGroups, resultGroups] = await safe(groups.listWithMembers()); + if (errorGroups) return next(new ldap.OperationsError(errorGroups.toString())); + + 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 members = group.userIds.filter(function (uid) { return result.map(function (u) { return u.id; }).indexOf(uid) !== -1; }); + + const obj = { + dn: dn.toString(), + attributes: { + objectclass: ['group'], + cn: group.name, + memberuid: members + } + }; + + // 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); + } + }); + + finalSend(results, req, res, next); +} + 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); @@ -731,12 +859,15 @@ async function startExposed() { debugExposed('server startup error ', error); }); - gExposedServer.search('ou=users,dc=cloudron', authenticateApp, userSearch); - gExposedServer.search('ou=groups,dc=cloudron', authenticateApp, groupSearch); - gExposedServer.bind('ou=users,dc=cloudron', authenticateApp, authenticateUser, authorizeUserForApp); + gExposedServer.search('ou=users,dc=cloudron', userSearchExposed); + gExposedServer.search('ou=groups,dc=cloudron', groupSearchExposed); + gExposedServer.bind('ou=users,dc=cloudron', authenticateUser, async function (req, res) { + assert.strictEqual(typeof req.user, 'object'); - gExposedServer.compare('cn=users,ou=groups,dc=cloudron', authenticateApp, groupUsersCompare); - gExposedServer.compare('cn=admins,ou=groups,dc=cloudron', authenticateApp, groupAdminsCompare); + await eventlog.upsertLoginEvent(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', appId: req.app.id }, { userId: req.user.id, user: users.removePrivateFields(req.user) }); + + res.end(); + }); // just log that an attempt was made to unknown route, this helps a lot during app packaging gExposedServer.use(function(req, res, next) {