mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-01-31 19:02:59 +01:00
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:
parent
a51132d712
commit
304318b618
5 changed files with 422 additions and 76 deletions
|
@ -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.
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue