2020-09-19 15:30:04 -04:00
|
|
|
const assert = require('assert').strict;
|
2020-08-25 17:04:34 -04:00
|
|
|
const express = require('express');
|
|
|
|
const log4js = require('log4js');
|
|
|
|
const httpLogger = log4js.getLogger('http');
|
|
|
|
const settings = require('../../utils/Settings');
|
|
|
|
const hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks');
|
|
|
|
const ueberStore = require('../../db/SessionStore');
|
|
|
|
const stats = require('ep_etherpad-lite/node/stats');
|
|
|
|
const sessionModule = require('express-session');
|
|
|
|
const cookieParser = require('cookie-parser');
|
2012-02-25 13:38:09 +01:00
|
|
|
|
2020-08-27 14:33:58 -04:00
|
|
|
hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead';
|
|
|
|
|
2020-09-11 19:26:26 -04:00
|
|
|
exports.normalizeAuthzLevel = (level) => {
|
|
|
|
if (!level) return false;
|
|
|
|
switch (level) {
|
|
|
|
case true:
|
|
|
|
return 'create';
|
2020-09-19 15:30:04 -04:00
|
|
|
case 'readOnly':
|
2020-09-11 19:46:47 -04:00
|
|
|
case 'modify':
|
2020-09-11 19:26:26 -04:00
|
|
|
case 'create':
|
|
|
|
return level;
|
|
|
|
default:
|
|
|
|
httpLogger.warn(`Unknown authorization level '${level}', denying access`);
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
};
|
|
|
|
|
2020-09-19 15:30:04 -04:00
|
|
|
exports.userCanModify = (padId, req) => {
|
|
|
|
if (!settings.requireAuthentication) return true;
|
|
|
|
const {session: {user} = {}} = req;
|
|
|
|
assert(user); // If authn required and user == null, the request should have already been denied.
|
|
|
|
assert(user.padAuthorizations); // This is populated even if !settings.requireAuthorization.
|
|
|
|
const level = exports.normalizeAuthzLevel(user.padAuthorizations[padId]);
|
|
|
|
assert(level); // If !level, the request should have already been denied.
|
|
|
|
return level !== 'readOnly';
|
|
|
|
};
|
|
|
|
|
2020-08-23 16:56:28 -04:00
|
|
|
// Exported so that tests can set this to 0 to avoid unnecessary test slowness.
|
|
|
|
exports.authnFailureDelayMs = 1000;
|
|
|
|
|
2020-08-28 01:49:26 -04:00
|
|
|
exports.checkAccess = (req, res, next) => {
|
2020-08-25 17:04:34 -04:00
|
|
|
const hookResultMangle = (cb) => {
|
2020-08-25 16:38:58 -04:00
|
|
|
return (err, data) => {
|
2012-04-19 16:04:03 +02:00
|
|
|
return cb(!err && data.length && data[0]);
|
2020-08-25 16:44:44 -04:00
|
|
|
};
|
|
|
|
};
|
2012-04-19 16:04:03 +02:00
|
|
|
|
2020-08-23 16:56:28 -04:00
|
|
|
const requireAdmin = req.path.toLowerCase().indexOf('/admin') === 0;
|
|
|
|
|
2020-08-28 21:03:45 -04:00
|
|
|
// This may be called twice per access: once before authentication is checked and once after (if
|
|
|
|
// settings.requireAuthorization is true).
|
2020-09-12 17:23:48 -04:00
|
|
|
const authorize = (fail) => {
|
2020-09-11 19:26:26 -04:00
|
|
|
const grant = (level) => {
|
|
|
|
level = exports.normalizeAuthzLevel(level);
|
|
|
|
if (!level) return fail();
|
|
|
|
const user = req.session.user;
|
|
|
|
if (user == null) return next(); // This will happen if authentication is not required.
|
2020-09-24 13:59:07 -04:00
|
|
|
const encodedPadId = (req.path.match(/^\/p\/(.*)$/) || [])[1];
|
|
|
|
if (encodedPadId == null) return next();
|
|
|
|
const padId = decodeURIComponent(encodedPadId);
|
2020-09-11 19:26:26 -04:00
|
|
|
// 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.
|
|
|
|
if (user.padAuthorizations == null) user.padAuthorizations = {};
|
2020-09-24 13:59:07 -04:00
|
|
|
user.padAuthorizations[padId] = level;
|
2020-09-11 19:26:26 -04:00
|
|
|
return next();
|
|
|
|
};
|
2020-08-23 16:56:28 -04:00
|
|
|
const isAuthenticated = req.session && req.session.user;
|
|
|
|
if (isAuthenticated && req.session.user.is_admin) return grant('create');
|
|
|
|
const requireAuthn = requireAdmin || settings.requireAuthentication;
|
|
|
|
if (!requireAuthn) return grant('create');
|
|
|
|
if (!isAuthenticated) return grant(false);
|
|
|
|
if (requireAdmin && !req.session.user.is_admin) return grant(false);
|
|
|
|
if (!settings.requireAuthorization) return grant('create');
|
2020-09-11 19:26:26 -04:00
|
|
|
hooks.aCallFirst('authorize', {req, res, next, resource: req.path}, hookResultMangle(grant));
|
2020-08-25 16:44:44 -04:00
|
|
|
};
|
2012-04-19 14:25:12 +02:00
|
|
|
|
2020-08-23 16:56:28 -04:00
|
|
|
// Access checking is done in four steps:
|
2020-08-28 21:03:45 -04:00
|
|
|
//
|
2020-08-23 16:56:28 -04:00
|
|
|
// 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,
|
2020-08-28 21:03:45 -04:00
|
|
|
// or maybe different credentials are required), go to the next step.
|
2020-08-23 16:56:28 -04:00
|
|
|
// 3) Try to authenticate. (Or, if already logged in, reauthenticate with different credentials if
|
2020-08-28 21:03:45 -04:00
|
|
|
// supported by the authn scheme.) If authentication fails, give the user a 401 error to
|
|
|
|
// request new credentials. Otherwise, go to the next step.
|
2020-08-23 16:56:28 -04:00
|
|
|
// 4) Try to access the thing again. If this fails, give the user a 403 error.
|
2020-08-28 21:03:45 -04:00
|
|
|
//
|
|
|
|
// Plugins can use the 'next' callback (from the hook's context) to break out at any point (e.g.,
|
2020-08-23 16:56:28 -04:00
|
|
|
// 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;
|
|
|
|
|
|
|
|
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);
|
|
|
|
};
|
2020-08-28 21:03:45 -04:00
|
|
|
|
2020-08-23 16:56:28 -04:00
|
|
|
step2PreAuthenticate = () => authorize(step3Authenticate);
|
2012-04-19 14:25:12 +02:00
|
|
|
|
2020-08-23 16:56:28 -04:00
|
|
|
step3Authenticate = () => {
|
2020-08-27 01:00:36 -04:00
|
|
|
if (settings.users == null) settings.users = {};
|
|
|
|
const ctx = {req, res, users: settings.users, next};
|
2020-08-28 21:03:45 -04:00
|
|
|
// If the HTTP basic auth header is present, extract the username and password so it can be
|
|
|
|
// given to authn plugins.
|
|
|
|
const httpBasicAuth =
|
|
|
|
req.headers.authorization && req.headers.authorization.search('Basic ') === 0;
|
|
|
|
if (httpBasicAuth) {
|
|
|
|
const userpass =
|
|
|
|
Buffer.from(req.headers.authorization.split(' ')[1], 'base64').toString().split(':');
|
|
|
|
ctx.username = userpass.shift();
|
|
|
|
ctx.password = userpass.join(':');
|
|
|
|
}
|
|
|
|
hooks.aCallFirst('authenticate', ctx, hookResultMangle((ok) => {
|
|
|
|
if (!ok) {
|
2020-08-27 21:41:31 -04:00
|
|
|
// Fall back to HTTP basic auth.
|
|
|
|
if (!httpBasicAuth || !(ctx.username in settings.users) ||
|
|
|
|
settings.users[ctx.username].password !== ctx.password) {
|
|
|
|
httpLogger.info(`Failed authentication from IP ${req.ip}`);
|
2020-08-27 14:33:58 -04:00
|
|
|
return hooks.aCallFirst('authnFailure', {req, res}, hookResultMangle((ok) => {
|
2020-08-26 22:08:07 -04:00
|
|
|
if (ok) return;
|
2020-08-27 14:33:58 -04:00
|
|
|
return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => {
|
|
|
|
if (ok) return;
|
|
|
|
// No plugin handled the authentication failure. Fall back to basic authentication.
|
|
|
|
res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
|
|
|
|
// Delay the error response for 1s to slow down brute force attacks.
|
|
|
|
setTimeout(() => {
|
|
|
|
res.status(401).send('Authentication Required');
|
2020-08-23 16:56:28 -04:00
|
|
|
}, exports.authnFailureDelayMs);
|
2020-08-27 14:33:58 -04:00
|
|
|
}));
|
2020-08-26 22:08:07 -04:00
|
|
|
}));
|
2020-08-28 21:03:45 -04:00
|
|
|
}
|
|
|
|
settings.users[ctx.username].username = ctx.username;
|
|
|
|
req.session.user = settings.users[ctx.username];
|
|
|
|
}
|
2020-08-27 14:28:14 -04:00
|
|
|
if (req.session.user == null) {
|
|
|
|
httpLogger.error('authenticate hook failed to add user settings to session');
|
|
|
|
res.status(500).send('Internal Server Error');
|
|
|
|
return;
|
|
|
|
}
|
2020-08-27 21:41:31 -04:00
|
|
|
let username = req.session.user.username;
|
|
|
|
username = (username != null) ? username : '<no username>';
|
|
|
|
httpLogger.info(`Successful authentication from IP ${req.ip} for username ${username}`);
|
2020-08-23 16:56:28 -04:00
|
|
|
step4Authorize();
|
2020-08-28 21:03:45 -04:00
|
|
|
}));
|
|
|
|
};
|
2015-05-07 18:14:55 +01:00
|
|
|
|
2020-08-23 16:56:28 -04:00
|
|
|
step4Authorize = () => authorize(() => {
|
2020-08-27 14:33:58 -04:00
|
|
|
return hooks.aCallFirst('authzFailure', {req, res}, hookResultMangle((ok) => {
|
2020-08-26 22:08:07 -04:00
|
|
|
if (ok) return;
|
2020-08-27 14:33:58 -04:00
|
|
|
return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => {
|
|
|
|
if (ok) return;
|
|
|
|
// No plugin handled the authorization failure.
|
|
|
|
res.status(403).send('Forbidden');
|
|
|
|
}));
|
2020-08-26 22:08:07 -04:00
|
|
|
}));
|
|
|
|
});
|
2020-08-28 21:03:45 -04:00
|
|
|
|
2020-08-23 16:56:28 -04:00
|
|
|
step1PreAuthorize();
|
2020-08-25 16:44:44 -04:00
|
|
|
};
|
2012-02-25 13:38:09 +01:00
|
|
|
|
2012-09-22 16:03:40 +02:00
|
|
|
exports.secret = null;
|
2012-07-03 23:30:40 +02:00
|
|
|
|
2020-08-25 16:38:58 -04:00
|
|
|
exports.expressConfigure = (hook_name, args, cb) => {
|
2013-10-27 17:42:55 +01:00
|
|
|
// Measure response time
|
2020-08-25 16:38:58 -04:00
|
|
|
args.app.use((req, res, next) => {
|
2020-08-25 17:04:34 -04:00
|
|
|
const stopWatch = stats.timer('httpRequests').start();
|
|
|
|
const sendFn = res.send;
|
2020-08-25 16:38:58 -04:00
|
|
|
res.send = function() { // function, not arrow, due to use of 'arguments'
|
2020-08-25 16:44:44 -04:00
|
|
|
stopWatch.end();
|
|
|
|
sendFn.apply(res, arguments);
|
|
|
|
};
|
|
|
|
next();
|
|
|
|
});
|
2013-10-27 17:42:55 +01:00
|
|
|
|
2012-02-25 13:38:09 +01:00
|
|
|
// If the log level specified in the config file is WARN or ERROR the application server never starts listening to requests as reported in issue #158.
|
|
|
|
// Not installing the log4js connect logger when the log level has a higher severity than INFO since it would not log at that level anyway.
|
2020-08-28 02:00:16 -04:00
|
|
|
if (!(settings.loglevel === 'WARN' || settings.loglevel === 'ERROR'))
|
2020-08-25 16:55:56 -04:00
|
|
|
args.app.use(log4js.connectLogger(httpLogger, {level: log4js.levels.DEBUG, format: ':status, :method :url'}));
|
2012-04-19 14:25:12 +02:00
|
|
|
|
|
|
|
/* Do not let express create the session, so that we can retain a
|
|
|
|
* reference to it for socket.io to use. Also, set the key (cookie
|
|
|
|
* name) to a javascript identifier compatible string. Makes code
|
|
|
|
* handling it cleaner :) */
|
|
|
|
|
2012-07-03 23:30:40 +02:00
|
|
|
if (!exports.sessionStore) {
|
2013-02-13 01:33:22 +00:00
|
|
|
exports.sessionStore = new ueberStore();
|
2017-01-26 09:59:09 +01:00
|
|
|
exports.secret = settings.sessionKey;
|
2012-07-03 23:30:40 +02:00
|
|
|
}
|
|
|
|
|
2020-08-25 17:04:34 -04:00
|
|
|
const sameSite = settings.ssl ? 'Strict' : 'Lax';
|
2020-07-10 08:43:20 +01:00
|
|
|
|
2012-07-03 23:30:40 +02:00
|
|
|
args.app.sessionStore = exports.sessionStore;
|
2019-12-07 04:22:54 +01:00
|
|
|
args.app.use(sessionModule({
|
|
|
|
secret: exports.secret,
|
|
|
|
store: args.app.sessionStore,
|
2020-03-30 14:59:20 +00:00
|
|
|
resave: false,
|
2019-12-07 04:22:54 +01:00
|
|
|
saveUninitialized: true,
|
|
|
|
name: 'express_sid',
|
|
|
|
proxy: true,
|
|
|
|
cookie: {
|
2020-07-10 08:43:20 +01:00
|
|
|
/*
|
|
|
|
* Firefox started enforcing sameSite, see https://github.com/ether/etherpad-lite/issues/3989
|
|
|
|
* for details. In response we set it based on if SSL certs are set in Etherpad. Note that if
|
|
|
|
* You use Nginx or so for reverse proxy this may cause problems. Use Certificate pinning to remedy.
|
|
|
|
*/
|
|
|
|
sameSite: sameSite,
|
2019-12-07 04:36:01 +01:00
|
|
|
/*
|
|
|
|
* The automatic express-session mechanism for determining if the
|
|
|
|
* application is being served over ssl is similar to the one used for
|
|
|
|
* setting the language cookie, which check if one of these conditions is
|
|
|
|
* true:
|
|
|
|
*
|
|
|
|
* 1. we are directly serving the nodejs application over SSL, using the
|
|
|
|
* "ssl" options in settings.json
|
|
|
|
*
|
|
|
|
* 2. we are serving the nodejs application in plaintext, but we are using
|
|
|
|
* a reverse proxy that terminates SSL for us. In this case, the user
|
|
|
|
* has to set trustProxy = true in settings.json, and the information
|
|
|
|
* wheter the application is over SSL or not will be extracted from the
|
|
|
|
* X-Forwarded-Proto HTTP header
|
|
|
|
*
|
|
|
|
* Please note that this will not be compatible with applications being
|
|
|
|
* served over http and https at the same time.
|
|
|
|
*
|
|
|
|
* reference: https://github.com/expressjs/session/blob/v1.17.0/README.md#cookiesecure
|
|
|
|
*/
|
|
|
|
secure: 'auto',
|
2019-12-07 04:22:54 +01:00
|
|
|
}
|
|
|
|
}));
|
2012-04-19 14:25:12 +02:00
|
|
|
|
2015-05-07 18:14:55 +01:00
|
|
|
args.app.use(cookieParser(settings.sessionKey, {}));
|
|
|
|
|
2020-08-28 01:49:26 -04:00
|
|
|
args.app.use(exports.checkAccess);
|
2020-08-25 16:44:44 -04:00
|
|
|
};
|