From bc14ded655a0b0c6d121cae7d19d976c726d7e96 Mon Sep 17 00:00:00 2001 From: Lloyd Hilaiel <lloyd@hilaiel.com> Date: Tue, 3 Jul 2012 10:55:36 +0100 Subject: [PATCH] implement complete_reset, stage_reset and refactor database code to support this addition, add tests --- lib/db.js | 4 +- lib/db/json.js | 95 ++++++++++----- lib/db/mysql.js | 115 ++++++++++++------ lib/wsapi/complete_email_addition.js | 2 +- lib/wsapi/complete_reset.js | 86 +++++++++++++ lib/wsapi/complete_user_creation.js | 2 +- lib/wsapi/stage_reset.js | 104 ++++++++++++++++ tests/db-test.js | 4 +- ...n-email-test.js => forgotten-pass-test.js} | 45 ++++--- tests/verify-in-different-browser-test.js | 2 +- 10 files changed, 373 insertions(+), 86 deletions(-) create mode 100644 lib/wsapi/complete_reset.js create mode 100644 lib/wsapi/stage_reset.js rename tests/{forgotten-email-test.js => forgotten-pass-test.js} (88%) diff --git a/lib/db.js b/lib/db.js index 1f08f6e99..27826a0fb 100644 --- a/lib/db.js +++ b/lib/db.js @@ -101,7 +101,9 @@ exports.onReady = function(f) { [ 'stageUser', 'stageEmail', - 'gotVerificationSecret', + 'completeCreateUser', + 'completeAddEmail', + 'completePasswordReset', 'removeEmail', 'cancelAccount', 'updatePassword', diff --git a/lib/db/json.js b/lib/db/json.js index 34cb13b3e..9d57297a1 100644 --- a/lib/db/json.js +++ b/lib/db/json.js @@ -263,7 +263,7 @@ exports.verificationSecretForEmail = function(email, cb) { }, 0); }; -exports.gotVerificationSecret = function(secret, cb) { +function getAndDeleteRowForSecret(secret, cb) { sync(); if (!db.staged.hasOwnProperty(secret)) return cb("unknown secret"); @@ -272,7 +272,42 @@ exports.gotVerificationSecret = function(secret, cb) { delete db.staged[secret]; delete db.stagedEmails[o.email]; flush(); - if (o.type === 'add_account') { + + process.nextTick(function() { cb(null, o); }); +} + +exports.completeAddEmail = function(secret, cb) { + getAndDeleteRowForSecret(secret, function(err, o) { + exports.emailKnown(o.email, function(err, known) { + function addIt() { + addEmailToAccount(o.existing_user, o.email, 'secondary', function(e) { + var hash = o.passwd; + if(e || hash === null) return cb(e, o.email, o.existing_user); + + // a hash was specified, update the password for the user + exports.emailToUID(o.email, function(err, uid) { + if(err) return cb(err, o.email, o.existing_user); + + exports.updatePassword(uid, hash, function(err) { + cb(err || null, o.email, o.existing_user); + }); + }); + }); + } + if (known) { + removeEmailNoCheck(o.email, function (err) { + if (err) cb(err); + else addIt(); + }); + } else { + addIt(); + } + }); + }); +} + +exports.completeCreateUser = function(secret, cb) { + getAndDeleteRowForSecret(secret, function(err, o) { exports.emailKnown(o.email, function(err, known) { function createAccount() { var emailVal = {}; @@ -303,35 +338,39 @@ exports.gotVerificationSecret = function(secret, cb) { createAccount(); } }); - } else if (o.type === 'add_email') { - exports.emailKnown(o.email, function(err, known) { - function addIt() { - addEmailToAccount(o.existing_user, o.email, 'secondary', function(e) { - var hash = o.passwd; - if(e || hash === null) return cb(e, o.email, o.existing_user); - - // a hash was specified, update the password for the user - exports.emailToUID(o.email, function(err, uid) { - if(err) return cb(err, o.email, o.existing_user); + }); +}; - exports.updatePassword(uid, hash, function(err) { - cb(err || null, o.email, o.existing_user); - }); - }); - }); - } - if (known) { - removeEmailNoCheck(o.email, function (err) { - if (err) cb(err); - else addIt(); +exports.completePasswordReset = function(secret, cb) { + getAndDeleteRowForSecret(secret, function(err, o) { + exports.emailKnown(o.email, function(err, known) { + if (err) return cb(err); + + exports.emailToUID(o.email, function(err, uid) { + if (err) return cb(err); + + // if for some reason the email is associated with a different user now than when + // the action was initiated, error out. + if (uid !== o.existing_user) { + return cb("cannot update password, data inconsistency"); + } + + sync(); + // flip the verification bit on all emails for the user other than the one just verified + var emails = jsel.match(":has(.id:expr(x=?)) > .emails", [ uid ], db.users)[0]; + + Object.keys(emails).forEach(function(email) { + if (email != o.email && emails[email].type === 'secondary') { + emails[email].verified = false; + } }); - } else { - addIt(); - } + flush(); + + // update the password! + exports.updatePassword(uid, o.passwd, cb); + }); }); - } else { - cb("internal error"); - } + }); }; exports.addPrimaryEmailToAccount = function(userID, emailToAdd, cb) { diff --git a/lib/db/mysql.js b/lib/db/mysql.js index b1461c304..dcd1cadb2 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -15,6 +15,7 @@ * | string passwd | \- |*int user | * +---------------+ |*string address| * | enum type | + * | bool verified | * +---------------+ * * @@ -332,7 +333,7 @@ function addEmailToUser(userID, email, type, cb) { }); } -exports.gotVerificationSecret = function(secret, cb) { +function getAndDeleteRowForSecret(secret, cb) { client.query( "SELECT * FROM staged WHERE secret = ?", [ secret ], function(err, rows) { @@ -342,45 +343,87 @@ exports.gotVerificationSecret = function(secret, cb) { } else if (rows.length === 0) { cb("unknown secret"); } else { - var o = rows[0]; - // delete the record client.query("DELETE LOW_PRIORITY FROM staged WHERE secret = ?", [ secret ]); + cb(null, rows[0]); + } + }); +} - if (o.new_acct) { - var hash = o.passwd; - // we're creating a new account, add appropriate entries into user and email tables. - client.query( - "INSERT INTO user(passwd) VALUES(?)", - [ hash ], - function(err, info) { - if (err) return cb(err); - addEmailToUser(info.insertId, o.email, 'secondary', cb); - }); - } else { - // ensure the expected existing_user field is populated, which it must always be when - // new_acct is false - if (typeof o.existing_user !== 'number') { - return cb("data inconsistency, no numeric existing user associated with staged email address"); - } +exports.completeCreateUser = function(secret, cb) { + getAndDeleteRowForSecret(secret, function(err, o) { + if (err) return cb(err); + + if (!o.new_acct) return cb("this verification link is not for a new account"); - // we're adding an email address to an existing user account. add appropriate entries into - // email table - var hash = o.passwd; - var uid = o.existing_user; - if (hash) { - exports.updatePassword(uid, hash, function(err) { - if (err) return cb('could not set user\'s password'); - addEmailToUser(uid, o.email, 'secondary', cb); - }); - } else { - addEmailToUser(uid, o.email, 'secondary', cb); - } - } - }; + // we're creating a new account, add appropriate entries into user and email tables. + client.query( + "INSERT INTO user(passwd) VALUES(?)", + [ o.passwd ], + function(err, info) { + if (err) return cb(err); + addEmailToUser(info.insertId, o.email, 'secondary', cb); + }); + }); +}; + +exports.completeAddEmail = function(secret, cb) { + getAndDeleteRowForSecret(secret, function(err, o) { + if (err) return cb(err); + + if (o.new_acct) return cb("this verification link is not for an email addition"); + + // ensure the expected existing_user field is populated, which it must always be when + // new_acct is false + if (typeof o.existing_user !== 'number') { + return cb("data inconsistency, no numeric existing user associated with staged email address"); } - ); -} + + // we're adding an email address to an existing user account. add appropriate entries into + // email table + if (o.passwd) { + exports.updatePassword(o.existing_user, o.passwd, function(err) { + if (err) return cb('could not set user\'s password'); + addEmailToUser(o.existing_user, o.email, 'secondary', cb); + }); + } else { + addEmailToUser(o.existing_user, o.email, 'secondary', cb); + } + }); +}; + +exports.completePasswordReset = function(secret, cb) { + getAndDeleteRowForSecret(secret, function(err, o) { + if (err) return cb(err); + + if (o.new_acct || !o.passwd || !o.existing_user) { + return cb("this verification link is not for a password reset"); + } + + // verify that the email still exists in the database, and the the user with whom it is + // associated is the same as the user in the database + exports.emailToUID(o.email, function(err, uid) { + if (err) return cb(err); + + // if for some reason the email is associated with a different user now than when + // the action was initiated, error out. + if (uid !== o.existing_user) { + return cb("cannot update password, data inconsistency"); + } + + // flip the verification bit on all emails for the user other than the one just verified + client.query( + 'UPDATE email SET verified = FALSE WHERE user = ? AND type = ? AND address != ?', + [ uid, 'secondary', o.email ], + function(err) { + if (err) return cb(err); + + // update the password! + exports.updatePassword(uid, o.passwd, cb); + }); + }); + }); +}; exports.addPrimaryEmailToAccount = function(uid, emailToAdd, cb) { // we're adding an email address to an existing user account. add appropriate entries into @@ -481,7 +524,7 @@ exports.listEmails = function(uid, cb) { for (var i = 0; i < rows.length; i++) { var o = { type: rows[i].type }; if (o.type === 'secondary') { - o.verified = rows[i].verified; + o.verified = rows[i].verified ? true : false; } emails[rows[i].address] = o; } diff --git a/lib/wsapi/complete_email_addition.js b/lib/wsapi/complete_email_addition.js index 53ddb3625..f903a28ad 100644 --- a/lib/wsapi/complete_email_addition.js +++ b/lib/wsapi/complete_email_addition.js @@ -47,7 +47,7 @@ exports.process = function(req, res) { } function postAuthentication() { - db.gotVerificationSecret(req.body.token, function(e, email, uid) { + db.completeAddEmail(req.body.token, function(e, email, uid) { if (e) { logger.warn("couldn't complete email verification: " + e); wsapi.databaseDown(res, e); diff --git a/lib/wsapi/complete_reset.js b/lib/wsapi/complete_reset.js new file mode 100644 index 000000000..32ea867c2 --- /dev/null +++ b/lib/wsapi/complete_reset.js @@ -0,0 +1,86 @@ +/* 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/. */ + +const +db = require('../db.js'), +logger = require('../logging.js').logger, +wsapi = require('../wsapi.js'), +bcrypt = require('../bcrypt.js'), +httputils = require('../httputils.js'); + +exports.method = 'post'; +exports.writes_db = true; +exports.authed = false; +// NOTE: this API also takes a 'pass' parameter which is required +// when a user has a null password (only primaries on their acct) +exports.args = ['token']; +exports.i18n = true; + +exports.process = function(req, res) { + // in order to complete a password reset, one of the following must be true: + // + // 1. you are using the same browser to complete the email verification as you + // used to start it + // 2. you have provided the password chosen by the initiator of the verification + // request + + // is this the same browser? + if (typeof req.session.pendingReset === 'string' && + req.body.token === req.session.pendingReset) { + return postAuthentication(); + } + // is a password provided? + else if (typeof req.body.pass === 'string') { + return db.authForVerificationSecret(req.body.token, function(err, hash) { + if (err) { + logger.warn("couldn't get password for verification secret: " + err); + return wsapi.databaseDown(res, err); + } + + bcrypt.compare(req.body.pass, hash, function (err, success) { + if (err) { + logger.warn("max load hit, failing on auth request with 503: " + err); + return httputils.serviceUnavailable(res, "server is too busy"); + } else if (!success) { + return httputils.authRequired(res, "password mismatch"); + } else { + return postAuthentication(); + } + }); + }); + } else { + return httputils.authRequired(res, 'Provide your password'); + } + + function postAuthentication() { + db.haveVerificationSecret(req.body.token, function(err, known) { + if (err) return wsapi.databaseDown(res, err); + + if (!known) { + // clear the pendingCreation token from the session if we find no such + // token in the database + delete req.session.pendingCreation; + return res.json({ success: false} ); + } + + db.completePasswordReset(req.body.token, function(err, email, uid) { + if (err) { + logger.warn("couldn't complete email verification: " + err); + wsapi.databaseDown(res, err); + } else { + // clear the pendingCreation token from the session once we + // successfully complete user creation + delete req.session.pendingCreation; + + // At this point, the user is either on the same browser with a token from + // their email address, OR they've provided their account password. It's + // safe to grant them an authenticated session. + wsapi.authenticateSession(req.session, uid, 'password', + config.get('ephemeral_session_duration_ms')); + res.json({ success: true }); + } + }); + }); + } +}; diff --git a/lib/wsapi/complete_user_creation.js b/lib/wsapi/complete_user_creation.js index 7a65ec488..5e253d38a 100644 --- a/lib/wsapi/complete_user_creation.js +++ b/lib/wsapi/complete_user_creation.js @@ -68,7 +68,7 @@ exports.process = function(req, res) { return res.json({ success: false} ); } - db.gotVerificationSecret(req.body.token, function(err, email, uid) { + db.completeCreateUser(req.body.token, function(err, email, uid) { if (err) { logger.warn("couldn't complete email verification: " + err); wsapi.databaseDown(res, err); diff --git a/lib/wsapi/stage_reset.js b/lib/wsapi/stage_reset.js new file mode 100644 index 000000000..ccc0a4eca --- /dev/null +++ b/lib/wsapi/stage_reset.js @@ -0,0 +1,104 @@ +/* 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/. */ + +const +db = require('../db.js'), +wsapi = require('../wsapi.js'), +httputils = require('../httputils'), +logger = require('../logging.js').logger, +email = require('../email.js'), +sanitize = require('../sanitize'), +config = require('../configuration'); + +/* 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','pass']; +exports.i18n = true; + +exports.process = function(req, res) { + // a password *must* be supplied to this call iff the user's password + // is currently NULL - this would occur in the case where this is the + // first secondary address to be added to an account + + // validate + try { + sanitize(req.body.email).isEmail(); + sanitize(req.body.site).isOrigin(); + } catch(e) { + var msg = "invalid arguments: " + e; + logger.warn("bad request received: " + msg); + return httputils.badRequest(res, msg); + } + + var err = wsapi.checkPassword(req.body.pass); + if (err) { + logger.warn("invalid password received: " + err); + return httputils.badRequest(res, err); + } + + db.lastStaged(req.body.email, function (err, last) { + if (err) return wsapi.databaseDown(res, err); + + 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.throttled(res, "Too many emails sent to that address, try again later."); + } + + db.emailToUID(req.body.email, function(err, uid) { + if (err) { + logger.info("reset password fails: " + err); + return res.json({ success: false }); + } + + if (!uid) { + return res.json({ + reason: "No such email address.", + success: false + }); + } + + // staging a user logs you out. + wsapi.clearAuthenticatedUser(req.session); + + // now bcrypt the password + wsapi.bcryptPassword(req.body.pass, function (err, hash) { + if (err) { + if (err.indexOf('exceeded') != -1) { + logger.warn("max load hit, failing on auth request with 503: " + err); + return httputils.serviceUnavailable(res, "server is too busy"); + } + logger.error("can't bcrypt: " + err); + return res.json({ success: false }); + } + + // on failure stageEmail may throw + try { + db.stageEmail(uid, req.body.email, hash, function(err, secret) { + if (err) return wsapi.databaseDown(res, err); + + var langContext = wsapi.langContext(req); + + // store the email being added in session data + req.session.pendingReset = secret; + + res.json({ success: true }); + // let's now kick out a verification email! + email.sendForgotPasswordEmail(req.body.email, req.body.site, secret, langContext); + }); + } catch(e) { + // we should differentiate tween' 400 and 500 here. + httputils.badRequest(res, e.toString()); + } + }); + }); + }); +}; diff --git a/tests/db-test.js b/tests/db-test.js index 6c8ec1ea5..d17038d8b 100755 --- a/tests/db-test.js +++ b/tests/db-test.js @@ -125,7 +125,7 @@ suite.addBatch({ suite.addBatch({ "upon receipt of a secret": { topic: function() { - db.gotVerificationSecret(secret, this.callback); + db.completeCreateUser(secret, this.callback); }, "gotVerificationSecret completes without error": function (err, r) { assert.isNull(err); @@ -215,7 +215,7 @@ suite.addBatch({ "makes it visible via isStaged": function(sekret, r) { assert.isTrue(r); }, "lets you verify it": { topic: function(secret, r) { - db.gotVerificationSecret(secret, this.callback); + db.completeAddEmail(secret, this.callback); }, "successfully": function(err, r) { assert.isNull(err); diff --git a/tests/forgotten-email-test.js b/tests/forgotten-pass-test.js similarity index 88% rename from tests/forgotten-email-test.js rename to tests/forgotten-pass-test.js index c2e6aa382..a9ca24042 100755 --- a/tests/forgotten-email-test.js +++ b/tests/forgotten-pass-test.js @@ -132,11 +132,10 @@ suite.addBatch({ } }); -// Run the "forgot_email" flow with first address. This is really -// just re-registering the user. +// Run the "forgot_email" flow with first address. suite.addBatch({ "re-stage first account": { - topic: wsapi.post('/wsapi/stage_user', { + topic: wsapi.post('/wsapi/stage_reset', { email: 'first@fakeemail.com', pass: 'secondfakepass', site:'https://otherfakesite.com' @@ -187,9 +186,9 @@ suite.addBatch({ // now let's complete the re-registration of first email address suite.addBatch({ - "re-create first email address": { + "complete password reset": { topic: function() { - wsapi.post('/wsapi/complete_user_creation', { token: token }).call(this); + wsapi.post('/wsapi/complete_reset', { token: token }).call(this); }, "account created": function(err, r) { assert.equal(r.code, 200); @@ -198,8 +197,7 @@ suite.addBatch({ } }); -// now we should be able to sign into the first email address with the first -// password, and all other combinations should fail +// now we should be able to sign in using any email address suite.addBatch({ "first email, first pass bad": { topic: wsapi.post('/wsapi/authenticate_user', { @@ -221,20 +219,14 @@ suite.addBatch({ assert.strictEqual(JSON.parse(r.body).success, true); } }, - "logout": { - topic: wsapi.post('/wsapi/logout', {}), - "should work": function(err, r) { - assert.strictEqual(JSON.parse(r.body).success, true); - } - }, - "second email, first pass good": { + "second email, first pass bad": { topic: wsapi.post('/wsapi/authenticate_user', { email: 'second@fakeemail.com', pass: 'firstfakepass', ephemeral: false }), "should work": function(err, r) { - assert.strictEqual(JSON.parse(r.body).success, true); + assert.strictEqual(JSON.parse(r.body).success, false); } }, "second email, second pass bad": { @@ -244,11 +236,32 @@ suite.addBatch({ ephemeral: false }), "shouldn' work": function(err, r) { - assert.strictEqual(JSON.parse(r.body).success, false); + assert.strictEqual(JSON.parse(r.body).success, true); } }, }); +// test list emails +suite.addBatch({ + "list emails API": { + topic: wsapi.get('/wsapi/list_emails', {}), + "succeeds with HTTP 200" : function(err, r) { + assert.strictEqual(r.code, 200); + }, + "returns an object with proper bits set": function(err, r) { + r = JSON.parse(r.body); + assert.strictEqual(r['second@fakeemail.com'].verified, false); + assert.strictEqual(r['first@fakeemail.com'].verified, true); + } + } +}); + + +// XXX: test that verification of unverified emails fails + +// XXX: test that we can verify the remaining email ok + + start_stop.addShutdownBatches(suite); // run or export the suite. diff --git a/tests/verify-in-different-browser-test.js b/tests/verify-in-different-browser-test.js index b5a5a0fbb..b221322a8 100755 --- a/tests/verify-in-different-browser-test.js +++ b/tests/verify-in-different-browser-test.js @@ -254,7 +254,7 @@ suite.addBatch({ }, "but succeeds": { topic: function() { - wsapi.post('/wsapi/complete_email_addition', { + wsapi.post('/wsapi/complete_user_creation', { token: this._token, pass: TEST_PASS }).call(this); -- GitLab