src/app/core/user/auth/user-authentication.controller.spec.ts
import _ from 'lodash';
import { DateTime } from 'luxon';
import passport from 'passport';
import should from 'should';
import { assert, createSandbox } from 'sinon';
import * as userAuthenticationController from './user-authentication.controller';
import { config } from '../../../../dependencies';
import local from '../../../../lib/strategies/local';
import proxyPki from '../../../../lib/strategies/proxy-pki';
import { getResponseSpy } from '../../../../spec/helpers';
import {
BadRequestError,
ForbiddenError,
UnauthorizedError
} from '../../../common/errors';
import {
CacheEntry,
ICacheEntry
} from '../../access-checker/cache/cache-entry.model';
import { IUser, User } from '../user.model';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const emptyFn = () => {};
/**
* Helpers
*/
function clearDatabase() {
return Promise.all([
User.deleteMany({}).exec(),
CacheEntry.deleteMany({}).exec()
]);
}
function userSpec(key): Partial<IUser> {
return {
name: `${key} Name`,
email: `${key}@mail.com`,
username: `${key}_username`,
organization: `${key} Organization`
};
}
function localUserSpec(key) {
const spec = userSpec(key);
spec.provider = 'local';
spec.password = 'password';
return spec;
}
function proxyPkiUserSpec(key) {
const spec = userSpec(key);
spec.provider = 'proxy-pki';
spec.providerData = {
dn: key,
dnLower: key.toLowerCase()
};
return spec;
}
function cacheSpec(key): Partial<ICacheEntry> {
return {
key: key.toLowerCase(),
value: {
name: `${key} Name`,
organization: `${key} Organization`,
email: `${key}@mail.com`,
username: `${key}_username`
}
};
}
/**
* Unit tests
*/
describe('User Auth Controller:', () => {
let res;
let sandbox;
before(() => {
return clearDatabase();
});
after(() => {
return clearDatabase();
});
beforeEach(() => {
sandbox = createSandbox();
res = getResponseSpy();
});
afterEach(() => {
sandbox.restore();
});
describe('signout', () => {
it('should successfully redirect after logout', () => {
const req = {
logout: (cb: () => void) => {
if (cb) {
return cb();
}
}
};
userAuthenticationController.signout(req, res);
assert.calledWith(res.redirect, '/');
});
});
describe("'local' Strategy", () => {
const spec = { user: localUserSpec('user1') };
let user;
beforeEach(async () => {
await clearDatabase();
user = await new User(spec.user).save();
//setup to use local passport
const configGetStub = sandbox.stub(config, 'get');
configGetStub.withArgs('auth.strategy').returns('local');
configGetStub.callThrough();
passport.use(local);
});
afterEach(() => {
return clearDatabase();
});
describe('login', () => {
it('should succeed with correct credentials', async () => {
const req: Record<string, unknown> = {};
req.body = {
username: spec.user.username,
password: spec.user.password
};
req.headers = {};
req.logIn = (u, cb) => {
return cb && cb();
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [result] = res.json.getCall(0).args;
// Should return the user
should.exist(result);
should(result.username).equal(user.username);
should(result.name).equal(user.name);
// The user's password should have been removed
should.not.exist(result.password);
});
it('should fail with incorrect password', async () => {
const req: Record<string, unknown> = {};
req.body = { username: user.username, password: 'wrong' };
req.headers = {};
req.logIn = (u, cb) => {
return cb && cb();
};
await userAuthenticationController
.signin(req, res, emptyFn)
.should.be.rejectedWith(
new UnauthorizedError('Incorrect username or password')
);
assert.notCalled(res.status);
assert.notCalled(res.json);
});
it('should fail with missing password', async () => {
const req: Record<string, unknown> = {};
req.body = { username: user.username, password: undefined };
req.headers = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
await userAuthenticationController
.signin(req, res, emptyFn)
.should.be.rejectedWith('Missing credentials');
assert.notCalled(res.status);
assert.notCalled(res.json);
});
it('should fail with missing username', async () => {
const req: Record<string, unknown> = {};
req.body = { username: undefined, password: 'asdfasdf' };
req.headers = {};
req.login = (_user, cb) => {
return cb && cb();
};
await userAuthenticationController
.signin(req, res, emptyFn)
.should.be.rejectedWith('Missing credentials');
assert.notCalled(res.status);
assert.notCalled(res.json);
});
it('should fail with unknown user', async () => {
const req: Record<string, unknown> = {};
req.body = { username: 'totally doesnt exist', password: 'asdfasdf' };
req.headers = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
await userAuthenticationController
.signin(req, res, emptyFn)
.should.be.rejectedWith(
new UnauthorizedError('Incorrect username or password')
);
assert.notCalled(res.status);
assert.notCalled(res.json);
});
}); // describe - login
});
describe('Proxy PKI Strategy', () => {
// Specs for tests
const spec = {
cache: {} as Record<string, Partial<ICacheEntry>>,
user: {} as Record<string, Partial<IUser>>
};
// Synced User/Cache Entry
spec.cache.synced = cacheSpec('synced');
spec.cache.synced.value.roles = ['role1', 'role2'];
spec.cache.synced.value.groups = ['group1', 'group2'];
spec.user.synced = proxyPkiUserSpec('synced');
spec.user.synced.externalRoles = ['role1', 'role2'];
spec.user.synced.externalGroups = ['group1', 'group2'];
// Different user metadata in cache
spec.cache.oldMd = cacheSpec('oldMd');
spec.user.oldMd = proxyPkiUserSpec('oldMd');
spec.cache.oldMd.value.name = 'New Name';
spec.cache.oldMd.value.organization = 'New Organization';
spec.cache.oldMd.value.email = 'new.email@mail.com';
// Different roles in cache
spec.cache.differentRolesAndGroups = cacheSpec('differentRoles');
spec.cache.differentRolesAndGroups.value.roles = ['role1', 'role2'];
spec.cache.differentRolesAndGroups.value.groups = ['group1', 'group2'];
spec.user.differentRolesAndGroups = proxyPkiUserSpec('differentRoles');
spec.user.differentRolesAndGroups.externalRoles = ['role3', 'role4'];
spec.user.differentRolesAndGroups.externalGroups = ['group3', 'group4'];
// Missing from cache, no bypass
spec.user.missingUser = proxyPkiUserSpec('missingUser');
spec.user.missingUser.externalRoles = ['role1', 'role2'];
spec.user.missingUser.externalGroups = ['group1', 'group2'];
// Expired in cache, no bypass
spec.user.expiredUser = proxyPkiUserSpec('expiredUser');
spec.cache.expiredUser = cacheSpec('expiredUser');
spec.cache.expiredUser.ts = DateTime.now().minus({ days: 2 }).toJSDate();
spec.user.expiredUser.externalRoles = ['role1', 'role2'];
spec.user.expiredUser.externalGroups = ['group1', 'group2'];
// Missing from cache, with bypass
spec.user.missingUserBypassed = proxyPkiUserSpec('missingUserBypassed');
spec.user.missingUserBypassed.bypassAccessCheck = true;
spec.user.missingUserBypassed.externalRoles = ['role1', 'role2'];
spec.user.missingUserBypassed.externalGroups = ['group1', 'group2'];
// Missing from cache, in access checker, with bypass with local changes
spec.user.userBypassed = proxyPkiUserSpec('userBypassed');
spec.user.userBypassed.bypassAccessCheck = true;
spec.user.userBypassed.name = 'My New Name';
spec.user.userBypassed.organization = 'My New Org';
// Only in cache
spec.cache.cacheOnly = cacheSpec('cacheOnly');
spec.cache.cacheOnly.value.roles = ['role1', 'role2', 'role3'];
spec.cache.cacheOnly.value.groups = ['group1', 'group2', 'group3'];
spec.user.userCanProxy = proxyPkiUserSpec('proxyableUser');
spec.user.userCanProxy.canProxy = true;
spec.user.userCanProxy.name = 'Trusted Server';
spec.user.userCanProxy.organization = 'Trusted Organization';
const cache = {};
const user = {};
beforeEach(async () => {
await clearDatabase();
let defers = [];
defers = defers.concat(
_.keys(spec.cache).map(async (k) => {
cache[k] = await new CacheEntry(spec.cache[k]).save();
})
);
defers = defers.concat(
_.keys(spec.user).map(async (k_1) => {
user[k_1] = await new User(spec.user[k_1]).save();
})
);
await Promise.all(defers);
const configGetStub = sandbox.stub(config, 'get');
configGetStub.withArgs('auth.strategy').returns('proxy-pki');
configGetStub
.withArgs('auth.accessChecker.provider.file')
.returns('src/app/core/access-checker/providers/example.provider');
configGetStub.withArgs('auth.accessChecker.provider.config').returns({
userbypassed: {
name: 'Invalid Name',
organization: 'Invalid Org',
email: 'invalid@invalid.org',
username: 'invalid'
}
});
configGetStub.callThrough();
// All of the data is loaded, so initialize proxy-pki
passport.use(proxyPki);
});
afterEach(() => {
return clearDatabase();
});
/**
* Test basic login where access checker isn't really involved.
* Granting access and denying access based on known/unknown dn
*/
describe('basic login', () => {
const req: Record<string, unknown> = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
it('should work when user is synced with access checker', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.synced.providerData.dn
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [result] = res.json.getCall(0).args;
should.exist(result);
should(result.name).equal(spec.user.synced.name);
should(result.organization).equal(spec.user.synced.organization);
should(result.email).equal(spec.user.synced.email);
should(result.username).equal(spec.user.synced.username);
should(result.externalRoles).be.an.Array();
should(result.externalRoles).have.length(
spec.user.synced.externalRoles.length
);
should(result.externalRoles).containDeep(
spec.user.synced.externalRoles
);
});
// No DN header
it('should fail when there is no dn', async () => {
req.headers = {};
await userAuthenticationController
.signin(req, res, emptyFn)
.should.be.rejectedWith(new BadRequestError('Missing certificate'));
assert.notCalled(res.status);
assert.notCalled(res.json);
});
// Unknown DN header
it('should fail when the dn is unknown and auto create is disabled', async () => {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const configGetStub = config.get as any;
configGetStub.withArgs('auth.autoCreateAccounts').returns(false);
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]: 'unknown'
};
await userAuthenticationController
.signin(req, {}, emptyFn)
.should.be.rejectedWith(
new UnauthorizedError(
'Could not authenticate request, please verify your credentials.'
)
);
});
});
/**
* Test situations where access checking is more involved because the cache
* is not in sync with the user
*/
describe('syncing with access checker', () => {
const req: Record<string, unknown> = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
it('should update the user info from access checker on login', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.oldMd.providerData.dn
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [result] = res.json.getCall(0).args;
should.exist(result);
should(result.name).equal(spec.cache.oldMd.value.name);
should(result.organization).equal(spec.cache.oldMd.value.organization);
should(result.email).equal(spec.cache.oldMd.value.email);
should(result.username).equal(spec.cache.oldMd.value.username);
});
it('should sync roles and groups from access checker on login', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.differentRolesAndGroups.providerData.dn
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [result] = res.json.getCall(0).args;
should.exist(result);
should(result.externalRoles).be.an.Array();
should(result.externalRoles).have.length(
(spec.cache.differentRolesAndGroups.value.roles as unknown[]).length
);
should(result.externalRoles).containDeep(
spec.cache.differentRolesAndGroups.value.roles
);
should(result.externalGroups).be.an.Array();
should(result.externalGroups).have.length(
(spec.cache.differentRolesAndGroups.value.groups as unknown[]).length
);
should(result.externalGroups).containDeep(
spec.cache.differentRolesAndGroups.value.groups
);
});
});
describe('missing or expired cache entries with no bypass', () => {
const req: Record<string, unknown> = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
it('should have external roles and groups removed on login when missing from cache', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.missingUser.providerData.dn
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [result] = res.json.getCall(0).args;
should.exist(result);
should(result.name).equal(spec.user.missingUser.name);
should(result.organization).equal(spec.user.missingUser.organization);
should(result.email).equal(spec.user.missingUser.email);
should(result.username).equal(spec.user.missingUser.username);
should(result.externalRoles).be.an.Array();
result.externalRoles.should.have.length(0);
should(result.externalGroups).be.an.Array();
result.externalGroups.should.have.length(0);
});
it('should have external roles and groups removed on login when cache expired', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.expiredUser.providerData.dn
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [result] = res.json.getCall(0).args;
should.exist(result);
should(result.name).equal(spec.user.expiredUser.name);
should(result.organization).equal(spec.user.expiredUser.organization);
should(result.email).equal(spec.user.expiredUser.email);
should(result.username).equal(spec.user.expiredUser.username);
should(result.externalRoles).be.an.Array();
result.externalRoles.should.have.length(0);
should(result.externalGroups).be.an.Array();
result.externalGroups.should.have.length(0);
});
});
describe('missing cache entries with bypass access checker enabled', () => {
const req: Record<string, unknown> = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
it('should preserve user info, roles and groups on login', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.missingUserBypassed.providerData.dn
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [info] = res.json.getCall(0).args;
should.exist(info);
should(info.name).equal(spec.user.missingUserBypassed.name);
should(info.organization).equal(
spec.user.missingUserBypassed.organization
);
should(info.email).equal(spec.user.missingUserBypassed.email);
should(info.username).equal(spec.user.missingUserBypassed.username);
should(info.externalRoles).be.an.Array();
should(info.externalRoles).have.length(
spec.user.missingUserBypassed.externalRoles.length
);
should(info.externalRoles).containDeep(
spec.user.missingUserBypassed.externalRoles
);
should(info.externalGroups).be.an.Array();
should(info.externalGroups).have.length(
spec.user.missingUserBypassed.externalGroups.length
);
should(info.externalGroups).containDeep(
spec.user.missingUserBypassed.externalGroups
);
});
});
describe('in cache, access checker enabled, but with fields modified locally', () => {
const req: Record<string, unknown> = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
it('should preserve user info, roles and groups on login', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.userBypassed.providerData.dn
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [result] = res.json.getCall(0).args;
should.exist(result);
should(result.name).equal(spec.user.userBypassed.name);
should(result.organization).equal(spec.user.userBypassed.organization);
should(result.email).equal(spec.user.userBypassed.email);
should(result.username).equal(spec.user.userBypassed.username);
should(result.externalRoles).be.an.Array();
should(result.externalRoles).have.length(0);
should(result.externalGroups).be.an.Array();
should(result.externalGroups).have.length(0);
});
});
describe('auto create accounts', () => {
const req: Record<string, unknown> = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
it('should create a new account from access checker information', async () => {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const configGetStub = config.get as any;
configGetStub.withArgs('auth.autoCreateAccounts').returns(true);
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.cache.cacheOnly.key
};
await userAuthenticationController.signin(req, res, () => {
assert.error('should not be called');
});
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
const [info] = res.json.getCall(0).args;
should.exist(info);
should(info.name).equal(spec.cache.cacheOnly.value.name);
should(info.organization).equal(
spec.cache.cacheOnly.value.organization
);
should(info.email).equal(spec.cache.cacheOnly.value.email);
should(info.username).equal(spec.cache.cacheOnly.value.username);
should(info.externalRoles).be.an.Array();
should(info.externalRoles).have.length(
(spec.cache.cacheOnly.value.roles as unknown[]).length
);
should(info.externalRoles).containDeep(
spec.cache.cacheOnly.value.roles
);
should(info.externalGroups).be.an.Array();
should(info.externalGroups).have.length(
(spec.cache.cacheOnly.value.groups as unknown[]).length
);
should(info.externalGroups).containDeep(
spec.cache.cacheOnly.value.groups
);
});
});
describe('proxy for other users', () => {
/**
* @type {any}
*/
let req;
beforeEach(() => {
req = {};
req.logIn = (_user, cb) => {
return cb && cb();
};
});
it('should fail when not authorized to proxy users', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.synced.providerData.dn,
[config.get<string>('proxyPkiProxiedUserHeader')]:
spec.user.userBypassed.providerData.dn
};
await userAuthenticationController
.signin(req, res, emptyFn)
.should.be.rejectedWith(
new ForbiddenError(
'Not approved to proxy users. Please verify your credentials.'
)
);
assert.notCalled(res.status);
assert.notCalled(res.json);
});
it('should succeed when authorized to proxy users', async () => {
req.headers = {
[config.get<string>('proxyPkiPrimaryUserHeader')]:
spec.user.userCanProxy.providerData.dn,
[config.get<string>('proxyPkiProxiedUserHeader')]:
spec.user.userBypassed.providerData.dn
};
await userAuthenticationController.signin(req, res, emptyFn);
assert.calledWith(res.status, 200);
assert.calledOnce(res.json);
// Verify that the user returned is the proxied user (not the primary user)
const [result] = res.json.getCall(0).args;
should.exist(result);
should(result.name).equal(spec.user.userBypassed.name);
should(result.organization).equal(spec.user.userBypassed.organization);
should(result.email).equal(spec.user.userBypassed.email);
should(result.username).equal(spec.user.userBypassed.username);
should(result.externalRoles).be.an.Array();
should(result.externalRoles).have.length(0);
should(result.externalGroups).be.an.Array();
should(result.externalGroups).have.length(0);
});
});
});
});