From 8c5e67acedbec932b751eb894067df3ba2936994 Mon Sep 17 00:00:00 2001 From: Lloyd Hilaiel <lloyd@hilaiel.com> Date: Thu, 10 Nov 2011 09:49:29 -0700 Subject: [PATCH] reorganize browserid process - break out view serving and wsapi handling. preparation for dbwriter split. issue #460 --- bin/browserid | 282 +++----------- bin/dbwriter | 3 + lib/browserid/fake_verification.js | 2 +- lib/browserid/views.js | 124 +++++++ lib/browserid/wsapi.js | 464 ------------------------ lib/configuration.js | 11 +- lib/db.js | 24 +- lib/{browserid => }/email.js | 7 +- lib/secrets.js | 3 +- lib/wsapi.js | 230 ++++++++++++ lib/wsapi/account_cancel.js | 18 + lib/wsapi/authenticate_user.js | 45 +++ lib/wsapi/cert_key.js | 27 ++ lib/wsapi/complete_email_addition.js | 20 + lib/wsapi/complete_user_creation.js | 50 +++ lib/wsapi/email_addition_status.js | 40 ++ lib/wsapi/email_for_token.js | 19 + lib/wsapi/have_email.js | 16 + lib/wsapi/list_emails.js | 22 ++ lib/wsapi/logout.js | 11 + lib/wsapi/remove_email.js | 21 ++ lib/wsapi/session_context.js | 53 +++ lib/wsapi/stage_email.js | 44 +++ lib/wsapi/stage_user.js | 52 +++ lib/wsapi/user_creation_status.js | 35 ++ tests/ca-test.js | 2 +- tests/cert-emails-test.js | 2 +- tests/cookie-session-security-test.js | 2 +- tests/forgotten-email-test.js | 2 +- tests/password-length-test.js | 2 +- tests/registration-status-wsapi-test.js | 2 +- 31 files changed, 918 insertions(+), 717 deletions(-) create mode 100755 bin/dbwriter create mode 100644 lib/browserid/views.js delete mode 100644 lib/browserid/wsapi.js rename lib/{browserid => }/email.js (95%) create mode 100644 lib/wsapi.js create mode 100644 lib/wsapi/account_cancel.js create mode 100644 lib/wsapi/authenticate_user.js create mode 100644 lib/wsapi/cert_key.js create mode 100644 lib/wsapi/complete_email_addition.js create mode 100644 lib/wsapi/complete_user_creation.js create mode 100644 lib/wsapi/email_addition_status.js create mode 100644 lib/wsapi/email_for_token.js create mode 100644 lib/wsapi/have_email.js create mode 100644 lib/wsapi/list_emails.js create mode 100644 lib/wsapi/logout.js create mode 100644 lib/wsapi/remove_email.js create mode 100644 lib/wsapi/session_context.js create mode 100644 lib/wsapi/stage_email.js create mode 100644 lib/wsapi/stage_user.js create mode 100644 lib/wsapi/user_creation_status.js diff --git a/bin/browserid b/bin/browserid index 92c84f079..2eb922c05 100755 --- a/bin/browserid +++ b/bin/browserid @@ -40,21 +40,21 @@ fs = require('fs'), path = require('path'), url = require('url'), http = require('http'); -sessions = require('connect-cookie-session'), urlparse = require('urlparse'), express = require('express'); const -wsapi = require('../lib/browserid/wsapi.js'), +wsapi = require('../lib/wsapi.js'), httputils = require('../lib/httputils.js'), secrets = require('../lib/secrets.js'), db = require('../lib/db.js'), config = require('../lib/configuration.js'), heartbeat = require('../lib/heartbeat.js'), metrics = require('../lib/metrics.js'), -logger = require('../lib/logging.js').logger +logger = require('../lib/logging.js').logger, forward = require('../lib/browserid/http_forward'), -shutdown = require('../lib/shutdown'); +shutdown = require('../lib/shutdown'), +views = require('../lib/browserid/views.js'); var app = undefined; @@ -62,142 +62,14 @@ app = express.createServer(); logger.info("browserid server starting up"); -const COOKIE_SECRET = secrets.hydrateSecret('browserid_cookie', config.get('var_path')); -const COOKIE_KEY = 'browserid_state'; - // verify that we have a keysigner configured if (!config.get('keysigner_url')) { logger.error('missing required configuration - url for the keysigner (KEYSIGNER_URL in env)'); process.exit(1); } -// NOTE: ordering is important in this file. Pay attention - -function router(app) { - app.set("views", path.join(__dirname, "..", "resources", "views")); - - app.set('view options', { - production: config.get('use_minified_resources') - }); - - // this should probably be an internal redirect - // as soon as relative paths are figured out. - app.get('/sign_in', function(req, res, next ) { - metrics.userEntry(req); - res.render('dialog.ejs', { - title: 'A Better Way to Sign In', - layout: 'dialog_layout.ejs', - useJavascript: true, - production: config.get('use_minified_resources') - }); - }); - - app.get('/communication_iframe', function(req, res, next ) { - res.removeHeader('x-frame-options'); - res.render('communication_iframe.ejs', { - layout: false, - production: config.get('use_minified_resources') - }); - }); - - app.get("/unsupported_dialog", function(req,res) { - res.render('unsupported_dialog.ejs', {layout: 'dialog_layout.ejs', useJavascript: false}); - }); - - // Used for a relay page for communication. - app.get("/relay", function(req,res, next) { - // Allow the relay to be run within a frame - res.removeHeader('x-frame-options'); - res.render('relay.ejs', { - layout: false, - production: config.get('use_minified_resources') - }); - }); - - app.get('/', function(req,res) { - res.render('index.ejs', {title: 'A Better Way to Sign In', fullpage: true}); - }); - - app.get("/signup", function(req, res) { - res.render('signup.ejs', {title: 'Sign Up', fullpage: false}); - }); - - app.get("/forgot", function(req, res) { - res.render('forgot.ejs', {title: 'Forgot Password', fullpage: false, email: req.query.email}); - }); - - app.get("/signin", function(req, res) { - res.render('signin.ejs', {title: 'Sign In', fullpage: false}); - }); - - app.get("/about", function(req, res) { - res.render('about.ejs', {title: 'About', fullpage: false}); - }); - - app.get("/tos", function(req, res) { - res.render('tos.ejs', {title: 'Terms of Service', fullpage: false}); - }); - - app.get("/privacy", function(req, res) { - res.render('privacy.ejs', {title: 'Privacy Policy', fullpage: false}); - }); - - app.get("/verify_email_address", function(req, res) { - res.render('verifyuser.ejs', {title: 'Complete Registration', fullpage: true, token: req.query.token}); - }); - - app.get("/add_email_address", function(req,res) { - res.render('verifyemail.ejs', {title: 'Verify Email Address', fullpage: false}); - }); - - // REDIRECTS - REDIRECTS = { - "/manage": "/", - "/users": "/", - "/users/": "/", - "/primaries" : "/developers", - "/primaries/" : "/developers", - "/developers" : "https://github.com/mozilla/browserid/wiki/How-to-Use-BrowserID-on-Your-Site" - }; - - // set up all the redirects - // oh my watch out for scope issues on var url - closure time - for (var url in REDIRECTS) { - (function(from,to) { - app.get(from, function(req, res) { - res.redirect(to); - }); - })(url, REDIRECTS[url]); - } - - // register all the WSAPI handlers - wsapi.setup(app); - - // the public key - app.get("/pk", function(req, res) { - res.json(config.get('public_key').toSimpleObject()); - }); - - // vep bundle of JavaScript - app.get("/vepbundle", function(req, res) { - fs.readFile(__dirname + "/../node_modules/jwcrypto/vepbundle.js", function(error, content) { - if (error) { - res.writeHead(500); - res.end("oops"); - console.log(error); - } else { - res.writeHead(200, {'Content-Type': 'text/javascript'}); - res.write(content); - res.end(); - } - }); - }); - - shutdown.installUpdateHandler(app, function(readyForShutdown) { - logger.debug("closing database connection"); - db.close(readyForShutdown) - }); -}; +// NOTE: ordering of middleware registration is important in this file, it is the +// order in which middleware will be invoked as requests are processed. // #1 - Setup health check / heartbeat middleware. // This is in front of logging on purpose. see issue #537 @@ -206,7 +78,7 @@ heartbeat.setup(app, function(cb) { heartbeat.check(config.get('keysigner_url'), cb); }); -// request to logger, dev formatted which omits personal data in the requests +// #2 - logging! all requests other than __heartbeat__ are logged app.use(express.logger({ format: config.get('express_log_format'), stream: { @@ -216,8 +88,24 @@ app.use(express.logger({ } })); -// if these are verify requests, we'll redirect them off -// to the verifier +// #3 - Add Strict-Transport-Security headers if we're serving over SSL +if (config.get('scheme') == 'https') { + app.use(function(req, resp, next) { + // expires in 30 days, include subdomains like www + resp.setHeader("Strict-Transport-Security", "max-age=2592000; includeSubdomains"); + next(); + }); +} + +// #4 - prevent framing of everything. content underneath that needs to be +// framed must explicitly remove the x-frame-options +app.use(function(req, resp, next) { + resp.setHeader('x-frame-options', 'DENY'); + next(); +}); + +// #5 - redirection! redirect requests to the "verifier" or to the "dbwriter" +// processes if (config.get('verifier_url')) { app.use(function(req, res, next) { if (/^\/verify$/.test(req.url)) { @@ -234,51 +122,7 @@ if (config.get('verifier_url')) { }); } -// over SSL? -var overSSL = (config.get('scheme') == 'https'); - -app.use(express.cookieParser()); - -var cookieSessionMiddleware = sessions({ - secret: COOKIE_SECRET, - key: COOKIE_KEY, - cookie: { - path: '/wsapi', - httpOnly: true, - // IMPORTANT: we allow users to go 1 weeks on the same device - // without entering their password again - maxAge: config.get('authentication_duration_ms'), - secure: overSSL - } -}); - -// cookie sessions && cache control -app.use(function(req, resp, next) { - // 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 - if (/^\/wsapi/.test(req.url)) { - // 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; - - return cookieSessionMiddleware(req, resp, next); - - } else { - return next(); - } -}); - -config.performSubstitution(app); - -// verify all JSON responses are objects - prevents regression on issue #217 +// #6 - verify all JSON responses are objects - prevents regression on issue #217 app.use(function(req, resp, next) { var realRespJSON = resp.json; resp.json = function(obj) { @@ -291,67 +135,37 @@ app.use(function(req, resp, next) { return next(); }); -app.use(express.bodyParser()); - -// Check CSRF token early. POST requests are only allowed to -// /wsapi and they always must have a valid csrf token -app.use(function(req, resp, next) { - // only on POSTs - if (req.method == "POST") { - var denied = false; - if (!/^\/wsapi/.test(req.url)) { // post requests only allowed to /wsapi - denied = true; - logger.warn("CSRF validation failure: POST only allowed to /wsapi urls. not '" + req.url + "'"); - } - - else if (req.session === undefined) { // there must be a session - denied = true; - logger.warn("CSRF validation failure: POST calls to /wsapi require an active session"); - } - - // the session must have a csrf token - else if (typeof req.session.csrf !== 'string') { - denied = true; - logger.warn("CSRF validation failure: POST calls to /wsapi require an csrf token to be set"); - } - - // and the token must match what is sent in the post body - else if (req.body.csrf != req.session.csrf) { - denied = true; - // if any of these things are false, then we'll block the request - logger.warn("CSRF validation failure, token mismatch. got:" + req.body.csrf + " want:" + req.session.csrf); - } +// #7 - perform response substitution to support local/dev/beta environments +// (specifically, this replaces URLs in responses, e.g. https://browserid.org +// with https://diresworb.org) +config.performSubstitution(app); - if (denied) return httputils.badRequest(resp, "CSRF violation"); +// #8 - handle /wsapi requests +wsapi.setup(app); - } - return next(); -}); +// #9 - handle views for dynamicish content +views.setup(app); -// Strict Transport Security -app.use(function(req, resp, next) { - if (overSSL) { - // expires in 30 days, include subdomains like www - resp.setHeader("Strict-Transport-Security", "max-age=2592000; includeSubdomains"); - } - next(); -}); +// #10 - if nothing else has caught this request, serve static files +app.use(express.static(path.join(__dirname, "..", "resources", "static"))); -// prevent framing -app.use(function(req, resp, next) { - resp.setHeader('x-frame-options', 'DENY'); - next(); +// #11 - calls to /code_update from localhost will restart the daemon, +// this feature is not externally accessible and is only used by +// the update logic +shutdown.installUpdateHandler(app, function(readyForShutdown) { + logger.debug("closing database connection"); + db.close(readyForShutdown) }); -// add the actual URL handlers other than static -router(app); - -// use the express 'static' middleware for serving of static files (cache headers, HTTP range, etc) -app.use(express.static(path.join(__dirname, "..", "resources", "static"))); +// #12 if the BROWSERID_FAKE_VERIFICATION env var is defined, we'll include +// fake_verification.js. This is used during testing only and should +// never be included in a production deployment +if (process.env['BROWSERID_FAKE_VERIFICATION']) { + require('../lib/fake_verification.js').addVerificationWSAPI(app); +} // open the databse db.open(config.get('database'), function (error) { - if (error) { logger.error("can't open database: " + error); // let async logging flush, then exit 1 diff --git a/bin/dbwriter b/bin/dbwriter new file mode 100755 index 000000000..79bfbf63b --- /dev/null +++ b/bin/dbwriter @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('./browserid'); diff --git a/lib/browserid/fake_verification.js b/lib/browserid/fake_verification.js index 8fcafc904..7212ac81e 100644 --- a/lib/browserid/fake_verification.js +++ b/lib/browserid/fake_verification.js @@ -41,7 +41,7 @@ */ const -email = require('./email.js'), +email = require('../email.js'), configuration = require('../../libs/configuration.js'), url = require('url'); diff --git a/lib/browserid/views.js b/lib/browserid/views.js new file mode 100644 index 000000000..c39f16791 --- /dev/null +++ b/lib/browserid/views.js @@ -0,0 +1,124 @@ +const metrics = require('../metrics.js'); + +// all templated content, redirects, and renames are handled here. +// anything that is not an api, and not static + +const +path = require('path'); + +exports.setup = function(app) { + app.set("views", path.join(__dirname, "..", "..", "resources", "views")); + + app.set('view options', { + production: config.get('use_minified_resources') + }); + + // this should probably be an internal redirect + // as soon as relative paths are figured out. + app.get('/sign_in', function(req, res, next ) { + metrics.userEntry(req); + res.render('dialog.ejs', { + title: 'A Better Way to Sign In', + layout: 'dialog_layout.ejs', + useJavascript: true, + production: config.get('use_minified_resources') + }); + }); + + app.get('/communication_iframe', function(req, res, next ) { + res.removeHeader('x-frame-options'); + res.render('communication_iframe.ejs', { + layout: false, + production: config.get('use_minified_resources') + }); + }); + + app.get("/unsupported_dialog", function(req,res) { + res.render('unsupported_dialog.ejs', {layout: 'dialog_layout.ejs', useJavascript: false}); + }); + + // Used for a relay page for communication. + app.get("/relay", function(req,res, next) { + // Allow the relay to be run within a frame + res.removeHeader('x-frame-options'); + res.render('relay.ejs', { + layout: false, + production: config.get('use_minified_resources') + }); + }); + + app.get('/', function(req,res) { + res.render('index.ejs', {title: 'A Better Way to Sign In', fullpage: true}); + }); + + app.get("/signup", function(req, res) { + res.render('signup.ejs', {title: 'Sign Up', fullpage: false}); + }); + + app.get("/forgot", function(req, res) { + res.render('forgot.ejs', {title: 'Forgot Password', fullpage: false, email: req.query.email}); + }); + + app.get("/signin", function(req, res) { + res.render('signin.ejs', {title: 'Sign In', fullpage: false}); + }); + + app.get("/about", function(req, res) { + res.render('about.ejs', {title: 'About', fullpage: false}); + }); + + app.get("/tos", function(req, res) { + res.render('tos.ejs', {title: 'Terms of Service', fullpage: false}); + }); + + app.get("/privacy", function(req, res) { + res.render('privacy.ejs', {title: 'Privacy Policy', fullpage: false}); + }); + + app.get("/verify_email_address", function(req, res) { + res.render('verifyuser.ejs', {title: 'Complete Registration', fullpage: true, token: req.query.token}); + }); + + app.get("/add_email_address", function(req,res) { + res.render('verifyemail.ejs', {title: 'Verify Email Address', fullpage: false}); + }); + + // REDIRECTS + REDIRECTS = { + "/manage": "/", + "/users": "/", + "/users/": "/", + "/primaries" : "/developers", + "/primaries/" : "/developers", + "/developers" : "https://github.com/mozilla/browserid/wiki/How-to-Use-BrowserID-on-Your-Site" + }; + + // set up all the redirects + // oh my watch out for scope issues on var url - closure time + for (var url in REDIRECTS) { + (function(from,to) { + app.get(from, function(req, res) { + res.redirect(to); + }); + })(url, REDIRECTS[url]); + } + + // the public key + app.get("/pk", function(req, res) { + res.json(config.get('public_key').toSimpleObject()); + }); + + // vep bundle of JavaScript + try { + const vepBundleFilePath = path.join(__dirname, "..", "..", "node_modules", "jwcrypto", "vepbundle.js"); + var vepBundleFile = fs.readFileSync(vepBundleFilePath); + } catch(e) { + logger.error("can't read vepbundle file (" + vepBundleFilePath +"): " + e); + } + + app.get("/vepbundle", function(req, res) { + res.writeHead(200, {'Content-Type': 'text/javascript'}); + res.write(vepBundleFile); + res.end(); + }); +}; diff --git a/lib/browserid/wsapi.js b/lib/browserid/wsapi.js deleted file mode 100644 index a76778e8a..000000000 --- a/lib/browserid/wsapi.js +++ /dev/null @@ -1,464 +0,0 @@ -/* ***** BEGIN LICENSE BLOCK ***** - * Version: MPL 1.1/GPL 2.0/LGPL 2.1 - * - * The contents of this file are subject to the Mozilla Public License Version - * 1.1 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * http://www.mozilla.org/MPL/ - * - * Software distributed under the License is distributed on an "AS IS" basis, - * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License - * for the specific language governing rights and limitations under the - * License. - * - * The Original Code is Mozilla BrowserID. - * - * The Initial Developer of the Original Code is Mozilla. - * Portions created by the Initial Developer are Copyright (C) 2011 - * the Initial Developer. All Rights Reserved. - * - * Contributor(s): - * - * Alternatively, the contents of this file may be used under the terms of - * either the GNU General Public License Version 2 or later (the "GPL"), or - * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), - * in which case the provisions of the GPL or the LGPL are applicable instead - * of those above. If you wish to allow use of your version of this file only - * under the terms of either the GPL or the LGPL, and not to allow others to - * use your version of this file under the terms of the MPL, indicate your - * decision by deleting the provisions above and replace them with the notice - * and other provisions required by the GPL or the LGPL. If you do not delete - * the provisions above, a recipient may use your version of this file under - * the terms of any one of the MPL, the GPL or the LGPL. - * - * ***** END LICENSE BLOCK ***** */ - -// a module which implements the authorities web server api. -// it used to be that we stuffed every function in exports. -// now we're using proper express function registration to deal -// with HTTP methods and the like, apply middleware, etc. - -const -db = require('../db.js'), -url = require('url'), -httputils = require('../httputils.js'), -email = require('./email.js'), -bcrypt = require('bcrypt'), -crypto = require('crypto'), -logger = require('../logging.js').logger, -config = require('../configuration.js'), -validate = require('../validate'), -forward = require('./http_forward.js'); - -// log a user out, clearing everything from their session except the csrf token -function clearAuthenticatedUser(session) { - Object.keys(session).forEach(function(k) { - if (k !== 'csrf') delete session[k]; - }); -} - -function setAuthenticatedUser(session, email) { - session.authenticatedUser = email; - session.authenticatedAt = new Date(); -} - -function isAuthed(req) { - var who; - try { - if (req.session.authenticatedUser) { - if (!Date.parse(req.session.authenticatedAt) > 0) throw "bad timestamp"; - if (new Date() - new Date(req.session.authenticatedAt) > - config.get('authentication_duration_ms')) - { - throw "expired"; - } - who = req.session.authenticatedUser; - } - } catch(e) { - logger.debug("Session authentication has expired:", e); - clearAuthenticatedUser(req.session); - } - - return who; -} - -// turned this into a proper middleware -function checkAuthed(req, resp, next) { - if (!isAuthed(req)) { - return httputils.badRequest(resp, "requires authentication"); - } - - next(); -} - -function setup(app) { - // return the CSRF token, authentication status, and current server time (for assertion signing) - // IMPORTANT: this is safe because it's only readable by same-origin code - app.get('/wsapi/session_context', function(req, res) { - if (typeof req.session == 'undefined') { - req.session = {}; - } - - if (typeof req.session.csrf == 'undefined') { - // FIXME: using express-csrf's approach for generating randomness - // not awesome, but probably sufficient for now. - req.session.csrf = crypto.createHash('md5').update('' + new Date().getTime()).digest('hex'); - logger.debug("NEW csrf token created: " + req.session.csrf); - } - - var auth_status = false; - - function sendResponse() { - res.json({ - csrf_token: req.session.csrf, - server_time: (new Date()).getTime(), - authenticated: auth_status - }); - }; - - // if they're authenticated for an email address that we don't know about, - // then we should purge the stored cookie - if (!isAuthed(req)) { - logger.debug("user is not authenticated"); - sendResponse(); - } else { - db.emailKnown(req.session.authenticatedUser, function (known) { - if (!known) { - logger.debug("user is authenticated with an email that doesn't exist in the database"); - clearAuthenticatedUser(req.session); - } else { - logger.debug("user is authenticated"); - auth_status = true; - } - sendResponse(); - }); - } - }); - - /* checks to see if an email address is known to the server - * takes 'email' as a GET argument */ - app.get('/wsapi/have_email', function(req, resp) { - // get inputs from get data! - var email = url.parse(req.url, true).query['email']; - db.emailKnown(email, function(known) { - resp.json({ email_known: known }); - }); - }); - - /* First half of account creation. Stages a user account for creation. - * this involves creating a secret url that must be delivered to the - * user via their claimed email address. Upon timeout expiry OR clickthrough - * the staged user account transitions to a valid user account - */ - app.post('/wsapi/stage_user', validate([ "email", "site" ]), function(req, resp) { - // staging a user logs you out. - clearAuthenticatedUser(req.session); - - db.lastStaged(req.body.email, function (last) { - if (last && (new Date() - last) < config.get('min_time_between_emails_ms')) { - logger.warn('throttling request to stage email address ' + req.body.email + ', only ' + - ((new Date() - last) / 1000.0) + "s elapsed"); - return httputils.forbidden(resp, "throttling. try again later."); - } - - try { - // upon success, stage_user returns a secret (that'll get baked into a url - // and given to the user), on failure it throws - db.stageUser(req.body.email, function(secret) { - // store the email being registered in the session data - if (!req.session) req.session = {}; - - // store the secret we're sending via email in the users session, as checking - // that it still exists in the database is the surest way to determine the - // status of the email verification. - req.session.pendingCreation = secret; - - resp.json({ success: true }); - - // let's now kick out a verification email! - email.sendNewUserEmail(req.body.email, req.body.site, secret); - }); - } catch(e) { - // we should differentiate tween' 400 and 500 here. - httputils.badRequest(resp, e.toString()); - } - }); - }); - - app.get('/wsapi/user_creation_status', function(req, resp) { - var email = req.query.email; - if (typeof email !== 'string') { - logger.warn("user_creation_status called without 'email' parameter"); - httputils.badRequest(resp, "no 'email' parameter"); - return; - } - - // if the user is authenticated as the user in question, we're done - if (isAuthed(req) && req.session.authenticatedUser === email) { - return resp.json({ status: 'complete' }); - } - // if the user isn't authenticated and there's no pendingCreation token, - // then they must authenticate - else if (!req.session.pendingCreation) { - return resp.json({ status: 'mustAuth' }); - } - - // if the secret is still in the database, it hasn't yet been verified and - // verification is still pending - db.emailForVerificationSecret(req.session.pendingCreation, function (email) { - if (email) return resp.json({ status: 'pending' }); - // if the secret isn't known, and we're not authenticated, then the user must authenticate - // (maybe they verified the URL on a different browser, or maybe they canceled the account - // creation) - else { - delete req.session.pendingCreation; - resp.json({ status: 'mustAuth' }); - } - }); - }); - - function bcrypt_password(password, cb) { - var bcryptWorkFactor = config.get('bcrypt_work_factor'); - - bcrypt.gen_salt(bcryptWorkFactor, function (err, salt) { - if (err) { - var msg = "error generating salt with bcrypt: " + err; - logger.error(msg); - return cb(msg); - } - bcrypt.encrypt(password, salt, function(err, hash) { - if (err) { - var msg = "error generating password hash with bcrypt: " + err; - logger.error(msg); - return cb(msg); - } - return cb(undefined, hash); - }); - }); - }; - - app.post('/wsapi/complete_user_creation', validate(["token", "pass"]), function(req, resp) { - // issue #155, valid password length is between 8 and 80 chars. - if (req.body.pass.length < 8 || req.body.pass.length > 80) { - httputils.badRequest(resp, "valid passwords are between 8 and 80 chars"); - return; - } - - // at the time the email verification is performed, we'll clear the pendingCreation - // data on the session. - delete req.session.pendingCreation; - - // We should check to see if the verification secret is valid *before* - // bcrypting the password (which is expensive), to prevent a possible - // DoS attack. - db.emailForVerificationSecret(req.body.token, function(email) { - if (!email) return resp.json({ success: false} ); - - // now bcrypt the password - bcrypt_password(req.body.pass, function (err, hash) { - if (err) { - logger.error("can't bcrypt: " + err); - return resp.json({ success: false }); - } - - db.gotVerificationSecret(req.body.token, hash, function(err, email) { - if (err) { - logger.warn("couldn't complete email verification: " + err); - resp.json({ success: false }); - } else { - // FIXME: not sure if we want to do this (ba) - // at this point the user has set a password associated with an email address - // that they've verified. We create an authenticated session. - setAuthenticatedUser(req.session, email); - resp.json({ success: true }); - } - }); - }); - }); - }); - - app.post('/wsapi/stage_email', checkAuthed, validate(["email", "site"]), function (req, resp) { - db.lastStaged(req.body.email, function (last) { - if (last && (new Date() - last) < config.get('min_time_between_emails_ms')) { - logger.warn('throttling request to stage email address ' + req.body.email + ', only ' + - ((new Date() - last) / 1000.0) + "s elapsed"); - return httputils.forbidden(resp, "throttling. try again later."); - } - - try { - // on failure stageEmail may throw - db.stageEmail(req.session.authenticatedUser, req.body.email, function(secret) { - - // store the email being added in session data - req.session.pendingAddition = secret; - - resp.json({ success: true }); - - // let's now kick out a verification email! - email.sendAddAddressEmail(req.body.email, req.body.site, secret); - }); - } catch(e) { - // we should differentiate tween' 400 and 500 here. - httputils.badRequest(resp, e.toString()); - } - }); - }); - - app.get('/wsapi/email_for_token', validate(["token"]), function(req,resp) { - db.emailForVerificationSecret(req.query.token, function(email) { - resp.json({ email: email }); - }); - }); - - app.get('/wsapi/email_addition_status', function(req, resp) { - - var email = req.query.email; - if (typeof email !== 'string') - { - logger.warn("email_addition_status called without an 'email' parameter"); - httputils.badRequest(resp, "missing 'email' parameter"); - return; - } - - // this is a pending email addition, it requires authentication - if (!isAuthed(req, resp)) { - delete req.session.pendingAddition; - return httputils.badRequest(resp, "requires authentication"); - } - - // check if the currently authenticated user has the email stored under pendingAddition - // in their acct. - db.emailsBelongToSameAccount( - email, - req.session.authenticatedUser, - function(registered) { - if (registered) { - delete req.session.pendingAddition; - resp.json({ status: 'complete' }); - } else if (!req.session.pendingAddition) { - resp.json('failed'); - } else { - db.emailForVerificationSecret(req.session.pendingAddition, function (email) { - if (email) { - return resp.json({ status: 'pending' }); - } else { - delete req.session.pendingAddition; - resp.json({ status: 'failed' }); - } - }); - } - }); - }); - - app.post('/wsapi/complete_email_addition', validate(["token"]), function(req, resp) { - db.gotVerificationSecret(req.body.token, undefined, function(e) { - if (e) { - logger.warn("couldn't complete email verification: " + e); - resp.json({ success: false }); - } else { - resp.json({ success: true }); - } - }); - }); - - app.post('/wsapi/authenticate_user', validate(["email", "pass"]), function(req, resp) { - db.checkAuth(req.body.email, function(hash) { - if (typeof hash !== 'string' || - typeof req.body.pass !== 'string') - { - return resp.json({ success: false }); - } - - bcrypt.compare(req.body.pass, hash, function (err, success) { - if (err) { - logger.warn("error comparing passwords with bcrypt: " + err); - success = false; - } - if (success) { - if (!req.session) req.session = {}; - setAuthenticatedUser(req.session, req.body.email); - - // if the work factor has changed, update the hash here. issue #204 - // NOTE: this runs asynchronously and will not delay the response - if (config.get('bcrypt_work_factor') != bcrypt.get_rounds(hash)) { - logger.info("updating bcrypted password for email " + req.body.email); - bcrypt_password(req.body.pass, function(err, hash) { - db.updatePassword(req.body.email, hash, function(err) { - if (err) { - logger.error("error updating bcrypted password for email " + req.body.email, err); - } - }); - }); - } - } - resp.json({ success: success }); - }); - }); - }); - - app.post('/wsapi/remove_email', checkAuthed, validate(["email"]), function(req, resp) { - var email = req.body.email; - - db.removeEmail(req.session.authenticatedUser, email, function(error) { - if (error) { - logger.error("error removing email " + email); - httputils.badRequest(resp, error.toString()); - } else { - resp.json({ success: true }); - }}); - }); - - app.post('/wsapi/account_cancel', checkAuthed, function(req, resp) { - db.cancelAccount(req.session.authenticatedUser, function(error) { - if (error) { - logger.error("error cancelling account : " + error.toString()); - httputils.badRequest(resp, error.toString()); - } else { - resp.json({ success: true }); - }}); - }); - - app.post('/wsapi/cert_key', checkAuthed, validate(["email", "pubkey"]), function(req, res) { - db.emailsBelongToSameAccount(req.session.authenticatedUser, req.body.email, function(sameAccount) { - // not same account? big fat error - if (!sameAccount) return httputils.badRequest(res, "that email does not belong to you"); - - // forward to the keysigner! - var keysigner = config.get('keysigner_url'); - keysigner.path = '/wsapi/cert_key'; - forward(keysigner, req, res, function(err) { - if (err) { - logger.error("error forwarding request:", err); - } - }); - }); - }); - - app.post('/wsapi/logout', function(req, resp) { - clearAuthenticatedUser(req.session); - resp.json({ success: true }); - }); - - // returns a list of emails owned by the user - // returns: - // { - // "foo@foo.com" : {..properties..} - // ... - // } - app.get('/wsapi/list_emails', checkAuthed, function(req, resp) { - logger.debug('listing emails for ' + req.session.authenticatedUser); - db.listEmails(req.session.authenticatedUser, function(err, emails) { - if (err) httputils.serverError(resp, err); - else resp.json(emails); - }); - }); - - // if the BROWSERID_FAKE_VERIFICATION env var is defined, we'll include - // fake_verification.js. This is used during testing only and should - // never be included in a production deployment - if (process.env['BROWSERID_FAKE_VERIFICATION']) { - require('./fake_verification.js').addVerificationWSAPI(app); - } -} - -exports.setup = setup; diff --git a/lib/configuration.js b/lib/configuration.js index 4b150976d..87230eea6 100644 --- a/lib/configuration.js +++ b/lib/configuration.js @@ -80,7 +80,8 @@ g_configs.production = { database: { driver: "mysql", user: 'browserid', - create_schema: true + create_schema: true, + may_write: false }, bcrypt_work_factor: 12, authentication_duration_ms: (2 * 7 * 24 * 60 * 60 * 1000), @@ -95,7 +96,10 @@ g_configs.local = { email_to_console: true, // don't send email, just dump verification URLs to console. use_minified_resources: false, var_path: path.join(__dirname, "..", "var"), - database: { driver: "json" }, + database: { + driver: "json", + may_write: false + }, bcrypt_work_factor: g_configs.production.bcrypt_work_factor, authentication_duration_ms: g_configs.production.authentication_duration_ms, certificate_validity_ms: g_configs.production.certificate_validity_ms, @@ -211,6 +215,9 @@ g_config['process_type'] = path.basename(process.argv[1], ".js"); g_config['public_key'] = secrets.loadPublicKey('root', exports.get('var_path')); +// only allow the dbwriter process to write to the database +g_config.database.may_write = true; //(g_config.process_type === 'dbwriter'); + // log the process_type setTimeout(function() { require("./logging.js").logger.info("process type is " + g_config["process_type"]); diff --git a/lib/db.js b/lib/db.js index e3a409203..ce154bd02 100644 --- a/lib/db.js +++ b/lib/db.js @@ -34,6 +34,7 @@ * ***** END LICENSE BLOCK ***** */ var logger = require('./logging.js').logger; +const config = require('./configuration.js'); var driver; @@ -92,22 +93,37 @@ exports.onReady = function(f) { }, 0); }; +// these are read only database calls [ 'emailKnown', 'isStaged', 'emailsBelongToSameAccount', - 'stageUser', - 'stageEmail', - 'gotVerificationSecret', 'emailForVerificationSecret', 'checkAuth', 'listEmails', + 'lastStaged' +].forEach(function(fn) { + exports[fn] = function() { + checkReady(); + driver[fn].apply(undefined, arguments); + }; +}); + +// These are database calls that write. Database +// writing must be enabled on the process for them +// to work. +[ + 'stageUser', + 'stageEmail', + 'gotVerificationSecret', 'removeEmail', 'cancelAccount', 'updatePassword', - 'lastStaged' ].forEach(function(fn) { exports[fn] = function() { + if (!config.get('database').may_write) { + throw "this process may not write the database" + } checkReady(); driver[fn].apply(undefined, arguments); }; diff --git a/lib/browserid/email.js b/lib/email.js similarity index 95% rename from lib/browserid/email.js rename to lib/email.js index b0b873d70..5bee98ed5 100644 --- a/lib/browserid/email.js +++ b/lib/email.js @@ -34,13 +34,12 @@ * ***** END LICENSE BLOCK ***** */ const -db = require('../db.js'), emailer = require('nodemailer'), fs = require('fs'), path = require('path'), mustache = require('mustache'), -config = require('../configuration.js'), -logger = require('../logging.js').logger; +config = require('./configuration.js'), +logger = require('./logging.js').logger; /* if smtp parameters are configured, use them */ var smtp_params = config.get('smtp'); @@ -56,7 +55,7 @@ if (smtp_params && smtp_params.host) { } } -const template = fs.readFileSync(path.join(__dirname, "prove_template.txt")).toString(); +const template = fs.readFileSync(path.join(__dirname, "browserid", "prove_template.txt")).toString(); var interceptor = undefined; diff --git a/lib/secrets.js b/lib/secrets.js index 6382d5e42..41d53d024 100644 --- a/lib/secrets.js +++ b/lib/secrets.js @@ -36,8 +36,7 @@ const path = require('path'), fs = require('fs'), -jwk = require('jwcrypto/jwk'), -configuration = require("./configuration"); +jwk = require('jwcrypto/jwk'); exports.generate = function(chars) { var str = ""; diff --git a/lib/wsapi.js b/lib/wsapi.js new file mode 100644 index 000000000..3c14d7d53 --- /dev/null +++ b/lib/wsapi.js @@ -0,0 +1,230 @@ +// 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 + + +const +sessions = require('connect-cookie-session'), +express = require('express'); +secrets = require('./secrets'), +config = require('./configuration'), +logger = require('./logging.js').logger, +httputils = require('./httputils.js'), +url = require('url'), +fs = require('fs'), +path = require('path'), +validate = require('./validate'), +bcrypt = require('bcrypt'); + +const COOKIE_SECRET = secrets.hydrateSecret('browserid_cookie', config.get('var_path')); +const COOKIE_KEY = 'browserid_state'; + +function clearAuthenticatedUser(session) { + Object.keys(session).forEach(function(k) { + if (k !== 'csrf') delete session[k]; + }); +} + + +function isAuthed(req) { + var who; + try { + if (req.session.authenticatedUser) { + if (!Date.parse(req.session.authenticatedAt) > 0) throw "bad timestamp"; + if (new Date() - new Date(req.session.authenticatedAt) > + config.get('authentication_duration_ms')) + { + throw "expired"; + } + who = req.session.authenticatedUser; + } + } catch(e) { + logger.debug("Session authentication has expired:", e); + clearAuthenticatedUser(req.session); + } + + return who; +} + +function bcryptPassword(password, cb) { + var bcryptWorkFactor = config.get('bcrypt_work_factor'); + + bcrypt.gen_salt(bcryptWorkFactor, function (err, salt) { + if (err) { + var msg = "error generating salt with bcrypt: " + err; + logger.error(msg); + return cb(msg); + } + bcrypt.encrypt(password, salt, function(err, hash) { + if (err) { + var msg = "error generating password hash with bcrypt: " + err; + logger.error(msg); + return cb(msg); + } + return cb(undefined, hash); + }); + }); +}; + +function setAuthenticatedUser(session, email) { + session.authenticatedUser = email; + session.authenticatedAt = new Date(); +} + +// common functions exported, for use by different api calls +exports.clearAuthenticatedUser = clearAuthenticatedUser; +exports.isAuthed = isAuthed; +exports.bcryptPassword = bcryptPassword; +exports.setAuthenticatedUser = setAuthenticatedUser; + +exports.setup = function(app) { + + // XXX: we can and should make all of the logic below only take effect for POST requests + // to /wsapi to reduce code run for other requests (cookie parsing, etc) + + // 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'); + + app.use(express.cookieParser()); + + var cookieSessionMiddleware = sessions({ + secret: COOKIE_SECRET, + key: COOKIE_KEY, + cookie: { + path: '/wsapi', + httpOnly: true, + // IMPORTANT: we allow users to go 1 weeks on the same device + // without entering their password again + maxAge: config.get('authentication_duration_ms'), + secure: overSSL + } + }); + + // cookie sessions && cache control + app.use(function(req, resp, next) { + // 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 + if (/^\/wsapi/.test(req.url)) { + // 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; + + return cookieSessionMiddleware(req, resp, next); + + } else { + return next(); + } + }); + + app.use(express.bodyParser()); + + // Check CSRF token early. POST requests are only allowed to + // /wsapi and they always must have a valid csrf token + app.use(function(req, resp, next) { + // only on POSTs + if (req.method == "POST") { + var denied = false; + if (!/^\/wsapi/.test(req.url)) { // post requests only allowed to /wsapi + denied = true; + logger.warn("CSRF validation failure: POST only allowed to /wsapi urls. not '" + req.url + "'"); + } + + else if (req.session === undefined) { // there must be a session + denied = true; + logger.warn("CSRF validation failure: POST calls to /wsapi require an active session"); + } + + // the session must have a csrf token + else if (typeof req.session.csrf !== 'string') { + denied = true; + logger.warn("CSRF validation failure: POST calls to /wsapi require an csrf token to be set"); + } + + // and the token must match what is sent in the post body + else if (req.body.csrf != req.session.csrf) { + denied = true; + // if any of these things are false, then we'll block the request + logger.warn("CSRF validation failure, token mismatch. got:" + req.body.csrf + " want:" + req.session.csrf); + } + + if (denied) return httputils.badRequest(resp, "CSRF violation"); + + } + return next(); + }); + + const WSAPI_PREFIX = '/wsapi/'; + + // load all of the APIs supported by this process + var wsapis = { }; + + logger.debug("registering WSAPIs:"); + 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); + + try { + var api = require(path.join(__dirname, 'wsapi', f)); + wsapis[operation] = api; + + // set up the argument validator + if (api.args) { + if (!Array.isArray(api.args)) throw "exports.args must be an array of strings"; + wsapis[operation].validate = validate(api.args); + } else { + wsapis[operation].validate = function(req,res,next) { next(); }; + } + + } catch(e) { + var msg = "error registering " + operation + " api: " + e; + logger.error(msg); + throw msg; + } + + logger.debug(' ' + operation); + + }); + + app.use(function(req, resp, next) { + var purl = url.parse(req.url); + + if (purl.pathname.substr(0, WSAPI_PREFIX.length) === WSAPI_PREFIX) { + const operation = purl.pathname.substr(WSAPI_PREFIX.length); + if (wsapis.hasOwnProperty(operation) && + wsapis[operation].method.toLowerCase() === req.method.toLowerCase()) { + + // does the request require authentication? + if (wsapis[operation].authed && !isAuthed(req)) { + return httputils.badRequest(resp, "requires authentication"); + } + + // validate the arguments of the request + wsapis[operation].validate(req, resp, function() { + wsapis[operation].process(req, resp); + }); + } else { + return httputils.badRequest(resp, "no such api"); + } + + } else { + next(); + } + }); +}; diff --git a/lib/wsapi/account_cancel.js b/lib/wsapi/account_cancel.js new file mode 100644 index 000000000..4850bafc5 --- /dev/null +++ b/lib/wsapi/account_cancel.js @@ -0,0 +1,18 @@ +const +db = require('../db.js'), +httputils = require('../httputils'), +logger = require('../logging.js').logger; + +exports.method = 'post'; +exports.writes_db = true; +exports.authed = true; + +exports.process = function(req, res) { + db.cancelAccount(req.session.authenticatedUser, function(error) { + if (error) { + logger.error("error cancelling account : " + error.toString()); + httputils.badRequest(res, error.toString()); + } else { + res.json({ success: true }); + }}); +}; diff --git a/lib/wsapi/authenticate_user.js b/lib/wsapi/authenticate_user.js new file mode 100644 index 000000000..80d6e0dbd --- /dev/null +++ b/lib/wsapi/authenticate_user.js @@ -0,0 +1,45 @@ +const +db = require('../db.js'), +wsapi = require('../wsapi.js'), +httputils = require('../httputils'), +logger = require('../logging.js').logger, +bcrypt = require('bcrypt'); + +exports.method = 'post'; +exports.writes_db = true; +exports.authed = false; +exports.args = ['email','pass']; + +exports.process = function(req, res) { + db.checkAuth(req.body.email, function(hash) { + if (typeof hash !== 'string' || typeof req.body.pass !== 'string') + { + return res.json({ success: false }); + } + + bcrypt.compare(req.body.pass, hash, function (err, success) { + if (err) { + logger.warn("error comparing passwords with bcrypt: " + err); + success = false; + } + if (success) { + if (!req.session) req.session = {}; + wsapi.setAuthenticatedUser(req.session, req.body.email); + + // if the work factor has changed, update the hash here. issue #204 + // NOTE: this runs asynchronously and will not delay the response + if (config.get('bcrypt_work_factor') != bcrypt.get_rounds(hash)) { + logger.info("updating bcrypted password for email " + req.body.email); + wsapi.bcryptPassword(req.body.pass, function(err, hash) { + db.updatePassword(req.body.email, hash, function(err) { + if (err) { + logger.error("error updating bcrypted password for email " + req.body.email, err); + } + }); + }); + } + } + res.json({ success: success }); + }); + }); +}; diff --git a/lib/wsapi/cert_key.js b/lib/wsapi/cert_key.js new file mode 100644 index 000000000..57d821281 --- /dev/null +++ b/lib/wsapi/cert_key.js @@ -0,0 +1,27 @@ +const +db = require('../db.js'), +httputils = require('../httputils'), +logger = require('../logging.js').logger, +forward = require('../browserid/http_forward.js'), +config = require('../configuration.js'); + +exports.method = 'post'; +exports.writes_db = false; +exports.authed = true; +exports.args = ['email','pubkey']; + +exports.process = function(req, res) { + db.emailsBelongToSameAccount(req.session.authenticatedUser, req.body.email, function(sameAccount) { + // not same account? big fat error + if (!sameAccount) return httputils.badRequest(res, "that email does not belong to you"); + + // forward to the keysigner! + var keysigner = config.get('keysigner_url'); + keysigner.path = '/wsapi/cert_key'; + forward(keysigner, req, res, function(err) { + if (err) { + logger.error("error forwarding request:", err); + } + }); + }); +}; diff --git a/lib/wsapi/complete_email_addition.js b/lib/wsapi/complete_email_addition.js new file mode 100644 index 000000000..d0a1cb091 --- /dev/null +++ b/lib/wsapi/complete_email_addition.js @@ -0,0 +1,20 @@ +const +db = require('../db.js'), +logger = require('../logging.js').logger; + +exports.method = 'post'; +exports.writes_db = false; +// XXX: see issue #290 - we want to require authentication here and update frontend code +exports.authed = false; +exports.args = ['token']; + +exports.process = function(req, res) { + db.gotVerificationSecret(req.body.token, undefined, function(e) { + if (e) { + logger.warn("couldn't complete email verification: " + e); + res.json({ success: false }); + } else { + res.json({ success: true }); + } + }); +}; diff --git a/lib/wsapi/complete_user_creation.js b/lib/wsapi/complete_user_creation.js new file mode 100644 index 000000000..1e586c145 --- /dev/null +++ b/lib/wsapi/complete_user_creation.js @@ -0,0 +1,50 @@ +const +db = require('../db.js'), +wsapi = require('../wsapi.js'), +httputils = require('../httputils'), +logger = require('../logging.js').logger; + +exports.method = 'post'; +exports.writes_db = true; +exports.authed = false; +exports.args = ['token','pass']; + +exports.process = function(req, res) { + // issue #155, valid password length is between 8 and 80 chars. + if (req.body.pass.length < 8 || req.body.pass.length > 80) { + httputils.badRequest(res, "valid passwords are between 8 and 80 chars"); + return; + } + + // at the time the email verification is performed, we'll clear the pendingCreation + // data on the session. + delete req.session.pendingCreation; + + // We should check to see if the verification secret is valid *before* + // bcrypting the password (which is expensive), to prevent a possible + // DoS attack. + db.emailForVerificationSecret(req.body.token, function(email) { + if (!email) return res.json({ success: false} ); + + // now bcrypt the password + wsapi.bcryptPassword(req.body.pass, function (err, hash) { + if (err) { + logger.error("can't bcrypt: " + err); + return res.json({ success: false }); + } + + db.gotVerificationSecret(req.body.token, hash, function(err, email) { + if (err) { + logger.warn("couldn't complete email verification: " + err); + res.json({ success: false }); + } else { + // FIXME: not sure if we want to do this (ba) + // at this point the user has set a password associated with an email address + // that they've verified. We create an authenticated session. + wsapi.setAuthenticatedUser(req.session, email); + res.json({ success: true }); + } + }); + }); + }); +}; diff --git a/lib/wsapi/email_addition_status.js b/lib/wsapi/email_addition_status.js new file mode 100644 index 000000000..dc7ef61bb --- /dev/null +++ b/lib/wsapi/email_addition_status.js @@ -0,0 +1,40 @@ +const +db = require('../db.js'); + +/* First half of account creation. Stages a user account for creation. + * this involves creating a secret url that must be delivered to the + * user via their claimed email address. Upon timeout expiry OR clickthrough + * the staged user account transitions to a valid user account + */ + +exports.method = 'get'; +exports.writes_db = false; +exports.authed = true; +exports.args = ['email']; + +exports.process = function(req, res) { + var email = req.query.email; + + // check if the currently authenticated user has the email stored under pendingAddition + // in their acct. + db.emailsBelongToSameAccount( + email, + req.session.authenticatedUser, + function(registered) { + if (registered) { + delete req.session.pendingAddition; + res.json({ status: 'complete' }); + } else if (!req.session.pendingAddition) { + res.json('failed'); + } else { + db.emailForVerificationSecret(req.session.pendingAddition, function (email) { + if (email) { + return res.json({ status: 'pending' }); + } else { + delete req.session.pendingAddition; + res.json({ status: 'failed' }); + } + }); + } + }); +}; diff --git a/lib/wsapi/email_for_token.js b/lib/wsapi/email_for_token.js new file mode 100644 index 000000000..3a6f70748 --- /dev/null +++ b/lib/wsapi/email_for_token.js @@ -0,0 +1,19 @@ +const +db = require('../db.js'); + +/* First half of account creation. Stages a user account for creation. + * this involves creating a secret url that must be delivered to the + * user via their claimed email address. Upon timeout expiry OR clickthrough + * the staged user account transitions to a valid user account + */ + +exports.method = 'get'; +exports.writes_db = false; +exports.authed = false; +exports.args = ['token']; + +exports.process = function(req, res) { + db.emailForVerificationSecret(req.query.token, function(email) { + res.json({ email: email }); + }); +}; diff --git a/lib/wsapi/have_email.js b/lib/wsapi/have_email.js new file mode 100644 index 000000000..e399bb86b --- /dev/null +++ b/lib/wsapi/have_email.js @@ -0,0 +1,16 @@ +const +db = require('../db.js'); + +// return if an email is known to browserid + +exports.method = 'get'; +exports.writes_db = false; +exports.authed = false; +exports.args = ['email']; + +exports.process = function(req, resp) { + var email = url.parse(req.url, true).query['email']; + db.emailKnown(email, function(known) { + resp.json({ email_known: known }); + }); +}; diff --git a/lib/wsapi/list_emails.js b/lib/wsapi/list_emails.js new file mode 100644 index 000000000..5263c2265 --- /dev/null +++ b/lib/wsapi/list_emails.js @@ -0,0 +1,22 @@ +const +db = require('../db.js'), +logger = require('../logging.js').logger; + +// returns a list of emails owned by the user: +// +// { +// "foo@foo.com" : {..properties..} +// ... +// } + +exports.method = 'get'; +exports.writes_db = false; +exports.authed = true; + +exports.process = function(req, resp) { + logger.debug('listing emails for ' + req.session.authenticatedUser); + db.listEmails(req.session.authenticatedUser, function(err, emails) { + if (err) httputils.serverError(resp, err); + else resp.json(emails); + }); +}; diff --git a/lib/wsapi/logout.js b/lib/wsapi/logout.js new file mode 100644 index 000000000..ff48f014b --- /dev/null +++ b/lib/wsapi/logout.js @@ -0,0 +1,11 @@ +const +wsapi = require('../wsapi.js'); + +exports.method = 'post'; +exports.writes_db = false; +exports.authed = true; + +exports.process = function(req, res) { + wsapi.clearAuthenticatedUser(req.session); + res.json({ success: true }); +}; diff --git a/lib/wsapi/remove_email.js b/lib/wsapi/remove_email.js new file mode 100644 index 000000000..404ee9ac6 --- /dev/null +++ b/lib/wsapi/remove_email.js @@ -0,0 +1,21 @@ +const +db = require('../db.js'), +httputils = require('../httputils'), +logger = require('../logging.js').logger; + +exports.method = 'post'; +exports.writes_db = true; +exports.authed = true; +exports.args = ['email']; + +exports.process = function(req, res) { + var email = req.body.email; + + db.removeEmail(req.session.authenticatedUser, email, function(error) { + if (error) { + logger.error("error removing email " + email); + httputils.badRequest(res, error.toString()); + } else { + res.json({ success: true }); + }}); +}; diff --git a/lib/wsapi/session_context.js b/lib/wsapi/session_context.js new file mode 100644 index 000000000..3a73014ef --- /dev/null +++ b/lib/wsapi/session_context.js @@ -0,0 +1,53 @@ +const +db = require('../db.js'), +logger = require('../logging.js').logger, +crypto = require('crypto'), +wsapi = require('../wsapi.js'); + +// return the CSRF token, authentication status, and current server time (for assertion signing) +// IMPORTANT: this is safe because it's only readable by same-origin code + +exports.method = 'get'; +exports.writes_db = false; +exports.authed = false; + +exports.process = function(req, res) { + if (typeof req.session == 'undefined') { + req.session = {}; + } + + if (typeof req.session.csrf == 'undefined') { + // FIXME: using express-csrf's approach for generating randomness + // not awesome, but probably sufficient for now. + req.session.csrf = crypto.createHash('md5').update('' + new Date().getTime()).digest('hex'); + logger.debug("NEW csrf token created: " + req.session.csrf); + } + + var auth_status = false; + + function sendResponse() { + res.json({ + csrf_token: req.session.csrf, + server_time: (new Date()).getTime(), + authenticated: auth_status + }); + }; + + // if they're authenticated for an email address that we don't know about, + // then we should purge the stored cookie + if (!wsapi.isAuthed(req)) { + logger.debug("user is not authenticated"); + sendResponse(); + } else { + db.emailKnown(req.session.authenticatedUser, function (known) { + if (!known) { + logger.debug("user is authenticated with an email that doesn't exist in the database"); + wsapi.clearAuthenticatedUser(req.session); + } else { + logger.debug("user is authenticated"); + auth_status = true; + } + sendResponse(); + }); + } +}; diff --git a/lib/wsapi/stage_email.js b/lib/wsapi/stage_email.js new file mode 100644 index 000000000..df2cbe07f --- /dev/null +++ b/lib/wsapi/stage_email.js @@ -0,0 +1,44 @@ +const +db = require('../db.js'), +wsapi = require('../wsapi.js'), +httputils = require('../httputils'), +logger = require('../logging.js').logger, +email = require('../email.js'); + +/* First half of account creation. Stages a user account for creation. + * this involves creating a secret url that must be delivered to the + * user via their claimed email address. Upon timeout expiry OR clickthrough + * the staged user account transitions to a valid user account + */ + +exports.method = 'post'; +exports.writes_db = true; +exports.authed = true; +exports.args = ['email','site']; + +exports.process = function(req, res) { + db.lastStaged(req.body.email, function (last) { + if (last && (new Date() - last) < config.get('min_time_between_emails_ms')) { + logger.warn('throttling request to stage email address ' + req.body.email + ', only ' + + ((new Date() - last) / 1000.0) + "s elapsed"); + return httputils.forbidden(res, "throttling. try again later."); + } + + try { + // on failure stageEmail may throw + db.stageEmail(req.session.authenticatedUser, req.body.email, function(secret) { + + // store the email being added in session data + req.session.pendingAddition = secret; + + res.json({ success: true }); + + // let's now kick out a verification email! + email.sendAddAddressEmail(req.body.email, req.body.site, secret); + }); + } catch(e) { + // we should differentiate tween' 400 and 500 here. + httputils.badRequest(res, e.toString()); + } + }); +}; diff --git a/lib/wsapi/stage_user.js b/lib/wsapi/stage_user.js new file mode 100644 index 000000000..7830bb840 --- /dev/null +++ b/lib/wsapi/stage_user.js @@ -0,0 +1,52 @@ +const +db = require('../db.js'), +wsapi = require('../wsapi.js'), +httputils = require('../httputils'), +logger = require('../logging.js').logger, +email = require('../email.js'); + +/* First half of account creation. Stages a user account for creation. + * this involves creating a secret url that must be delivered to the + * user via their claimed email address. Upon timeout expiry OR clickthrough + * the staged user account transitions to a valid user account + */ + +exports.method = 'post'; +exports.writes_db = true; +exports.authed = false; +exports.args = ['email','site']; + +exports.process = function(req, resp) { + // staging a user logs you out. + wsapi.clearAuthenticatedUser(req.session); + + db.lastStaged(req.body.email, function (last) { + if (last && (new Date() - last) < config.get('min_time_between_emails_ms')) { + logger.warn('throttling request to stage email address ' + req.body.email + ', only ' + + ((new Date() - last) / 1000.0) + "s elapsed"); + return httputils.forbidden(resp, "throttling. try again later."); + } + + try { + // upon success, stage_user returns a secret (that'll get baked into a url + // and given to the user), on failure it throws + db.stageUser(req.body.email, function(secret) { + // store the email being registered in the session data + if (!req.session) req.session = {}; + + // store the secret we're sending via email in the users session, as checking + // that it still exists in the database is the surest way to determine the + // status of the email verification. + req.session.pendingCreation = secret; + + resp.json({ success: true }); + + // let's now kick out a verification email! + email.sendNewUserEmail(req.body.email, req.body.site, secret); + }); + } catch(e) { + // we should differentiate tween' 400 and 500 here. + httputils.badRequest(resp, e.toString()); + } + }); +}; diff --git a/lib/wsapi/user_creation_status.js b/lib/wsapi/user_creation_status.js new file mode 100644 index 000000000..ea80db258 --- /dev/null +++ b/lib/wsapi/user_creation_status.js @@ -0,0 +1,35 @@ +const +db = require('../db.js'), +wsapi = require('../wsapi.js'); + +exports.method = 'get'; +exports.writes_db = false; +exports.authed = false; +exports.args = ['email']; + +exports.process = function(req, res) { + var email = req.query.email; + + // if the user is authenticated as the user in question, we're done + if (wsapi.isAuthed(req) && req.session.authenticatedUser === email) { + return res.json({ status: 'complete' }); + } + // if the user isn't authenticated and there's no pendingCreation token, + // then they must authenticate + else if (!req.session.pendingCreation) { + return res.json({ status: 'mustAuth' }); + } + + // if the secret is still in the database, it hasn't yet been verified and + // verification is still pending + db.emailForVerificationSecret(req.session.pendingCreation, function (email) { + if (email) return res.json({ status: 'pending' }); + // if the secret isn't known, and we're not authenticated, then the user must authenticate + // (maybe they verified the URL on a different browser, or maybe they canceled the account + // creation) + else { + delete req.session.pendingCreation; + res.json({ status: 'mustAuth' }); + } + }); +}; diff --git a/tests/ca-test.js b/tests/ca-test.js index 7336c88a0..028a182d1 100755 --- a/tests/ca-test.js +++ b/tests/ca-test.js @@ -41,7 +41,7 @@ const assert = require('assert'), vows = require('vows'), start_stop = require('./lib/start-stop.js'), wsapi = require('./lib/wsapi.js'), -email = require('../lib/browserid/email.js'), +email = require('../lib/email.js'), ca = require('../lib/keysigner/ca.js'), jwcert = require('jwcrypto/jwcert'), jwk = require('jwcrypto/jwk'), diff --git a/tests/cert-emails-test.js b/tests/cert-emails-test.js index e8c313a25..531f77b7f 100755 --- a/tests/cert-emails-test.js +++ b/tests/cert-emails-test.js @@ -41,7 +41,7 @@ const assert = require('assert'), vows = require('vows'), start_stop = require('./lib/start-stop.js'), wsapi = require('./lib/wsapi.js'), -email = require('../lib/browserid/email.js'), +email = require('../lib/email.js'), ca = require('../lib/keysigner/ca.js'), jwcert = require('jwcrypto/jwcert'), jwk = require('jwcrypto/jwk'), diff --git a/tests/cookie-session-security-test.js b/tests/cookie-session-security-test.js index 17705ed9d..1393ecbf4 100755 --- a/tests/cookie-session-security-test.js +++ b/tests/cookie-session-security-test.js @@ -42,7 +42,7 @@ vows = require('vows'), start_stop = require('./lib/start-stop.js'), wsapi = require('./lib/wsapi.js'), wcli = require('../lib/wsapi_client.js'); -email = require('../lib/browserid/email.js'), +email = require('../lib/email.js'), jwcert = require('jwcrypto/jwcert'), jwk = require('jwcrypto/jwk'), jws = require('jwcrypto/jws'); diff --git a/tests/forgotten-email-test.js b/tests/forgotten-email-test.js index 3f30279d7..14b2c2729 100755 --- a/tests/forgotten-email-test.js +++ b/tests/forgotten-email-test.js @@ -41,7 +41,7 @@ const assert = require('assert'), vows = require('vows'), start_stop = require('./lib/start-stop.js'), wsapi = require('./lib/wsapi.js'), -email = require('../lib/browserid/email.js'); +email = require('../lib/email.js'); var suite = vows.describe('forgotten-email'); diff --git a/tests/password-length-test.js b/tests/password-length-test.js index 0a4b7e2dc..e8d47283b 100755 --- a/tests/password-length-test.js +++ b/tests/password-length-test.js @@ -42,7 +42,7 @@ require('assert'), vows = require('vows'), start_stop = require('./lib/start-stop.js'), wsapi = require('./lib/wsapi.js'), -email = require('../lib/browserid/email.js'); +email = require('../lib/email.js'); var suite = vows.describe('password-length'); diff --git a/tests/registration-status-wsapi-test.js b/tests/registration-status-wsapi-test.js index 171974e91..dfb962282 100755 --- a/tests/registration-status-wsapi-test.js +++ b/tests/registration-status-wsapi-test.js @@ -117,7 +117,7 @@ suite.addBatch({ assert.strictEqual(r.code, 400); }, "returns an error string": function (r, err) { - assert.strictEqual(r.body, "Bad Request: no 'email' parameter"); + assert.strictEqual(r.body, "Bad Request: missing 'email' argument"); } } }); -- GitLab