mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-04-22 08:26:16 -04:00
webaccess: Asyncify checkAccess
This commit is contained in:
parent
a803f570e0
commit
867fdbd3f9
1 changed files with 108 additions and 123 deletions
|
@ -48,32 +48,33 @@ exports.userCanModify = (padId, req) => {
|
||||||
// Exported so that tests can set this to 0 to avoid unnecessary test slowness.
|
// Exported so that tests can set this to 0 to avoid unnecessary test slowness.
|
||||||
exports.authnFailureDelayMs = 1000;
|
exports.authnFailureDelayMs = 1000;
|
||||||
|
|
||||||
const checkAccess = (req, res, next) => {
|
const checkAccess = async (req, res, next) => {
|
||||||
const hookResultMangle = (cb) => {
|
// Promisified wrapper around hooks.aCallFirst.
|
||||||
return (err, data) => {
|
const aCallFirst = (hookName, context, pred = null) => new Promise((resolve, reject) => {
|
||||||
if (err != null) httpLogger.error(`Error during access check: ${err}`);
|
hooks.aCallFirst(hookName, context, (err, r) => err != null ? reject(err) : resolve(r), pred);
|
||||||
return cb(!err && data.length && data[0]);
|
});
|
||||||
};
|
|
||||||
};
|
const aCallFirst0 =
|
||||||
|
async (hookName, context, pred = null) => (await aCallFirst(hookName, context, pred))[0];
|
||||||
|
|
||||||
const requireAdmin = req.path.toLowerCase().indexOf('/admin') === 0;
|
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 helper is used in steps 2 and 4 below, so it may be called twice per access: once before
|
||||||
// settings.requireAuthorization is true).
|
// authentication is checked and once after (if settings.requireAuthorization is true).
|
||||||
const authorize = (fail) => {
|
const authorize = async () => {
|
||||||
const grant = (level) => {
|
const grant = (level) => {
|
||||||
level = exports.normalizeAuthzLevel(level);
|
level = exports.normalizeAuthzLevel(level);
|
||||||
if (!level) return fail();
|
if (!level) return false;
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user == null) return next(); // This will happen if authentication is not required.
|
if (user == null) return true; // This will happen if authentication is not required.
|
||||||
const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1];
|
const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1];
|
||||||
if (encodedPadId == null) return next();
|
if (encodedPadId == null) return true;
|
||||||
const padId = decodeURIComponent(encodedPadId);
|
const padId = decodeURIComponent(encodedPadId);
|
||||||
// The user was granted access to a pad. Remember the authorization level in the user's
|
// The user was granted access to a pad. Remember the authorization level in the user's
|
||||||
// settings so that SecurityManager can approve or deny specific actions.
|
// settings so that SecurityManager can approve or deny specific actions.
|
||||||
if (user.padAuthorizations == null) user.padAuthorizations = {};
|
if (user.padAuthorizations == null) user.padAuthorizations = {};
|
||||||
user.padAuthorizations[padId] = level;
|
user.padAuthorizations[padId] = level;
|
||||||
return next();
|
return true;
|
||||||
};
|
};
|
||||||
const isAuthenticated = req.session && req.session.user;
|
const isAuthenticated = req.session && req.session.user;
|
||||||
if (isAuthenticated && req.session.user.is_admin) return grant('create');
|
if (isAuthenticated && req.session.user.is_admin) return grant('create');
|
||||||
|
@ -82,41 +83,29 @@ const checkAccess = (req, res, next) => {
|
||||||
if (!isAuthenticated) return grant(false);
|
if (!isAuthenticated) return grant(false);
|
||||||
if (requireAdmin && !req.session.user.is_admin) return grant(false);
|
if (requireAdmin && !req.session.user.is_admin) return grant(false);
|
||||||
if (!settings.requireAuthorization) return grant('create');
|
if (!settings.requireAuthorization) return grant('create');
|
||||||
hooks.aCallFirst('authorize', {req, res, next, resource: req.path}, hookResultMangle(grant));
|
return grant(await aCallFirst0('authorize', {req, res, next, resource: req.path}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Access checking is done in four steps:
|
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
//
|
// Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin
|
||||||
// 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. Plugins can
|
||||||
// pages). If any plugin explicitly grants or denies access, skip the remaining steps.
|
// use the preAuthzFailure hook to override the default 403 error.
|
||||||
// 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.
|
|
||||||
// 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
|
|
||||||
// request new credentials. Otherwise, go to the next step.
|
|
||||||
// 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.,
|
|
||||||
// to process an OAuth callback). Plugins can use the preAuthzFailure, authnFailure, and
|
|
||||||
// authzFailure hooks to override the default error handling behavior (e.g., to redirect to a
|
|
||||||
// login page).
|
|
||||||
|
|
||||||
let step1PreAuthorize, step2PreAuthenticate, step3Authenticate, step4Authorize;
|
let results;
|
||||||
|
try {
|
||||||
step1PreAuthorize = () => {
|
results = await aCallFirst('preAuthorize', {req, res, next},
|
||||||
// This aCallFirst predicate will cause aCallFirst to call the hook functions one at a time
|
// This predicate will cause aCallFirst to call the hook functions one at a time until one
|
||||||
// until one of them returns a non-empty list, with an exception: If the request is for an
|
// of them returns a non-empty list, with an exception: If the request is for an /admin
|
||||||
// /admin page, truthy entries are filtered out before checking to see whether the list is
|
// page, truthy entries are filtered out before checking to see whether the list is empty.
|
||||||
// empty. This prevents plugin authors from accidentally granting admin privileges to the
|
// This prevents plugin authors from accidentally granting admin privileges to the general
|
||||||
// general public.
|
// public.
|
||||||
const predicate = (results) => (results != null &&
|
(r) => (r != null && r.filter((x) => (!requireAdmin || !x)).length > 0));
|
||||||
results.filter((x) => (!requireAdmin || !x)).length > 0);
|
} catch (err) {
|
||||||
hooks.aCallFirst('preAuthorize', {req, res, next}, (err, results) => {
|
httpLogger.error(`Error in preAuthorize hook: ${err.stack || err.toString()}`);
|
||||||
if (err != null) {
|
|
||||||
httpLogger.error('Error in preAuthorize hook:', err);
|
|
||||||
return res.status(500).send('Internal Server Error');
|
return res.status(500).send('Internal Server Error');
|
||||||
}
|
}
|
||||||
if (req.path.match(staticPathsRE)) results.push(true);
|
if (staticPathsRE.test(req.path)) results.push(true);
|
||||||
if (requireAdmin) {
|
if (requireAdmin) {
|
||||||
// Filter out all 'true' entries to prevent plugin authors from accidentally granting admin
|
// Filter out all 'true' entries to prevent plugin authors from accidentally granting admin
|
||||||
// privileges to the general public.
|
// privileges to the general public.
|
||||||
|
@ -125,23 +114,30 @@ const checkAccess = (req, res, next) => {
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
// Access was explicitly granted or denied. If any value is false then access is denied.
|
// Access was explicitly granted or denied. If any value is false then access is denied.
|
||||||
if (results.every((x) => x)) return next();
|
if (results.every((x) => x)) return next();
|
||||||
return hooks.aCallFirst('preAuthzFailure', {req, res}, hookResultMangle((ok) => {
|
if (await aCallFirst0('preAuthzFailure', {req, res})) return;
|
||||||
if (ok) return;
|
|
||||||
// No plugin handled the pre-authentication authorization failure.
|
// No plugin handled the pre-authentication authorization failure.
|
||||||
res.status(403).send('Forbidden');
|
return res.status(403).send('Forbidden');
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
step2PreAuthenticate();
|
|
||||||
}, predicate);
|
|
||||||
};
|
|
||||||
|
|
||||||
step2PreAuthenticate = () => authorize(step3Authenticate);
|
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Step 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.
|
||||||
|
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
if (await authorize()) return next();
|
||||||
|
|
||||||
|
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Step 3: Authenticate the user. (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 request new credentials. Otherwise, go to the next step. Plugins can use the
|
||||||
|
// authnFailure hook to override the default error handling behavior (e.g., to redirect to a login
|
||||||
|
// page).
|
||||||
|
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
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 given
|
||||||
// given to authn plugins.
|
// to authn plugins.
|
||||||
const httpBasicAuth =
|
const httpBasicAuth =
|
||||||
req.headers.authorization && req.headers.authorization.search('Basic ') === 0;
|
req.headers.authorization && req.headers.authorization.search('Basic ') === 0;
|
||||||
if (httpBasicAuth) {
|
if (httpBasicAuth) {
|
||||||
|
@ -150,24 +146,19 @@ const checkAccess = (req, res, next) => {
|
||||||
ctx.username = userpass.shift();
|
ctx.username = userpass.shift();
|
||||||
ctx.password = userpass.join(':');
|
ctx.password = userpass.join(':');
|
||||||
}
|
}
|
||||||
hooks.aCallFirst('authenticate', ctx, hookResultMangle((ok) => {
|
if (!(await aCallFirst0('authenticate', ctx))) {
|
||||||
if (!ok) {
|
|
||||||
// Fall back to HTTP basic auth.
|
// Fall back to HTTP basic auth.
|
||||||
const {[ctx.username]: {password} = {}} = settings.users;
|
const {[ctx.username]: {password} = {}} = settings.users;
|
||||||
if (!httpBasicAuth || password == null || password !== ctx.password) {
|
if (!httpBasicAuth || password == null || password !== ctx.password) {
|
||||||
httpLogger.info(`Failed authentication from IP ${req.ip}`);
|
httpLogger.info(`Failed authentication from IP ${req.ip}`);
|
||||||
return hooks.aCallFirst('authnFailure', {req, res}, hookResultMangle((ok) => {
|
if (await aCallFirst0('authnFailure', {req, res})) return;
|
||||||
if (ok) return;
|
if (await aCallFirst0('authFailure', {req, res, next})) return;
|
||||||
return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => {
|
|
||||||
if (ok) return;
|
|
||||||
// No plugin handled the authentication failure. Fall back to basic authentication.
|
// No plugin handled the authentication failure. Fall back to basic authentication.
|
||||||
res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
|
res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
|
||||||
// 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(() => {
|
await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs));
|
||||||
res.status(401).send('Authentication Required');
|
res.status(401).send('Authentication Required');
|
||||||
}, exports.authnFailureDelayMs);
|
return;
|
||||||
}));
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
settings.users[ctx.username].username = ctx.username;
|
settings.users[ctx.username].username = ctx.username;
|
||||||
// Make a shallow copy so that the password property can be deleted (to prevent it from
|
// Make a shallow copy so that the password property can be deleted (to prevent it from
|
||||||
|
@ -177,31 +168,25 @@ const checkAccess = (req, res, next) => {
|
||||||
}
|
}
|
||||||
if (req.session.user == null) {
|
if (req.session.user == null) {
|
||||||
httpLogger.error('authenticate hook failed to add user settings to session');
|
httpLogger.error('authenticate hook failed to add user settings to session');
|
||||||
res.status(500).send('Internal Server Error');
|
return res.status(500).send('Internal Server Error');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let username = req.session.user.username;
|
const {username = '<no username>'} = req.session.user;
|
||||||
username = (username != null) ? username : '<no username>';
|
httpLogger.info(`Successful authentication from IP ${req.ip} for user ${username}`);
|
||||||
httpLogger.info(`Successful authentication from IP ${req.ip} for username ${username}`);
|
|
||||||
step4Authorize();
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
step4Authorize = () => authorize(() => {
|
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
return hooks.aCallFirst('authzFailure', {req, res}, hookResultMangle((ok) => {
|
// Step 4: Try to access the thing again. If this fails, give the user a 403 error. Plugins can
|
||||||
if (ok) return;
|
// use the authzFailure hook to override the default error handling behavior (e.g., to redirect to
|
||||||
return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => {
|
// a login page).
|
||||||
if (ok) return;
|
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
if (await authorize()) return next();
|
||||||
|
if (await aCallFirst0('authzFailure', {req, res})) return;
|
||||||
|
if (await aCallFirst0('authFailure', {req, res, next})) return;
|
||||||
// No plugin handled the authorization failure.
|
// No plugin handled the authorization failure.
|
||||||
res.status(403).send('Forbidden');
|
res.status(403).send('Forbidden');
|
||||||
}));
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
step1PreAuthorize();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.expressConfigure = (hookName, args, cb) => {
|
exports.expressConfigure = (hookName, args, cb) => {
|
||||||
args.app.use(checkAccess);
|
args.app.use((req, res, next) => { checkAccess(req, res, next).catch(next); });
|
||||||
return cb();
|
return cb();
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue