webaccess: Move pre-authn authz check to a separate hook

Before this change, the authorize hook was invoked twice: once before
authentication and again after (if settings.requireAuthorization is
true). Now pre-authentication authorization is instead handled by a
new preAuthorize hook, and the authorize hook is only invoked after
the user has authenticated.

Rationale: Without this change it is too easy to write an
authorization plugin that is too permissive. Specifically:

  * If the plugin does not check the path for /admin then a non-admin
    user might be able to access /admin pages.
  * If the plugin assumes that the user has already been authenticated
    by the time the authorize function is called then unauthenticated
    users might be able to gain access to restricted resources.

This change also avoids calling the plugin's authorize function twice
per access, which makes it easier for plugin authors to write an
authorization plugin that is easy to understand.

This change may break existing authorization plugins: After this
change, the authorize hook will no longer be able to authorize
non-admin access to /admin pages. This is intentional. Access to admin
pages should instead be controlled via the `is_admin` user setting,
which can be set in the config file or by an authentication plugin.

Also:
  * Add tests for the authenticate and authorize hooks.
  * Disable the authentication failure delay when testing.
This commit is contained in:
Richard Hansen 2020-08-23 16:56:28 -04:00 committed by John McLear
parent a51132d712
commit 304318b618
5 changed files with 422 additions and 76 deletions

View file

