Files
cloudron-box/src/test/externalldap-test.js
Girish Ramakrishnan 7455490074 Fix tests
2024-02-28 16:02:42 +01:00

685 lines
25 KiB
JavaScript

/* 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) {
let 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(LDAP_BASE_DN, (req, res, next) => {
let 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) => {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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 () {
let 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, []);
});
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' });
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.getMembers(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.getMembers(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.getMembers(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 () {
let 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();
});
});
});