/* global it:false */ /* global describe:false */ /* global before:false */ /* global after:false */ 'use strict'; const async = require('async'), BoxError = require('../boxerror.js'), common = require('./common.js'), expect = require('expect.js'), externalLdap = require('../externalldap.js'), groups = require('../groups.js'), ldap = require('ldapjs'), safe = require('safetydance'), users = require('../users.js'); const LDAP_SHARED_PASSWORD = 'validpassword'; const LDAP_SHARED_TOTP_TOKEN = '1234'; const LDAP_PORT = 4321; const LDAP_BASE_DN = 'ou=Users,dc=cloudron,dc=io'; const LDAP_GROUP_BASE_DN = 'ou=Groups,dc=cloudron,dc=io'; const LDAP_CONFIG = { provider: 'testserver', url: `ldap://localhost:${LDAP_PORT}`, usernameField: 'customusernameprop', baseDn: LDAP_BASE_DN, filter: '(objectClass=inetOrgPerson)', syncGroups: false, groupBaseDn: LDAP_GROUP_BASE_DN, groupFilter: '(objectClass=groupOfNames)', groupnameField: 'customgroupnameprop', autoCreate: false }; class LdapServer { #provider; #ldapServer; #users = []; #groups = []; constructor(provider) { this.#provider = provider; } setUsers(users) { this.#users = users; } setGroups(groups) { this.#groups = groups; } setProvider(provider) { this.#provider = provider; } // helper function to deal with pagination taken from ldap.js #finalSend(results, req, res, next) { const min = 0; const 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(); } start(callback) { this.#ldapServer = ldap.createServer(); this.#ldapServer.search('', (req, res) => { res.send({ dn: '', attributes: { objectclass: [ 'RootDSE', 'top', 'OpenLDAProotDSE' ], supportedLDAPVersion: '3', vendorName: 'Cloudron Test LDAP', vendorVersion: '1.0.0', //supportedControl: [ ldap.PagedResultsControl.OID ], supportedExtension: [] } }); res.end(); }); this.#ldapServer.search(LDAP_BASE_DN, (req, res, next) => { const results = []; this.#users.forEach(function (entry) { const dn = ldap.parseDN(`cn=${entry.username},${LDAP_BASE_DN}`); const obj = { dn: dn.toString(), attributes: { objectclass: [ 'inetorgperson' ], mail: entry.email, cn: entry.displayName, customusernameprop: entry.username } }; if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(obj.attributes)) { results.push(obj); } }); this.#finalSend(results, req, res, next); }); this.#ldapServer.search(LDAP_GROUP_BASE_DN, (req, res, next) => { const results = []; this.#groups.forEach(function (entry) { const dn = ldap.parseDN(`cn=${entry.groupname},${LDAP_GROUP_BASE_DN}`); const obj = { dn: dn.toString(), attributes: { objectclass: [ 'groupOfNames' ], cn: entry.groupname, member: entry.member || [], customgroupnameprop: entry.groupname } }; if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(obj.attributes)) { results.push(obj); } }); this.#finalSend(results, req, res, next); }); this.#ldapServer.bind(LDAP_BASE_DN, (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')); const u = this.#users.find(function (u) { return u.username === commonName; }); if (!u) return next(new ldap.NoSuchObjectError('No such user')); if (req.credentials !== LDAP_SHARED_PASSWORD) return next(new ldap.InvalidCredentialsError('Bad password')); if (this.#provider === 'cloudron') { // behave like cloudron user directory const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken'; const totpToken = TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null; const skipTotpCheck = !(TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs); if (!skipTotpCheck && u.has2fa) { if (!totpToken) return next(new ldap.InvalidCredentialsError('totpToken is required')); if (totpToken !== LDAP_SHARED_TOTP_TOKEN) return next(new ldap.InvalidCredentialsError('Invalid totpToken')); } } res.end(); }); this.#ldapServer.listen(LDAP_PORT, callback); } stop(callback) { if (this.#ldapServer) { this.#ldapServer.close(callback); } else { callback(); } } } describe('External LDAP', function () { const { setup, cleanup, admin, auditSource } = common; const ldapServer = new LdapServer(LDAP_CONFIG.provider); before(function (done) { async.series([ ldapServer.start.bind(ldapServer), setup ], done); }); after(function (done) { async.series([ ldapServer.stop.bind(ldapServer), cleanup ], done); }); describe('settings', function () { it('enabling fails with missing url', async function () { const conf = Object.assign({}, LDAP_CONFIG); delete conf.url; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling fails with empty url', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.url = ''; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling fails with missing baseDn', async function () { const conf = Object.assign({}, LDAP_CONFIG); delete conf.baseDn; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling fails with empty baseDn', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.baseDn = ''; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling fails with missing filter', async function () { const conf = Object.assign({}, LDAP_CONFIG); delete conf.filter; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling fails with empty filter', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.filter = ''; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling succeeds', async function () { await externalLdap.setConfig(LDAP_CONFIG, auditSource); }); it('disabling succeeds', async function () { await externalLdap.setConfig({ provider: 'noop' }, auditSource); }); // now test with groups it('enabling with groups fails with missing groupBaseDn', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.syncGroups = true; delete conf.groupBaseDn; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling with groups fails with empty groupBaseDn', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.syncGroups = true; conf.groupBaseDn = ''; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling with groups fails with missing groupFilter', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.syncGroups = true; delete conf.groupFilter; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling with groups fails with empty groupFilter', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.syncGroups = true; conf.groupFilter = ''; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling with groups fails with missing groupnameField', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.syncGroups = true; delete conf.groupnameField; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling with groups fails with empty groupnameField', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.syncGroups = true; conf.groupnameField = ''; const [error] = await safe(externalLdap.setConfig(conf, auditSource)); expect(error.reason).to.equal(BoxError.BAD_FIELD); }); it('enabling with groups succeeds', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.syncGroups = true; await externalLdap.setConfig(conf, auditSource); }); }); describe('verifyPassword', function () { before(async function () { await externalLdap.setConfig(LDAP_CONFIG, auditSource); ldapServer.setUsers([{ username: 'maximus', displayName: 'First User', email: 'first@user.com' }]); }); it('fails for unknown user', async function () { const [error] = await safe(externalLdap.verifyPassword('badusername', 'badpassword', {})); expect(error.reason).to.be(BoxError.NOT_FOUND); }); it('fails for bad password', async function () { const [error] = await safe(externalLdap.verifyPassword('maximus', 'badpassword', {})); expect(error.reason).to.be(BoxError.INVALID_CREDENTIALS); }); it('succeeds for valid password', async function () { const u = await externalLdap.verifyPassword('maximus', LDAP_SHARED_PASSWORD, {}); expect(u.username).to.be('maximus'); }); }); describe('2FA', function () { describe('non-cloudron provider', function () { before(async function () { ldapServer.setProvider(LDAP_CONFIG.provider); await externalLdap.setConfig(LDAP_CONFIG, auditSource); ldapServer.setUsers([{ username: 'maximus', displayName: 'First User', email: 'first@user.com' }]); }); it('succeeds when skip totp', async function () { const u = await externalLdap.verifyPassword('maximus', LDAP_SHARED_PASSWORD, { skipTotpCheck: true }); expect(u.username).to.be('maximus'); }); it('succeeds when not skipping totp since totpToken is ignored for non-cloudron provider', async function () { const u = await externalLdap.verifyPassword('maximus', LDAP_SHARED_PASSWORD, { skipTotpCheck: false, totpToken: 'random' }); expect(u.username).to.be('maximus'); }); }); describe('cloudron provider', function () { before(async function () { ldapServer.setProvider('cloudron'); await externalLdap.setConfig(Object.assign({}, LDAP_CONFIG, { provider: 'cloudron' }), auditSource); ldapServer.setUsers([{ username: 'maximus', displayName: 'First User', email: 'first@user.com', has2fa: true }]); }); it('succeeds when skip totp (e.g. some cloudron app)', async function () { const u = await externalLdap.verifyPassword('maximus', LDAP_SHARED_PASSWORD, { skipTotpCheck: true }); expect(u.username).to.be('maximus'); }); it('fails when not skipping totp (e.g. cloudron dashboard)', async function () { const [error] = await safe(externalLdap.verifyPassword('maximus', LDAP_SHARED_PASSWORD, { skipTotpCheck: false, totpToken: 'badtotp' })); expect(error.reason).to.be(BoxError.INVALID_CREDENTIALS); }); it('fails when totp required and empty totp (e.g cloudron dashboard)', async function () { const [error] = await safe(externalLdap.verifyPassword('maximus', LDAP_SHARED_PASSWORD, { skipTotpCheck: false, totpToken: '' })); expect(error.reason).to.be(BoxError.INVALID_CREDENTIALS); }); it('succeeds when totp required and good totp', async function () { const u = await externalLdap.verifyPassword('maximus', LDAP_SHARED_PASSWORD, { skipTotpCheck: false, totpToken: LDAP_SHARED_TOTP_TOKEN }); expect(u.username).to.be('maximus'); }); }); }); describe('sync', function () { const ldapUsers = [], ldapGroups = []; before(function () { ldapServer.setUsers(ldapUsers); ldapServer.setGroups(ldapGroups); }); it('disable sync', async function () { await externalLdap.setConfig({ provider: 'noop' }, auditSource); }); it('fails if disabled', async function () { const [error] = await safe(externalLdap.sync(function progress() {})); expect(error.reason).to.equal(BoxError.BAD_STATE); }); it('enable', async function () { await externalLdap.setConfig(LDAP_CONFIG, auditSource); }); it('succeeds for new users', async function () { ldapUsers.push({ username: 'firstuser', displayName: 'First User', email: 'first@user.com' }); await externalLdap.sync(function progress() {}); const result = await users.list(); expect(result.find(function (u) { return u.username === 'firstuser' && u.email === 'first@user.com' && u.displayName === 'First User' && u.source === 'ldap'; })).to.be.ok(); }); it('succeeds for updated users', async function () { ldapUsers[0].displayName = 'User First'; ldapUsers[0].email = 'first@changed.com'; await externalLdap.sync(function progress() {}); const result = await users.list(); expect(result.find(function (u) { return u.username === 'firstuser' && u.email === 'first@changed.com' && u.displayName === 'User First' && u.source === 'ldap'; })).to.be.ok(); }); it('maps already existing users with same username', async function () { ldapUsers.push({ username: admin.username, displayName: 'Something Else', email: 'foobar@bar.com' }); await externalLdap.sync(function progress() {}); const result = await users.list(); expect(result.find(function (u) { return u.email === 'foobar@bar.com' || u.displayName === 'Something Else'; })).to.be.ok(); }); it('does not sync group if group sync is disabled', async function () { ldapGroups.push({ groupname: 'extGroup1' }); await externalLdap.sync(function progress() {}); const result = await groups.list(); expect(result.length).to.equal(0); ldapGroups.splice(0, 1); // clear the group in-place }); it('can set groups of external user when group sync is disabled', async function () { const user = await users.getByUsername(ldapUsers[0].username); await groups.setLocalMembership(user, [], auditSource); }); it('enable with groupSync', async function () { const conf = Object.assign({}, LDAP_CONFIG, { syncGroups: true }); await externalLdap.setConfig(conf, auditSource); }); it('succeeds with groups enabled', async function () { await externalLdap.sync(function progress() {}); const result = await groups.list(); expect(result.length).to.equal(0); }); it('succeeds with groups enabled and new group', async function () { ldapGroups.push({ groupname: 'extGroup1' }); await externalLdap.sync(function progress() {}); const result = await groups.list(); expect(result.find(function (g) { return g.name === 'extgroup1' && g.source === 'ldap'; })).to.be.ok(); }); it('succeeds with groups enabled and second new group', async function () { ldapGroups.push({ groupname: 'extGroup2' }); await externalLdap.sync(function progress() {}); const result = await groups.list(); expect(result.length).to.be(2); expect(result.find(function (g) { return g.name === 'extgroup2' && g.source === 'ldap'; })).to.be.ok(); }); it('does not create or change already existing group', async function () { ldapGroups.push({ groupname: 'INTERNALgroup' // also tests lowercasing }); await groups.add({ name: 'internalgroup' }, auditSource); await externalLdap.sync(function progress() {}); const result = await groups.list(); expect(result.length).to.equal(3); expect(result.find(function (g) { return g.name === 'internalgroup' && g.source === 'ldap'; // source is updated to LDAP })).to.be.ok(); }); it('adds users of groups', async function () { ldapGroups.push({ groupname: 'nonEmptyGroup', member: ldapUsers.slice(-2).map(function (u) { return `cn=${u.username},${LDAP_CONFIG.baseDn}`; }) }); await externalLdap.sync(function progress() {}); const result = await groups.getByName('nonemptygroup'); expect(result).to.be.ok(); const result2 = await groups.getMemberIds(result.id); expect(result2.length).to.equal(2); }); it('adds new users of groups', async function () { ldapGroups.push({ groupname: 'nonEmptyGroup', member: ldapUsers.map(function (u) { return `cn=${u.username},${LDAP_CONFIG.baseDn}`; }) // has 2 entries }); await externalLdap.sync(function progress() {}); const result = await groups.getByName('nonemptygroup'); expect(result).to.be.ok(); const result2 = await groups.getMemberIds(result.id); expect(result2.length).to.equal(2); }); it('succeeds with only one group member (string instead of array)', async function () { ldapGroups.push({ groupname: 'onemembergroup', member: `cn=${ldapUsers[0].username},${LDAP_CONFIG.baseDn}` }); await externalLdap.sync(function progress() {}); const result = await groups.getByName('onemembergroup'); const result2 = await groups.getMemberIds(result.id); expect(result2.length).to.equal(1); const u = await users.get(result2[0]); expect(u.username).to.equal(ldapUsers[0].username); }); it('cannot update profile of external user', async function () { const user = await users.getByUsername(ldapUsers[0].username); const [error] = await safe(users.updateProfile(user, { displayName: 'another name'}, auditSource)); expect(error.reason).to.be(BoxError.BAD_STATE); }); }); describe('maybeCreateUser - user auto creation', function () { const ldapUsers = [], ldapGroups = []; before(async function () { ldapServer.setUsers(ldapUsers); ldapServer.setGroups(ldapGroups); }); it('enable', async function () { await externalLdap.setConfig(LDAP_CONFIG, auditSource); }); it('fails if auto create is disabled', async function () { ldapUsers.push({ username: 'autologinuser0', displayName: 'Auto Login0', email: 'auto0@login.com' }); const [error] = await safe(users.verifyWithUsername('autologinuser0', LDAP_SHARED_PASSWORD, users.AP_WEBADMIN, {})); expect(error.reason).to.be(BoxError.NOT_FOUND); const result = await users.list(); expect(result.find(function (u) { return u.username === 'autologinuser0'; })).to.not.be.ok(); }); it('enable auto create', async function () { const conf = Object.assign({}, LDAP_CONFIG); conf.autoCreate = true; await externalLdap.setConfig(conf, auditSource); }); it('fails for unknown user', async function () { const [error] = await safe(users.verifyWithUsername('doesnotexist', LDAP_SHARED_PASSWORD, users.AP_WEBADMIN, {})); expect(error.reason).to.be(BoxError.NOT_FOUND); const result = await users.list(); expect(result.find(function (u) { return u.username === 'doesnotexist'; })).to.not.be.ok(); }); it('succeeds for known user with wrong password', async function () { ldapUsers.push({ username: 'autologinuser1', displayName: 'Auto Login1', email: 'auto1@login.com' }); const [error] = await safe(users.verifyWithUsername('autologinuser1', 'wrongpassword', users.AP_WEBADMIN, {})); expect(error.reason).to.be(BoxError.INVALID_CREDENTIALS); const result = await users.list(); expect(result.find(function (u) { return u.username === 'autologinuser1'; })).to.be.ok(); }); it('succeeds for known user with correct password', async function () { ldapUsers.push({ username: 'autologinuser2', displayName: 'Auto Login2', email: 'auto2@login.com', password: LDAP_SHARED_PASSWORD }); await users.verifyWithUsername('autologinuser2', LDAP_SHARED_PASSWORD, users.AP_WEBADMIN, {}); const result = await users.list(); expect(result.find(function (u) { return u.username === 'autologinuser2'; })).to.be.ok(); }); }); });