@ -212,6 +212,50 @@ Things in context:
I have no idea what this is useful for, someone else will have to add this description. I have no idea what this is useful for, someone else will have to add this description.
## preAuthorize
Called from: src/node/hooks/express/webaccess.js
Things in context:
1. req - the request object
2. res - the response object
3. next - bypass callback. If this is called instead of the normal callback then
all remaining access checks are skipped.
This hook is called for each HTTP request before any authentication checks are
performed. Example uses:
* Always grant access to static content.
* Process an OAuth callback.
* Drop requests from IP addresses that have failed N authentication checks
within the past X minutes.
A preAuthorize function is always called for each request unless a preAuthorize
function from another plugin (if any) has already explicitly granted or denied
the request.
You can pass the following values to the provided callback:
* `[]` defers the access decision to the normal authentication and authorization
checks (or to a preAuthorize function from another plugin, if one exists).
* `[true]` immediately grants access to the requested resource, unless the
request is for an `/admin` page in which case it is treated the same as `[]`.
(This prevents buggy plugins from accidentally granting admin access to the
general public.)
* `[false]` immediately denies the request. The preAuthnFailure hook will be
called to handle the failure.
Example:
```
exports.preAuthorize = (hookName, context, cb) => {
if (ipAddressIsFirewalled(context.req)) return cb([false]);
if (requestIsForStaticContent(context.req)) return cb([true]);
if (requestIsForOAuthCallback(context.req)) return cb([true]);
return cb([]);
};
```
## authorize ## authorize
Called from: src/node/hooks/express/webaccess.js Called from: src/node/hooks/express/webaccess.js
@ -225,47 +269,23 @@ Things in context:
This hook is called to handle authorization. It is especially useful for This hook is called to handle authorization. It is especially useful for
controlling access to specific paths. controlling access to specific paths.
A plugin's authorize function is typically called twice for each access: once A plugin's authorize function is only called if all of the following are true:
before authentication and again after. Specifically, it is called if all of the
following are true:
* The request is not for static content or an API endpoint. (Requests for static * The request is not for static content or an API endpoint. (Requests for static
content and API endpoints are always authorized, even if unauthenticated.) content and API endpoints are always authorized, even if unauthenticated.)
* Either authentication has not yet been performed (`context.req.session.user` * The `requireAuthentication` and `requireAuthorization` settings are both true.
is undefined) or the user has successfully authenticated * The user has already successfully authenticated.
(`context.req.session.user` is an object containing user-specific settings). * The user is not an admin (admin users are always authorized).
* If the user has successfully authenticated, the user is not an admin. (Admin * The path being accessed is not an `/admin` path (`/admin` paths can only be
users are always authorized.) accessed by admin users, and admin users are always authorized).
* Either the request is for an `/admin` page or the `requireAuthentication` * An authorize function from a different plugin has not already caused
setting is true. authorization to pass or fail.
* Either the request is for an `/admin` page, or the user has not yet
authenticated, or the user has authenticated and the `requireAuthorization`
setting is true.
* For pre-authentication invocations of a plugin's authorize function
(`context.req.session.user` is undefined), an authorize function from a
different plugin has not already caused the pre-authentication authorization
to pass or fail.
* For post-authentication invocations of a plugin's authorize function
(`context.req.session.user` is an object), an authorize function from a
different plugin has not already caused the post-authentication authorization
to pass or fail.
For pre-authentication invocations of your authorize function, you can pass the Note that the authorize hook cannot grant access to `/admin` pages. If admin
following values to the provided callback: access is desired, the `is_admin` user setting must be set to true. This can be
set in the settings file or by the authenticate hook.
* `[true]`, `['create']`, or `['modify']` will immediately grant access without You can pass the following values to the provided callback:
requiring the user to authenticate.
* `[false]` will trigger authentication unless authentication is not required.
* `[]` or `undefined` will defer the decision to the next authorization plugin
(if any, otherwise it is the same as calling with `[false]`).
**WARNING:** Your authorize function can be called for an `/admin` page even if
the user has not yet authenticated. It is your responsibility to fail or defer
authorization if you do not want to grant admin privileges to the general
public.
For post-authentication invocations of your authorize function, you can pass the
following values to the provided callback:
* `[true]` or `['create']` will grant access to modify or create the pad if the * `[true]` or `['create']` will grant access to modify or create the pad if the
request is for a pad, otherwise access is simply granted. (Access will be request is for a pad, otherwise access is simply granted. (Access will be
@ -281,11 +301,6 @@ Example:
``` ```
exports.authorize = (hookName, context, cb) => { exports.authorize = (hookName, context, cb) => {
const user = context.req.session.user; const user = context.req.session.user;
if (!user) {
// The user has not yet authenticated so defer the pre-authentication
// authorization decision to the next plugin.
return cb([]);
}
const path = context.req.path; // or context.resource const path = context.req.path; // or context.resource
if (isExplicitlyProhibited(user, path)) return cb([false]); if (isExplicitlyProhibited(user, path)) return cb([false]);
if (isExplicitlyAllowed(user, path)) return cb([true]); if (isExplicitlyAllowed(user, path)) return cb([true]);
@ -395,6 +410,35 @@ exports.authFailure = (hookName, context, cb) => {
}; };
``` ```
## preAuthzFailure
Called from: src/node/hooks/express/webaccess.js
Things in context:
1. req - the request object
2. res - the response object
This hook is called to handle a pre-authentication authorization failure.
A plugin's preAuthzFailure function is only called if the pre-authentication
authorization failure was not already handled by a preAuthzFailure function from
another plugin.
Calling the provided callback with `[true]` tells Etherpad that the failure was
handled and no further error handling is required. Calling the callback with
`[]` or `undefined` defers error handling to a preAuthzFailure function from
another plugin (if any, otherwise fall back to a generic 403 error page).
Example:
```
exports.preAuthzFailure = (hookName, context, cb) => {
if (notApplicableToThisPlugin(context)) return cb([]);
context.res.status(403).send(renderFancy403Page(context.req));
return cb([true]);
};
```
## authnFailure ## authnFailure
Called from: src/node/hooks/express/webaccess.js Called from: src/node/hooks/express/webaccess.js
@ -435,7 +479,7 @@ Things in context:
1. req - the request object 1. req - the request object
2. res - the response object 2. res - the response object
This hook is called to handle an authorization failure. This hook is called to handle a post-authentication authorization failure.
A plugin's authzFailure function is only called if the authorization failure was A plugin's authzFailure function is only called if the authorization failure was
not already handled by an authzFailure function from another plugin. not already handled by an authzFailure function from another plugin.

View file

@ -24,6 +24,9 @@ exports.normalizeAuthzLevel = (level) => {
return false; return false;
}; };
// Exported so that tests can set this to 0 to avoid unnecessary test slowness.
exports.authnFailureDelayMs = 1000;
exports.checkAccess = (req, res, next) => { exports.checkAccess = (req, res, next) => {
const hookResultMangle = (cb) => { const hookResultMangle = (cb) => {
return (err, data) => { return (err, data) => {
@ -31,12 +34,11 @@ exports.checkAccess = (req, res, next) => {
}; };
}; };
const requireAdmin = req.path.toLowerCase().indexOf('/admin') === 0;
// This may be called twice per access: once before authentication is checked and once after (if // This may be called twice per access: once before authentication is checked and once after (if
// settings.requireAuthorization is true). // settings.requireAuthorization is true).
const authorize = (fail) => { const authorize = (fail) => {
// Do not require auth for static paths and the API...this could be a bit brittle
if (req.path.match(/^\/(static|javascripts|pluginfw|api)/)) return next();
const grant = (level) => { const grant = (level) => {
level = exports.normalizeAuthzLevel(level); level = exports.normalizeAuthzLevel(level);
if (!level) return fail(); if (!level) return fail();
@ -51,35 +53,70 @@ exports.checkAccess = (req, res, next) => {
user.padAuthorizations[padId] = level; user.padAuthorizations[padId] = level;
return next(); return next();
}; };
const isAuthenticated = req.session && req.session.user;
if (req.path.toLowerCase().indexOf('/admin') !== 0) { if (isAuthenticated && req.session.user.is_admin) return grant('create');
if (!settings.requireAuthentication) return grant('create'); const requireAuthn = requireAdmin || settings.requireAuthentication;
if (!settings.requireAuthorization && req.session && req.session.user) return grant('create'); if (!requireAuthn) return grant('create');
} if (!isAuthenticated) return grant(false);
if (requireAdmin && !req.session.user.is_admin) return grant(false);
if (req.session && req.session.user && req.session.user.is_admin) return grant('create'); if (!settings.requireAuthorization) return grant('create');
hooks.aCallFirst('authorize', {req, res, next, resource: req.path}, hookResultMangle(grant)); hooks.aCallFirst('authorize', {req, res, next, resource: req.path}, hookResultMangle(grant));
}; };
// Access checking is done in three steps: // Access checking is done in four steps:
// //
// 1) Try to just access the thing. If access fails (perhaps authentication has not yet completed, // 1) Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin
// pages). If any plugin explicitly grants or denies access, skip the remaining steps.
// 2) Try to just access the thing. If access fails (perhaps authentication has not yet completed,
// or maybe different credentials are required), go to the next step. // or maybe different credentials are required), go to the next step.
// 2) Try to authenticate. (Or, if already logged in, reauthenticate with different credentials if // 3) Try to authenticate. (Or, if already logged in, reauthenticate with different credentials if
// supported by the authn scheme.) If authentication fails, give the user a 401 error to // supported by the authn scheme.) If authentication fails, give the user a 401 error to
// request new credentials. Otherwise, go to the next step. // request new credentials. Otherwise, go to the next step.
// 3) Try to access the thing again. If this fails, give the user a 403 error. // 4) Try to access the thing again. If this fails, give the user a 403 error.
// //
// Plugins can use the 'next' callback (from the hook's context) to break out at any point (e.g., // Plugins can use the 'next' callback (from the hook's context) to break out at any point (e.g.,
// to process an OAuth callback). Plugins can use the authnFailure and authzFailure hooks to // to process an OAuth callback). Plugins can use the preAuthzFailure, authnFailure, and
// override the default error handling behavior (e.g., to redirect to a login page). // authzFailure hooks to override the default error handling behavior (e.g., to redirect to a
// login page).
let step1PreAuthenticate, step2Authenticate, step3Authorize; let step1PreAuthorize, step2PreAuthenticate, step3Authenticate, step4Authorize;
step1PreAuthenticate = () => authorize(step2Authenticate); step1PreAuthorize = () => {
// This aCallFirst predicate will cause aCallFirst to call the hook functions one at a time
// until one of them returns a non-empty list, with an exception: If the request is for an
// /admin page, truthy entries are filtered out before checking to see whether the list is
// empty. This prevents plugin authors from accidentally granting admin privileges to the
// general public.
const predicate = (results) => (results != null &&
results.filter((x) => (!requireAdmin || !x)).length > 0);
hooks.aCallFirst('preAuthorize', {req, res, next}, (err, results) => {
if (err != null) {
httpLogger.error('Error in preAuthorize hook:', err);
return res.status(500).send('Internal Server Error');
}
// Do not require auth for static paths and the API...this could be a bit brittle
if (req.path.match(/^\/(static|javascripts|pluginfw|api)/)) results.push(true);
if (requireAdmin) {
// Filter out all 'true' entries to prevent plugin authors from accidentally granting admin
// privileges to the general public.
results = results.filter((x) => !x);
}
if (results.length > 0) {
// Access was explicitly granted or denied. If any value is false then access is denied.
if (results.every((x) => x)) return next();
return hooks.aCallFirst('preAuthzFailure', {req, res}, hookResultMangle((ok) => {
if (ok) return;
// No plugin handled the pre-authentication authorization failure.
res.status(403).send('Forbidden');
}));
}
step2PreAuthenticate();
}, predicate);
};
step2Authenticate = () => { step2PreAuthenticate = () => authorize(step3Authenticate);
step3Authenticate = () => {
if (settings.users == null) settings.users = {}; if (settings.users == null) settings.users = {};
const ctx = {req, res, users: settings.users, next}; const ctx = {req, res, users: settings.users, next};
// If the HTTP basic auth header is present, extract the username and password so it can be // If the HTTP basic auth header is present, extract the username and password so it can be
@ -107,7 +144,7 @@ exports.checkAccess = (req, res, next) => {
// Delay the error response for 1s to slow down brute force attacks. // Delay the error response for 1s to slow down brute force attacks.
setTimeout(() => { setTimeout(() => {
res.status(401).send('Authentication Required'); res.status(401).send('Authentication Required');
}, 1000); }, exports.authnFailureDelayMs);
})); }));
})); }));
} }
@ -122,11 +159,11 @@ exports.checkAccess = (req, res, next) => {
let username = req.session.user.username; let username = req.session.user.username;
username = (username != null) ? username : '<no username>'; username = (username != null) ? username : '<no username>';
httpLogger.info(`Successful authentication from IP ${req.ip} for username ${username}`); httpLogger.info(`Successful authentication from IP ${req.ip} for username ${username}`);
step3Authorize(); step4Authorize();
})); }));
}; };
step3Authorize = () => authorize(() => { step4Authorize = () => authorize(() => {
return hooks.aCallFirst('authzFailure', {req, res}, hookResultMangle((ok) => { return hooks.aCallFirst('authzFailure', {req, res}, hookResultMangle((ok) => {
if (ok) return; if (ok) return;
return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => { return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => {
@ -137,7 +174,7 @@ exports.checkAccess = (req, res, next) => {
})); }));
}); });
step1PreAuthenticate(); step1PreAuthorize();
}; };
exports.secret = null; exports.secret = null;

View file

@ -61,14 +61,15 @@ exports.syncMapFirst = function (lst, fn) {
return []; return [];
} }
exports.mapFirst = function (lst, fn, cb) { exports.mapFirst = function (lst, fn, cb, predicate) {
if (predicate == null) predicate = (x) => (x != null && x.length > 0);
var i = 0; var i = 0;
var next = function () { var next = function () {
if (i >= lst.length) return cb(null, []); if (i >= lst.length) return cb(null, []);
fn(lst[i++], function (err, result) { fn(lst[i++], function (err, result) {
if (err) return cb(err); if (err) return cb(err);
if (result.length) return cb(null, result); if (predicate(result)) return cb(null, result);
next(); next();
}); });
} }
@ -142,7 +143,7 @@ exports.callFirst = function (hook_name, args) {
}); });
} }
function aCallFirst(hook_name, args, cb) { function aCallFirst(hook_name, args, cb, predicate) {
if (!args) args = {}; if (!args) args = {};
if (!cb) cb = function () {}; if (!cb) cb = function () {};
if (pluginDefs.hooks[hook_name] === undefined) return cb(null, []); if (pluginDefs.hooks[hook_name] === undefined) return cb(null, []);
@ -151,20 +152,21 @@ function aCallFirst(hook_name, args, cb) {
function (hook, cb) { function (hook, cb) {
hookCallWrapper(hook, hook_name, args, function (res) { cb(null, res); }); hookCallWrapper(hook, hook_name, args, function (res) { cb(null, res); });
}, },
cb cb,
predicate
); );
} }
/* return a Promise if cb is not supplied */ /* return a Promise if cb is not supplied */
exports.aCallFirst = function (hook_name, args, cb) { exports.aCallFirst = function (hook_name, args, cb, predicate) {
if (cb === undefined) { if (cb === undefined) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
aCallFirst(hook_name, args, function(err, res) { aCallFirst(hook_name, args, function(err, res) {
return err ? reject(err) : resolve(res); return err ? reject(err) : resolve(res);
}); }, predicate);
}); });
} else { } else {
return aCallFirst(hook_name, args, cb); return aCallFirst(hook_name, args, cb, predicate);
} }
} }

