From c9a175555dda81deaf6e8563b95ee166bd424fa9 Mon Sep 17 00:00:00 2001 From: Lloyd Hilaiel <lloyd@hilaiel.com> Date: Tue, 3 Jul 2012 20:29:51 +0100 Subject: [PATCH] implement reverification wsapis --- lib/db.js | 1 + lib/db/json.js | 28 ++++++- lib/db/mysql.js | 23 ++++++ lib/wsapi/cert_key.js | 4 +- lib/wsapi/complete_reverify.js | 61 ++++++++++++++++ lib/wsapi/email_for_token.js | 3 + lib/wsapi/email_reverify_status.js | 33 +++++++++ lib/wsapi/stage_reverify.js | 72 ++++++++++++++++++ tests/forgotten-pass-test.js | 113 ++++++++++++++++++++++++++++- 9 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 lib/wsapi/complete_reverify.js create mode 100644 lib/wsapi/email_reverify_status.js create mode 100644 lib/wsapi/stage_reverify.js diff --git a/lib/db.js b/lib/db.js index 379ed4909..ed0b46ad0 100644 --- a/lib/db.js +++ b/lib/db.js @@ -105,6 +105,7 @@ exports.onReady = function(f) { 'completeCreateUser', 'completeAddEmail', 'completePasswordReset', + 'completeReverify', 'removeEmail', 'cancelAccount', 'updatePassword', diff --git a/lib/db/json.js b/lib/db/json.js index 1d63f174c..4c0aa946e 100644 --- a/lib/db/json.js +++ b/lib/db/json.js @@ -315,6 +315,30 @@ exports.completeAddEmail = function(secret, cb) { }); } +exports.completeReverify = function(secret, cb) { + getAndDeleteRowForSecret(secret, function(err, o) { + 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 email = jsel.match(":has(.id:expr(x=?)) > .emails > .?", [ uid, o.email ], db.users); + if (!email.length) return cb("cannot find email"); + email = email[0]; + email.verified = true; + flush(); + + cb(err, o.email, uid); + }); + }); +}; + exports.completeCreateUser = function(secret, cb) { getAndDeleteRowForSecret(secret, function(err, o) { exports.emailKnown(o.email, function(err, known) { @@ -363,7 +387,7 @@ exports.completePasswordReset = function(secret, cb) { 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]; @@ -373,7 +397,7 @@ exports.completePasswordReset = function(secret, cb) { emails[email].verified = false; } }); - flush(); + flush(); // update the password! exports.updatePassword(uid, o.passwd, function(err) { diff --git a/lib/db/mysql.js b/lib/db/mysql.js index 263620ed5..185d1664e 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -403,6 +403,29 @@ exports.completeAddEmail = function(secret, cb) { }); }; +exports.completeReverify = 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 re-verification"); + + // 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"); + } + + // simply flip a bit + client.query( + 'UPDATE email SET verified = TRUE WHERE user = ? AND type = ? AND address = ?', + [ o.existing_user, 'secondary', o.email ], + function(err, rez) { + if (!rez || rez.affectedRows !== 1) cb("couldn't update email address"); + else cb(err, o.email, o.existing_user); + }); + }); +}; + exports.completePasswordReset = function(secret, cb) { getAndDeleteRowForSecret(secret, function(err, o) { if (err) return cb(err); diff --git a/lib/wsapi/cert_key.js b/lib/wsapi/cert_key.js index 0c0cb3e11..1f373b92a 100644 --- a/lib/wsapi/cert_key.js +++ b/lib/wsapi/cert_key.js @@ -21,8 +21,8 @@ exports.process = function(req, res) { db.userOwnsEmail(req.session.userid, req.body.email, function(err, owned) { if (err) return wsapi.databaseDown(res, err); - // not same account? big fat error - if (!owned) return httputils.badRequest(res, "that email does not belong to you"); + // not same account? big fat error + if (!owned) return httputils.badRequest(res, "that email does not belong to you"); // secondary addresses in the database may be "unverified". this occurs when // a user forgets their password. We will not issue certs for unverified email diff --git a/lib/wsapi/complete_reverify.js b/lib/wsapi/complete_reverify.js new file mode 100644 index 000000000..13f6a783b --- /dev/null +++ b/lib/wsapi/complete_reverify.js @@ -0,0 +1,61 @@ +/* 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 is not authenticated +exports.args = ['token']; +exports.i18n = false; + +exports.process = function(req, res) { + // in order to complete an email re-verification, one of the following must be true: + // + // 1. you must already be authenticated as the user who initiated the verification + // 2. you must provide the password of the initiator. + + db.authForVerificationSecret(req.body.token, function(err, initiator_hash, initiator_uid) { + if (err) { + logger.info("unknown verification secret: " + err); + return wsapi.databaseDown(res, err); + } + + if (req.session.userid === initiator_uid) { + postAuthentication(); + } else if (typeof req.body.pass === 'string') { + bcrypt.compare(req.body.pass, initiator_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 { + postAuthentication(); + } + }); + } else { + return httputils.authRequired(res, "password required"); + } + + function postAuthentication() { + db.completeReverify(req.body.token, function(e, email, uid) { + if (e) { + logger.warn("couldn't complete email verification: " + e); + wsapi.databaseDown(res, e); + } else { + wsapi.authenticateSession(req.session, uid, 'password'); + res.json({ success: true }); + } + }); + }; + }); +}; diff --git a/lib/wsapi/email_for_token.js b/lib/wsapi/email_for_token.js index 206b7e236..f5075e779 100644 --- a/lib/wsapi/email_for_token.js +++ b/lib/wsapi/email_for_token.js @@ -51,6 +51,9 @@ exports.process = function(req, res) { { must_auth = false; } + // NOTE: for reverification, we require you're authenticated. it's not enough + // to be on the same browser - that path is nonsensical because you must be + // authenticated to initiate a re-verification. res.json({ success: true, diff --git a/lib/wsapi/email_reverify_status.js b/lib/wsapi/email_reverify_status.js new file mode 100644 index 000000000..5068459c3 --- /dev/null +++ b/lib/wsapi/email_reverify_status.js @@ -0,0 +1,33 @@ +/* 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'); + +/* A polled API which returns whether the user has completed reverification + * of an email address + */ + +exports.method = 'get'; +exports.writes_db = false; +exports.authed = 'assertion'; +exports.args = ['email']; +exports.i18n = false; + +exports.process = function(req, res) { + var email = req.query.email; + + // For simplicity, all we check is if an email is verified. We do not check that + // the email is owned by the currently authenticated user, nor that the verification + // secret still exists. These checks would require more database interactions, and + // other calls will fail in such circumstances. + + // is the address verified? + db.emailIsVerified(email, function(err, verified) { + if (err) return wsapi.databaseDown(res, err); + + res.json({ status: verified ? 'complete' : 'pending' }); + }); +}; diff --git a/lib/wsapi/stage_reverify.js b/lib/wsapi/stage_reverify.js new file mode 100644 index 000000000..100e252fe --- /dev/null +++ b/lib/wsapi/stage_reverify.js @@ -0,0 +1,72 @@ +/* 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'); + +/* Stage an email for addition to a user's account. Causes email to be sent. */ + +exports.method = 'post'; +exports.writes_db = true; +exports.authed = 'assertion'; +exports.args = ['email','site']; +exports.i18n = true; + +exports.process = function(req, res) { + // 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); + } + + 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."); + } + + // one may only reverify an email that is owned and unverified + db.userOwnsEmail(req.session.userid, req.body.email, function(err, owned) { + if (err) return res.json({ success: false, reason: err }); + if (!owned) return res.json({ success: false, reason: 'you don\'t control that email address' }); + + db.emailIsVerified(req.body.email, function(err, verified) { + if (err) return res.json({ success: false, reason: err }); + if (verified) return res.json({ success: false, reason: 'email is already verified' }); + + try { + // on failure stageEmail may throw + db.stageEmail(req.session.userid, req.body.email, undefined, function(err, secret) { + if (err) return wsapi.databaseDown(res, err); + + var langContext = wsapi.langContext(req); + + // store the email being reverified + req.session.pendingReverification = secret; + + res.json({ success: true }); + // let's now kick out a verification email! + email.sendAddAddressEmail(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/forgotten-pass-test.js b/tests/forgotten-pass-test.js index a4342dea9..958fe071a 100755 --- a/tests/forgotten-pass-test.js +++ b/tests/forgotten-pass-test.js @@ -344,8 +344,119 @@ suite.addBatch({ } }); +// Now we have an account with an unverified email. Let's attempt to reverify our other email +// address +// Run the "forgot_email" flow with first address. +suite.addBatch({ + "reverify a non-existent email": { + topic: wsapi.post('/wsapi/stage_reverify', { + email: 'dne@fakeemail.com', + site:'https://otherfakesite.com' + }), + "fails": function(err, r) { + assert.strictEqual(r.code, 200); + assert.strictEqual(JSON.parse(r.body).success, false); + } + }, + "reverify a verified email": { + topic: wsapi.post('/wsapi/stage_reverify', { + email: 'first@fakeemail.com', + site:'https://otherfakesite.com' + }), + "fails": function(err, r) { + assert.strictEqual(r.code, 200); + assert.strictEqual(JSON.parse(r.body).success, false); + } + }, + "reverify an unverified email": { + topic: wsapi.post('/wsapi/stage_reverify', { + email: 'second@fakeemail.com', + site:'https://otherfakesite.com' + }), + "works": function(err, r) { + assert.strictEqual(r.code, 200); + assert.strictEqual(JSON.parse(r.body).success, true); + } + } +}); + +suite.addBatch({ + "a token": { + topic: function() { + start_stop.waitForToken(this.callback); + }, + "is obtained": function (t) { + assert.strictEqual(typeof t, 'string'); + token = t; + } + } +}); + +suite.addBatch({ + "given a token, getting an email": { + topic: function() { + wsapi.get('/wsapi/email_for_token', { token: token }).call(this); + }, + "works dandy": function(err, r) { + assert.equal(r.code, 200); + var body = JSON.parse(r.body); + assert.strictEqual(body.success, true); + assert.strictEqual(body.email, 'second@fakeemail.com'); + assert.strictEqual(body.must_auth, false); + } + } +}); + +suite.addBatch({ + "reverify status": { + topic: function() { + wsapi.get('/wsapi/email_reverify_status', { email: "second@fakeemail.com" }).call(this); + }, + "is pending": function(err, r) { + assert.equal(r.code, 200); + var body = JSON.parse(r.body); + assert.strictEqual(body.status, 'pending'); + } + } +}); + +suite.addBatch({ + "complete reverify": { + topic: function() { + wsapi.post('/wsapi/complete_reverify', { token: token }).call(this); + }, + "works": function(err, r) { + assert.equal(r.code, 200); + var body = JSON.parse(r.body); + } + } +}); + +suite.addBatch({ + "after reverification": { + topic: function() { + jwcrypto.generateKeypair({algorithm: "RS", keysize: 64}, this.callback); + }, + "we can generate a keypair": function(err, keypair) { + assert.isNull(err); + assert.isObject(keypair); + kp = keypair; + }, + "we can certify a key for the email address": { + topic: function() { + wsapi.post('/wsapi/cert_key', { + email: 'second@fakeemail.com', + pubkey: kp.publicKey.serialize(), + ephemeral: false + }).call(this); + }, + "returns a success response" : function(err, r) { + assert.strictEqual(r.code, 200); + } + } + } +}); -// XXX: test that we can verify the remaining email ok start_stop.addShutdownBatches(suite); -- GitLab