Newer
Older
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
Lloyd Hilaiel
committed
// an abstraction that implements all of the cookie handling, CSRF protection,
// etc of the wsapi. This module also routes request to the approriate handlers
// underneath wsapi/
//
// each handler under wsapi/ supports the following exports:
// exports.process - function(req, res) - process a request
// exports.writes_db - must be true if the processing causes a database write
// exports.method - either 'get' or 'post'
// exports.authed - whether the wsapi requires authentication
// exports.args - an array of arguments that should be verified
Austin King
committed
// exports.i18n - boolean, does this operation display user facing strings
Lloyd Hilaiel
committed
const
sessions = require('client-sessions'),
Zachary Carter
committed
express = require('express'),
Lloyd Hilaiel
committed
secrets = require('./secrets'),
config = require('./configuration'),
logger = require('./logging.js').logger,
httputils = require('./httputils.js'),
Lloyd Hilaiel
committed
forward = require('./http_forward.js').forward,
Lloyd Hilaiel
committed
url = require('url'),
fs = require('fs'),
path = require('path'),
validate = require('./validate'),
Zachary Carter
committed
statsd = require('./statsd'),
bcrypt = require('./bcrypt'),
i18n = require('./i18n'),
db = require('./db');
Austin King
committed
var abide = i18n.abide({
supported_languages: config.get('supported_languages'),
default_lang: config.get('default_lang'),
Lloyd Hilaiel
committed
translation_directory: config.get('translation_directory'),
disable_locale_check: config.get('disable_locale_check')
Austin King
committed
});
Lloyd Hilaiel
committed
const COOKIE_SECRET = secrets.hydrateSecret('browserid_cookie', config.get('var_path'));
Lloyd Hilaiel
committed
var COOKIE_KEY = 'browserid_state';
// to support testing of browserid, we'll add a hash fragment to the cookie name for
Lloyd Hilaiel
committed
// sites other than login.persona.org. This is to address a bug in IE, see issue #296
if (config.get('public_url').indexOf('https://login.persona.org') !== 0) {
Lloyd Hilaiel
committed
const crypto = require('crypto');
var hash = crypto.createHash('md5');
hash.update(config.get('public_url'));
COOKIE_KEY += "_" + hash.digest('hex').slice(0, 6);
}
const WSAPI_PREFIX = '/wsapi/';
Lloyd Hilaiel
committed
logger.info('session cookie name is: ' + COOKIE_KEY);
Lloyd Hilaiel
committed
function clearAuthenticatedUser(session) {
session.reset(['csrf']);
Lloyd Hilaiel
committed
}
function isAuthed(req, requiredLevel) {
if (req.session && req.session.userid && req.session.auth_level) {
// 'password' authentication allows access to all apis.
// 'assertion' authentication, grants access to only those apis
// that don't require 'password'
if (requiredLevel === 'assertion' || req.session.auth_level === 'password') {
return true;
}
}
return false;
Lloyd Hilaiel
committed
}
function bcryptPassword(password, cb) {
var startTime = new Date();
bcrypt.encrypt(config.get('bcrypt_work_factor'), password, function() {
var reqTime = new Date - startTime;
statsd.timing('bcrypt.encrypt_time', reqTime);
cb.apply(null, arguments);
Lloyd Hilaiel
committed
});
}
Lloyd Hilaiel
committed
function authenticateSession(options, cb) {
var session = options.session;
var uid = options.uid;
var level = options.level;
var duration_ms = options.duration_ms;
if (['assertion', 'password'].indexOf(level) === -1)
cb(new Error("invalid authentication level: " + level));
db.lastPasswordReset(uid, function(err, lastPasswordReset) {
if (err)
return cb(err);
if (lastPasswordReset === undefined)
return cb(new Error("authenticateSession called with undefined lastPasswordReset"));
// if the user is *already* authenticated as this uid with an equal or
// better level of auth, let's not lower them. Issue #1049
if (session.userid === uid && session.auth_level === 'password' &&
session.auth_level !== level) {
logger.info("not resetting cookies to 'assertion' authenticate a user who is already password authenticated");
} else {
if (duration_ms) {
session.setDuration(duration_ms);
}
session.userid = uid;
session.auth_level = level;
session.lastPasswordReset = lastPasswordReset;
Lloyd Hilaiel
committed
}
cb(null);
});
Lloyd Hilaiel
committed
}
function checkCSRF(req, resp, next) {
// only on POSTs
if (req.method !== "POST")
return next();
// there must be a session
if (req.session === undefined || typeof req.session.csrf !== 'string') {
logger.warn("POST calls to /wsapi require a cookie to be sent, this user may have cookies disabled");
return httputils.forbidden(resp, "no cookie");
}
// and the token must match what is sent in the post body
if (!req.body || !req.session || !req.session.csrf || req.body.csrf != req.session.csrf) {
// if any of these things are false, then we'll block the request
var b = req.body ? req.body.csrf : "<none>";
var s = req.session ? req.session.csrf : "<none>";
logger.warn("CSRF validation failure, token mismatch. got:" + b + " want:" + s);
return httputils.badRequest(resp, "CSRF violation");
function checkExpiredSession(req, resp, next) {
// all requests (both GET and POST) must have a session
if (req.session === undefined) {
logger.warn("calls to /wsapi require a cookie to be sent, this user may have cookies disabled");
return httputils.forbidden(resp, "no cookie");
}
if (!req.session.userid) {
// not yet authenticated, so nothing to expire, avoid the DB fetch
return next();
}
db.lastPasswordReset(req.session.userid, function(err, token) {
if (err) return databaseDown(resp, err);
// if token is 0 (or undefined), they haven't changed their password
// since the server was updated to use lastPasswordResets. Allow the
// session to pass, otherwise the server upgrade would gratuitously
// expire innocent sessions.
if (token && token != req.session.lastPasswordReset) {
logger.warn("expired cookie (password changed since issued)");
req.session.reset();
}
next();
});
}
Lloyd Hilaiel
committed
function langContext(req) {
return {
lang: req.lang,
locale: req.locale,
gettext: req.gettext,
ngettext: req.ngettext,
format: req.format
};
}
function databaseDown(res, err) {
logger.warn('database is down, cannot process request: ' + err);
httputils.serviceUnavailable(res, "database unavailable");
}
function operationFromURL (path) {
var purl = url.parse(path);
return purl.pathname.substr(0, WSAPI_PREFIX.length) === WSAPI_PREFIX &&
purl.pathname.substr(WSAPI_PREFIX.length) || null;
}
var APIs;
function allAPIs () {
if (APIs) return APIs;
APIs = {};
fs.readdirSync(path.join(__dirname, 'wsapi')).forEach(function (f) {
// skip files that don't have a .js suffix or start with a dot
if (f.length <= 3 || f.substr(-3) !== '.js' || f.substr(0,1) === '.') return;
var operation = f.substr(0, f.length - 3);
var api = require(path.join(__dirname, 'wsapi', f));
});
return APIs;
}
Lloyd Hilaiel
committed
// common functions exported, for use by different api calls
exports.clearAuthenticatedUser = clearAuthenticatedUser;
exports.isAuthed = isAuthed;
exports.bcryptPassword = bcryptPassword;
exports.authenticateSession = authenticateSession;
Lloyd Hilaiel
committed
exports.langContext = langContext;
exports.databaseDown = databaseDown;
Lloyd Hilaiel
committed
Lloyd Hilaiel
committed
exports.setup = function(options, app) {
Lloyd Hilaiel
committed
// If externally we're serving content over SSL we can enable things
// like strict transport security and change the way cookies are set
const overSSL = (config.get('scheme') == 'https');
Lloyd Hilaiel
committed
var cookieParser = express.cookieParser();
var bodyParser = express.bodyParser();
Lloyd Hilaiel
committed
Lloyd Hilaiel
committed
// stash our forward-to url so different wsapi handlers can use it
Lloyd Hilaiel
committed
Lloyd Hilaiel
committed
var cookieSessionMiddleware = sessions({
secret: COOKIE_SECRET,
cookieName: COOKIE_KEY,
Lloyd Hilaiel
committed
duration: config.get('authentication_duration_ms'),
Lloyd Hilaiel
committed
cookie: {
path: '/wsapi',
httpOnly: true,
maxAge: config.get('authentication_duration_ms'),
secure: overSSL
}
});
app.use(function(req, resp, next) {
Lloyd Hilaiel
committed
var purl = url.parse(req.url);
Lloyd Hilaiel
committed
// cookie sessions are only applied to calls to /wsapi
// as all other resources can be aggressively cached
// by layers higher up based on cache control headers.
// the fallout is that all code that interacts with sessions
// should be under /wsapi
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
if (purl.pathname.substr(0, WSAPI_PREFIX.length) !== WSAPI_PREFIX)
return next();
// explicitly disallow caching on all /wsapi calls (issue #294)
resp.setHeader('Cache-Control', 'no-cache, max-age=0');
// we set this parameter so the connect-cookie-session
// sends the cookie even though the local connection is HTTP
// (the load balancer does SSL)
if (overSSL)
req.connection.proxySecure = true;
const operation = purl.pathname.substr(WSAPI_PREFIX.length);
// count the number of WSAPI operation
statsd.increment("wsapi." + operation);
// check to see if the api is known here, before spending more time with
// the request.
if (!wsapis.hasOwnProperty(operation) ||
wsapis[operation].method.toLowerCase() !== req.method.toLowerCase())
{
// if the fake verification api is enabled (for load testing),
// then let this request fall through
if (operation !== 'fake_verification' || !process.env['BROWSERID_FAKE_VERIFICATION'])
return httputils.badRequest(resp, "no such api");
}
// perform full parsing and validation
return cookieParser(req, resp, function() {
bodyParser(req, resp, function() {
cookieSessionMiddleware(req, resp, function() {
checkExpiredSession(req, resp, function() {
return checkCSRF(req, resp, next);
Lloyd Hilaiel
committed
});
});
});
Lloyd Hilaiel
committed
});
// load all of the APIs supported by this process
var wsapis = { };
function describeOperation(name, op) {
var str = " " + name + " (";
str += op.method.toUpperCase() + " - ";
str += (op.authed ? "" : "not ") + "authed";
if (op.args) {
Zachary Carter
committed
var keys = Array.isArray(op.args) ? op.args : Object.keys(op.args);
str += " - " + keys.join(", ");
}
if (op.internal) str += ' - internal';
str += ")";
logger.debug(str);
}
var all = allAPIs();
Object.keys(all).forEach(function (operation) {
Lloyd Hilaiel
committed
try {
var api = all[operation];
Lloyd Hilaiel
committed
// - don't register read apis if we are configured as a writer,
Lloyd Hilaiel
committed
// with the exception of ping which tests database connection health.
// - don't register write apis if we are not configured as a writer
if ((options.only_write_apis && !api.writes_db && operation != 'ping') ||
(!options.only_write_apis && api.writes_db))
return;
Lloyd Hilaiel
committed
Lloyd Hilaiel
committed
wsapis[operation] = api;
Lloyd Hilaiel
committed
// set up the argument validator
if (api.args) {
wsapis[operation].validate = validate(api.args);
} else {
wsapis[operation].validate = function(req,res,next) { next(); };
}
Lloyd Hilaiel
committed
} catch(e) {
var msg = "error registering " + operation + " api: " + e;
logger.error(msg);
throw msg;
}
});
Lloyd Hilaiel
committed
// debug output - all supported apis
logger.debug("WSAPIs:");
Object.keys(wsapis).forEach(function(api) {
describeOperation(api, wsapis[api]);
Lloyd Hilaiel
committed
});
app.use(function(req, resp, next) {
var purl = url.parse(req.url);
if (purl.pathname.substr(0, WSAPI_PREFIX.length) === WSAPI_PREFIX) {
Lloyd Hilaiel
committed
const operation = purl.pathname.substr(WSAPI_PREFIX.length);
Lloyd Hilaiel
committed
// the fake_verification wsapi is implemented elsewhere.
if (operation == 'fake_verification') return next();
Lloyd Hilaiel
committed
// at this point, we *know* 'operation' is valid API, give checks performed
// above
Lloyd Hilaiel
committed
Lloyd Hilaiel
committed
// does the request require authentication?
if (wsapis[operation].authed && !isAuthed(req, wsapis[operation].authed)) {
Lloyd Hilaiel
committed
return httputils.badRequest(resp, "requires authentication");
Lloyd Hilaiel
committed
}
Lloyd Hilaiel
committed
// validate the arguments of the request
wsapis[operation].validate(req, resp, function() {
Austin King
committed
if (wsapis[operation].i18n) {
abide(req, resp, function () {
wsapis[operation].process(req, resp);
Austin King
committed
});
} else {
wsapis[operation].process(req, resp);
}
Lloyd Hilaiel
committed
});
Lloyd Hilaiel
committed
} else {
next();
}
});
};
exports.routeSetup = function (app, options) {
var wsapis = allAPIs();
app.use(function(req, resp, next) {
var operation = operationFromURL(req.url);
// not a WSAPI request
if (!operation) return next();
var api = wsapis[operation];
// check to see if the api is known here, before spending more time with
// the request.
if (!wsapis.hasOwnProperty(operation) ||
api.method.toLowerCase() !== req.method.toLowerCase()) {
// if the fake verification api is enabled (for load testing),
// then let this request fall through
if (operation !== 'fake_verification' || !process.env['BROWSERID_FAKE_VERIFICATION'])
return httputils.badRequest(resp, "no such api");
}
if (api.internal) {
return httputils.notFound(resp);
}
var destination_url = api.writes_db ? options.write_url + "/wsapi/" + operation
: options.read_url + req.url;
var cb = function() {
forward(
destination_url, req, resp,
function(err) {
if (err) {
logger.error("error forwarding request:", err);
}
});
};
return express.bodyParser()(req, resp, cb);
});
};