diff --git a/src/ldap.js b/src/ldap.js index d17a077fb..1d07c9abc 100644 --- a/src/ldap.js +++ b/src/ldap.js @@ -57,6 +57,34 @@ async function authenticateApp(req, res, next) { next(); } +// Will attach req.user if successful appId is only passed if request comes from an app +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(req.dn.toString())); + + 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 || '')); + 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)); + + req.user = user; + + next(); +} + async function getUsersWithAccessToApp(req) { assert.strictEqual(typeof req.app, 'object'); @@ -447,30 +475,9 @@ async function mailingListSearch(req, res, next) { async function authenticateUser(req, res, next) { debug('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id); - // 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(req.dn.toString())); + const appId = req.app.id; - 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 || '', req.app ? 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)); - - req.user = user; - - next(); + await userAuthInternal(appId, req, res, next); } async function authorizeUserForApp(req, res, next) { @@ -704,36 +711,6 @@ async function stop() { gServer = null; } -// Will attach req.user if successful -async function authenticateUserExposed(req, res, next) { - debugExposed('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id); - - // 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(req.dn.toString())); - - 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 || '', '')); - 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)); - - req.user = user; - - next(); -} - async function userSearchExposed(req, res, next) { debugExposed('exposed user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id); @@ -833,10 +810,6 @@ async function groupSearchExposed(req, res, next) { 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; }); @@ -891,7 +864,11 @@ async function startExposed() { gExposedServer.search('ou=users,dc=cloudron', userSearchExposed); gExposedServer.search('ou=groups,dc=cloudron', groupSearchExposed); - gExposedServer.bind('ou=users,dc=cloudron', authenticateUserExposed, async function (req, res) { + gExposedServer.bind('ou=users,dc=cloudron', async function(req, res, next) { + debugExposed('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id); + + await userAuthInternal('', req, res, next); + }, async function (req, res) { assert.strictEqual(typeof req.user, 'object'); await eventlog.upsertLoginEvent(eventlog.ACTION_USER_LOGIN, { authType: 'exposedldap', id: req.connection.ldap.id }, { userId: req.user.id, user: users.removePrivateFields(req.user) }); diff --git a/src/test/exposedldap-test.js b/src/test/exposedldap-test.js new file mode 100644 index 000000000..c22ee7276 --- /dev/null +++ b/src/test/exposedldap-test.js @@ -0,0 +1,258 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +const async = require('async'), + common = require('./common.js'), + constants = require('../constants.js'), + expect = require('expect.js'), + groups = require('../groups.js'), + ldap = require('ldapjs'), + ldapServer = require('../ldap.js'), + safe = require('safetydance'); + +async function ldapBind(dn, password) { + return new Promise((resolve, reject) => { + const client = ldap.createClient({ url: 'ldaps://127.0.0.1:' + constants.LDAPS_PORT, tlsOptions: { rejectUnauthorized: false }}); + + client.on('error', reject); + + client.bind(dn, password, function (error) { + client.unbind(); + + if (error) reject(error); + resolve(); + }); + }); +} + +// ldapsearch -LLL -E pr=10/noprompt -x -h localhost -p 3002 -b cn=userName0@example.com,ou=mailboxes,dc=cloudron objectclass=mailbox +async function ldapSearch(dn, opts) { + return new Promise((resolve, reject) => { + const client = ldap.createClient({ url: 'ldaps://127.0.0.1:' + constants.LDAPS_PORT, tlsOptions: { rejectUnauthorized: false }}); + + client.search(dn, opts, function (error, result) { + if (error) return reject(error); + + let entries = []; + + result.on('searchEntry', function (entry) { entries.push(entry.object); }); + + result.on('error', function (error) { + client.unbind(); + reject(error); + }); + + result.on('end', function (result) { + if (result.status !== 0) return reject(new Error(`Unexpected status: ${result.status}`)); + resolve(entries); + }); + }); + }); + +} + +describe('Exposed Ldap', function () { + const { setup, cleanup, admin, user, app, domain, auditSource } = common; + let group, group2; + const mockApp = Object.assign({}, app); + + before(function (done) { + async.series([ + setup, + ldapServer.startExposed.bind(null), + async () => { + group = await groups.add({ name: 'ldap-test-1' }); + await groups.setMembers(group.id, [ admin.id, user.id ]); + }, + async () => { + group2 = await groups.add({ name: 'ldap-test-2' }); + await groups.setMembers(group2.id, [ admin.id ]); + } + ], done); + + ldapServer._MOCK_APP = mockApp; + }); + + after(function (done) { + async.series([ + ldapServer.stopExposed, + cleanup + ], done); + }); + + describe('user bind', function () { + it('cn= fails for nonexisting user', async function () { + const [error] = await safe(ldapBind('cn=doesnotexist,ou=users,dc=cloudron', 'password')); + expect(error).to.be.a(ldap.NoSuchObjectError); + }); + + it('cn= fails with wrong password', async function () { + const [error] = await safe(ldapBind(`cn=${admin.id},ou=users,dc=cloudron`, 'wrongpassword')); + expect(error).to.be.a(ldap.InvalidCredentialsError); + }); + + it('cn= succeeds with id', async function () { + await ldapBind(`cn=${admin.id},ou=users,dc=cloudron`, admin.password); + }); + + it('cn= succeeds with username', async function () { + await ldapBind(`cn=${admin.username},ou=users,dc=cloudron`, admin.password); + }); + + it('cn= succeeds with email', async function () { + await ldapBind(`cn=${admin.email},ou=users,dc=cloudron`, admin.password); + }); + + it('mail= fails with bad email', async function () { + const [error] = await safe(ldapBind('mail=random,ou=users,dc=cloudron', admin.password)); + expect(error).to.be.a(ldap.NoSuchObjectError); + }); + + it('mail= succeeds with email', async function () { + await ldapBind(`mail=${admin.email},ou=users,dc=cloudron`, admin.password); + }); + }); + + describe('search users', function () { + it('fails for non existing tree', async function () { + const [error] = await safe(ldapSearch('o=example', { filter: '(&(l=Seattle)(email=*@' + domain.domain + '))' })); + expect(error).to.be.a(ldap.NoSuchObjectError); + }); + + it('succeeds with basic filter', async function () { + const entries = await ldapSearch('ou=users,dc=cloudron', { filter: 'objectcategory=person' }); + expect(entries.length).to.equal(2); + entries.sort(function (a, b) { return a.username > b.username; }); + expect(entries[0].username).to.equal(admin.username.toLowerCase()); + expect(entries[0].mail).to.equal(admin.email.toLowerCase()); + expect(entries[1].username).to.equal(user.username.toLowerCase()); + expect(entries[1].mail).to.equal(user.email.toLowerCase()); + }); + + it('succeeds with pagination', async function () { + const entries = await ldapSearch('ou=users,dc=cloudron', { filter: 'objectcategory=person', paged: true }); + expect(entries.length).to.equal(2); + entries.sort(function (a, b) { return a.username > b.username; }); + expect(entries[0].username).to.equal(admin.username.toLowerCase()); + expect(entries[0].mail).to.equal(admin.email.toLowerCase()); + expect(entries[1].username).to.equal(user.username.toLowerCase()); + expect(entries[1].mail).to.equal(user.email.toLowerCase()); + }); + + it('succeeds with username wildcard filter', async function () { + const entries = await ldapSearch('ou=users,dc=cloudron', { filter: '&(objectcategory=person)(username=*)' }); + expect(entries.length).to.equal(2); + entries.sort(function (a, b) { return a.username > b.username; }); + expect(entries[0].username).to.equal(admin.username.toLowerCase()); + expect(entries[1].username).to.equal(user.username.toLowerCase()); + }); + + it('succeeds with username filter', async function () { + const entries = await ldapSearch('ou=users,dc=cloudron', { filter: '&(objectcategory=person)(username=' + admin.username + ')' }); + expect(entries.length).to.equal(1); + expect(entries[0].username).to.equal(admin.username.toLowerCase()); + expect(entries[0].memberof.length).to.equal(2); + }); + }); + + describe('group search', function () { + it('succeeds with basic filter', async function () { + mockApp.accessRestriction = null; + + const entries = await ldapSearch('ou=groups,dc=cloudron', { filter: 'objectclass=group' }); + expect(entries.length).to.equal(4); + + // ensure order for testability + entries.sort(function (a, b) { return a.cn < b.cn; }); + + expect(entries[0].cn).to.equal('users'); + expect(entries[0].memberuid.length).to.equal(2); + expect(entries[0].memberuid).to.contain(admin.id); + expect(entries[0].memberuid).to.contain(user.id); + + expect(entries[1].cn).to.equal('admins'); + // if only one entry, the array becomes a string :-/ + expect(entries[1].memberuid).to.equal(admin.id); + + expect(entries[2].cn).to.equal('ldap-test-1'); + expect(entries[2].memberuid.length).to.equal(2); + expect(entries[2].memberuid).to.contain(admin.id); + expect(entries[2].memberuid).to.contain(user.id); + + expect(entries[3].cn).to.equal('ldap-test-2'); + expect(entries[3].memberuid).to.equal(admin.id); + }); + + it ('succeeds with cn wildcard filter', async function () { + const entries = await ldapSearch('ou=groups,dc=cloudron', { filter: '&(objectclass=group)(cn=*)' }); + expect(entries.length).to.equal(4); + + // ensure order for testability + entries.sort(function (a, b) { return a.cn < b.cn; }); + + expect(entries[0].cn).to.equal('users'); + expect(entries[0].memberuid.length).to.equal(2); + expect(entries[0].memberuid).to.contain(admin.id); + expect(entries[0].memberuid).to.contain(user.id); + + expect(entries[1].cn).to.equal('admins'); + // if only one entry, the array becomes a string :-/ + expect(entries[1].memberuid).to.equal(admin.id); + + expect(entries[2].cn).to.equal('ldap-test-1'); + expect(entries[2].memberuid.length).to.equal(2); + expect(entries[2].memberuid).to.contain(admin.id); + expect(entries[2].memberuid).to.contain(user.id); + + expect(entries[3].cn).to.equal('ldap-test-2'); + expect(entries[3].memberuid).to.equal(admin.id); + }); + + it('succeeds with memberuid filter', async function () { + const entries = await ldapSearch('ou=groups,dc=cloudron', { filter: '&(objectclass=group)(memberuid=' + user.id + ')' }); + expect(entries.length).to.equal(2); + + // ensure order for testability + entries.sort(function (a, b) { return a.cn < b.cn; }); + + expect(entries[0].cn).to.equal('users'); + expect(entries[0].memberuid.length).to.equal(2); + + expect(entries[1].cn).to.equal('ldap-test-1'); + expect(entries[1].memberuid.length).to.equal(2); + expect(entries[1].memberuid).to.contain(admin.id); + expect(entries[1].memberuid).to.contain(user.id); + }); + + it ('succeeds with pagination', async function () { + mockApp.accessRestriction = null; + const entries = await ldapSearch('ou=groups,dc=cloudron', { filter: 'objectclass=group', paged: true }); + expect(entries.length).to.equal(4); + + // ensure order for testability + entries.sort(function (a, b) { return a.cn < b.cn; }); + + expect(entries[0].cn).to.equal('users'); + expect(entries[0].memberuid.length).to.equal(2); + expect(entries[0].memberuid).to.contain(admin.id); + expect(entries[0].memberuid).to.contain(user.id); + + expect(entries[1].cn).to.equal('admins'); + // if only one entry, the array becomes a string :-/ + expect(entries[1].memberuid).to.equal(admin.id); + + expect(entries[2].cn).to.equal('ldap-test-1'); + expect(entries[2].memberuid.length).to.equal(2); + expect(entries[2].memberuid).to.contain(admin.id); + expect(entries[2].memberuid).to.contain(user.id); + + expect(entries[3].cn).to.equal('ldap-test-2'); + expect(entries[3].memberuid).to.equal(admin.id); + }); + }); +}); diff --git a/src/users.js b/src/users.js index 52cb18866..017d69087 100644 --- a/src/users.js +++ b/src/users.js @@ -332,6 +332,7 @@ async function verifyAppPassword(userId, password, identifier) { throw new BoxError(BoxError.INVALID_CREDENTIALS); } +// identifier is only used to check if password is valid for a specific app async function verify(userId, password, identifier) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof password, 'string');