View file

@ -9,12 +9,16 @@ const server = require(m('node/server'));
const setCookieParser = require(m('node_modules/set-cookie-parser')); const setCookieParser = require(m('node_modules/set-cookie-parser'));
const settings = require(m('node/utils/Settings')); const settings = require(m('node/utils/Settings'));
const supertest = require(m('node_modules/supertest')); const supertest = require(m('node_modules/supertest'));
const webaccess = require(m('node/hooks/express/webaccess'));
const logger = log4js.getLogger('test'); const logger = log4js.getLogger('test');
let agent; let agent;
let baseUrl; let baseUrl;
let authnFailureDelayMsBackup;
before(async function() { before(async function() {
authnFailureDelayMsBackup = webaccess.authnFailureDelayMs;
webaccess.authnFailureDelayMs = 0; // Speed up tests.
settings.port = 0; settings.port = 0;
settings.ip = 'localhost'; settings.ip = 'localhost';
const httpServer = await server.start(); const httpServer = await server.start();
@ -24,6 +28,7 @@ before(async function() {
}); });
after(async function() { after(async function() {
webaccess.authnFailureDelayMs = authnFailureDelayMsBackup;
await server.stop(); await server.stop();
}); });
@ -135,7 +140,6 @@ describe('socket.io access checks', function() {
authorize = () => true; authorize = () => true;
authorizeHooksBackup = plugins.hooks.authorize; authorizeHooksBackup = plugins.hooks.authorize;
plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => { plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => {
if (req.session.user == null) return cb([]); // Hasn't authenticated yet.
return cb([authorize(req)]); return cb([authorize(req)]);
}}]; }}];
await cleanUpPads(); await cleanUpPads();

