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