View file

@ -6,11 +6,15 @@ const plugins = require(m('static/js/pluginfw/plugin_defs'));
const server = require(m('node/server')); const server = require(m('node/server'));
const settings = require(m('node/utils/Settings')); const settings = require(m('node/utils/Settings'));
const supertest = require(m('node_modules/supertest')); const supertest = require(m('node_modules/supertest'));
const webaccess = require(m('node/hooks/express/webaccess'));
let agent; let agent;
const logger = log4js.getLogger('test'); const logger = log4js.getLogger('test');
let authnFailureDelayMsBackup;
before(async function() { before(async function() {
authnFailureDelayMsBackup = webaccess.authnFailureDelayMs;
webaccess.authnFailureDelayMs = 0; // Speed up tests.
settings.port = 0; settings.port = 0;
settings.ip = 'localhost'; settings.ip = 'localhost';
const httpServer = await server.start(); const httpServer = await server.start();
@ -20,10 +24,11 @@ before(async function() {
}); });
after(async function() { after(async function() {
webaccess.authnFailureDelayMs = authnFailureDelayMsBackup;
await server.stop(); await server.stop();
}); });
describe('webaccess without any plugins', function() { describe('webaccess: without plugins', function() {
const backup = {}; const backup = {};
before(async function() { before(async function() {
@ -95,7 +100,261 @@ describe('webaccess without any plugins', function() {
}); });
}); });
describe('webaccess with authnFailure, authzFailure, authFailure hooks', function() { describe('webaccess: preAuthorize, authenticate, and authorize hooks', function() {
let callOrder;
const Handler = class {
constructor(hookName, suffix) {
this.called = false;
this.hookName = hookName;
this.innerHandle = () => [];
this.id = hookName + suffix;
this.checkContext = () => {};
}
handle(hookName, context, cb) {
assert.equal(hookName, this.hookName);
assert(context != null);
assert(context.req != null);
assert(context.res != null);
assert(context.next != null);
this.checkContext(context);
assert(!this.called);
this.called = true;
callOrder.push(this.id);
return cb(this.innerHandle(context.req));
}
};
const handlers = {};
const hookNames = ['preAuthorize', 'authenticate', 'authorize'];
const hooksBackup = {};
const settingsBackup = {};
beforeEach(async function() {
callOrder = [];
hookNames.forEach((hookName) => {
// Create two handlers for each hook to test deferral to the next function.
const h0 = new Handler(hookName, '_0');
const h1 = new Handler(hookName, '_1');
handlers[hookName] = [h0, h1];
hooksBackup[hookName] = plugins.hooks[hookName] || [];
plugins.hooks[hookName] = [{hook_fn: h0.handle.bind(h0)}, {hook_fn: h1.handle.bind(h1)}];
});
hooksBackup.preAuthzFailure = plugins.hooks.preAuthzFailure || [];
Object.assign(settingsBackup, settings);
settings.users = {
admin: {password: 'admin-password', is_admin: true},
user: {password: 'user-password'},
};
});
afterEach(async function() {
Object.assign(plugins.hooks, hooksBackup);
Object.assign(settings, settingsBackup);
});
describe('preAuthorize', function() {
beforeEach(async function() {
settings.requireAuthentication = false;
settings.requireAuthorization = false;
});
it('defers if it returns []', async function() {
await agent.get('/').expect(200);
// Note: The preAuthorize hook always runs even if requireAuthorization is false.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('bypasses authenticate and authorize hooks when true is returned', async function() {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('bypasses authenticate and authorize hooks when false is returned', async function() {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('bypasses authenticate and authorize hooks for static content, defers', async function() {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/static/robots.txt').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('cannot grant access to /admin', async function() {
handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/admin/').expect(401);
// Notes:
// * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because
// 'true' entries are ignored for /admin/* requests.
// * The authenticate hook always runs for /admin/* requests even if
// settings.requireAuthentication is false.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('can deny access to /admin', async function() {
handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/admin/').auth('admin', 'admin-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('runs preAuthzFailure hook when access is denied', async function() {
handlers.preAuthorize[0].innerHandle = () => [false];
let called = false;
plugins.hooks.preAuthzFailure = [{hook_fn: (hookName, {req, res}, cb) => {
assert.equal(hookName, 'preAuthzFailure');
assert(req != null);
assert(res != null);
assert(!called);
called = true;
res.status(200).send('injected');
return cb([true]);
}}];
await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected');
assert(called);
});
it('returns 500 if an exception is thrown', async function() {
handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').expect(500);
});
});
describe('authenticate', function() {
beforeEach(async function() {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
});
it('is not called if !requireAuthentication and not /admin/*', async function() {
settings.requireAuthentication = false;
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('is called if !requireAuthentication and /admin/*', async function() {
settings.requireAuthentication = false;
await agent.get('/admin/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('defers if empty list returned', async function() {
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('does not defer if return [true], 200', async function() {
handlers.authenticate[0].innerHandle = (req) => { req.session.user = {}; return [true]; };
await agent.get('/').expect(200);
// Note: authenticate_1 was not called because authenticate_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('does not defer if return [false], 401', async function() {
handlers.authenticate[0].innerHandle = (req) => [false];
await agent.get('/').expect(401);
// Note: authenticate_1 was not called because authenticate_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('falls back to HTTP basic auth', async function() {
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('passes settings.users in context', async function() {
handlers.authenticate[0].checkContext = ({users}) => {
assert.equal(users, settings.users);
};
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('passes user, password in context if provided', async function() {
handlers.authenticate[0].checkContext = ({username, password}) => {
assert.equal(username, 'user');
assert.equal(password, 'user-password');
};
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('does not pass user, password in context if not provided', async function() {
handlers.authenticate[0].checkContext = ({username, password}) => {
assert(username == null);
assert(password == null);
};
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('errors if req.session.user is not created', async function() {
handlers.authenticate[0].innerHandle = () => [true];
await agent.get('/').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('returns 500 if an exception is thrown', async function() {
handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
});
describe('authorize', function() {
beforeEach(async function() {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
it('is not called if !requireAuthorization (non-/admin)', async function() {
settings.requireAuthorization = false;
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('is not called if !requireAuthorization (/admin)', async function() {
settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1']);
});
it('defers if empty list returned', async function() {
await agent.get('/').auth('user', 'user-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1',
'authorize_0', 'authorize_1']);
});
it('does not defer if return [true], 200', async function() {
handlers.authorize[0].innerHandle = () => [true];
await agent.get('/').auth('user', 'user-password').expect(200);
// Note: authorize_1 was not called because authorize_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1',
'authorize_0']);
});
it('does not defer if return [false], 403', async function() {
handlers.authorize[0].innerHandle = (req) => [false];
await agent.get('/').auth('user', 'user-password').expect(403);
// Note: authorize_1 was not called because authorize_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1',
'authorize_0']);
});
it('passes req.path in context', async function() {
handlers.authorize[0].checkContext = ({resource}) => {
assert.equal(resource, '/');
};
await agent.get('/').auth('user', 'user-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1',
'authorize_0', 'authorize_1']);
});
it('returns 500 if an exception is thrown', async function() {
handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').auth('user', 'user-password').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1',
'authenticate_0', 'authenticate_1',
'authorize_0']);
});
});
});
describe('webaccess: authnFailure, authzFailure, authFailure hooks', function() {
const Handler = class { const Handler = class {
constructor(hookName) { constructor(hookName) {
this.hookName = hookName; this.hookName = hookName;