diff --git a/lib/db.js b/lib/db.js index 7caf732f50790814dd69b15684ffbbab5df8ebd1..22fc1da0021d7cc5d004a64397234040f73f617d 100644 --- a/lib/db.js +++ b/lib/db.js @@ -108,6 +108,7 @@ exports.onReady = function(f) { 'isStaged', 'emailsBelongToSameAccount', 'emailForVerificationSecret', + 'haveVerificationSecret', 'verificationSecretForEmail', 'checkAuth', 'listEmails', diff --git a/lib/db/json.js b/lib/db/json.js index 9d0133880c31a01a545ab9dbac3b4047866598f3..831721a98511bb027410123aba633b9cf5f1005b 100644 --- a/lib/db/json.js +++ b/lib/db/json.js @@ -230,11 +230,26 @@ exports.createUserWithPrimaryEmail = function(email, cb) { }); }; +exports.haveVerificationSecret = function(secret, cb) { + process.nextTick(function() { + sync(); + cb(!!(db.staged[secret])); + }); +}; + + exports.emailForVerificationSecret = function(secret, cb) { - setTimeout(function() { + process.nextTick(function() { sync(); - cb(db.staged[secret] ? db.staged[secret].email : undefined); - }, 0); + if (!db.staged[secret]) return cb("no such secret"); + exports.checkAuth(db.staged[secret].email, function (hash) { + console.log(db.staged[secret].email, hash); + cb(undefined, { + email: db.staged[secret].email, + needs_password: !hash + }); + }); + }); }; exports.verificationSecretForEmail = function(email, cb) { diff --git a/lib/db/mysql.js b/lib/db/mysql.js index 660de0b5afda46a7920162963c7174fdf215020a..bed01cde4db4d874a294920000d33a63b187f274 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -280,12 +280,40 @@ exports.stageUser = function(email, cb) { }); }; + +exports.haveVerificationSecret = function(secret, cb) { + client.query( + "SELECT count(*) as n FROM staged WHERE secret = ?", [ secret ], + function(err, rows) { + if (err) cb(false); + else cb(rows.length === 1 && rows[0].n === 1); + }); +}; + exports.emailForVerificationSecret = function(secret, cb) { client.query( - "SELECT email FROM staged WHERE secret = ?", [ secret ], + "SELECT email, new_acct, existing FROM staged WHERE secret = ?", [ secret ], function(err, rows) { if (err) logUnexpectedError(err); - cb((rows && rows.length > 0) ? rows[0].email : undefined); + // if the record was not found, fail out + if (!rows || rows.length != 1) return cb("no such secret"); + + var o = rows[0]; + + // if the record was found and this is for a new_acct, return the email + if (o.new_acct) return cb(undefined, { email: o.email, needs_password: false }); + + // if the account is being added to an existing account, let's find + // out if the account has a password set (if only primary email addresses + // are associated with the acct at the moment, then there will not be a + // password set and the user will need to set one with the addition of + // this addresss) + exports.checkAuth(o.email, function(hash) { + cb(undefined, { + email: o.email, + needs_password: (hash == null) + }); + }); }); }; diff --git a/lib/wsapi/complete_email_addition.js b/lib/wsapi/complete_email_addition.js index 00a4422719e7dced325861bcaa9ca190be0cbd4b..cee00dfad1576104e323b675de02f7cb706e3328 100644 --- a/lib/wsapi/complete_email_addition.js +++ b/lib/wsapi/complete_email_addition.js @@ -5,10 +5,15 @@ logger = require('../logging.js').logger; exports.method = 'post'; exports.writes_db = true; // XXX: see issue #290 - we want to require authentication here and update frontend code -exports.authed = false; +exports.authed = true; exports.args = ['token']; 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 + db.checkAuth + db.gotVerificationSecret(req.body.token, undefined, function(e) { if (e) { logger.warn("couldn't complete email verification: " + e); diff --git a/lib/wsapi/complete_user_creation.js b/lib/wsapi/complete_user_creation.js index 1e586c14506f3126d5bb763c76f21896508af4c2..aeefb9dfa835b15193cd57f1f0368b94b246f03a 100644 --- a/lib/wsapi/complete_user_creation.js +++ b/lib/wsapi/complete_user_creation.js @@ -23,8 +23,8 @@ exports.process = function(req, res) { // 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} ); + db.haveVerificationSecret(req.body.token, function(known) { + if (!known) return res.json({ success: false} ); // now bcrypt the password wsapi.bcryptPassword(req.body.pass, function (err, hash) { diff --git a/lib/wsapi/email_addition_status.js b/lib/wsapi/email_addition_status.js index 87d387cdb2f1ea810d8a7a2f671388a256c5588a..f8b2cef27b94aa6d9fa56ef9949a59a90ac53da6 100644 --- a/lib/wsapi/email_addition_status.js +++ b/lib/wsapi/email_addition_status.js @@ -27,8 +27,8 @@ exports.process = function(req, res) { } else if (!req.session.pendingAddition) { res.json({ status: 'failed' }); } else { - db.emailForVerificationSecret(req.session.pendingAddition, function (email) { - if (email) { + db.haveVerificationSecret(req.session.pendingAddition, function (known) { + if (known) { return res.json({ status: 'pending' }); } else { delete req.session.pendingAddition; diff --git a/lib/wsapi/email_for_token.js b/lib/wsapi/email_for_token.js index 3a6f70748e49bc45a58f4c3a1cbd210933b1cf86..7e9497023f5d44885cb5ce15c2c6048e99903b87 100644 --- a/lib/wsapi/email_for_token.js +++ b/lib/wsapi/email_for_token.js @@ -13,7 +13,18 @@ exports.authed = false; exports.args = ['token']; exports.process = function(req, res) { - db.emailForVerificationSecret(req.query.token, function(email) { - res.json({ email: email }); + db.emailForVerificationSecret(req.query.token, function(err, r) { + if (err) { + res.json({ + success: false, + reason: err + }); + } else { + res.json({ + success: true, + email: r.email, + needs_password: r.needs_password + }); + } }); }; diff --git a/lib/wsapi/user_creation_status.js b/lib/wsapi/user_creation_status.js index ea80db25824c9f86c1b93455a7e6c6daeee3cdef..04461bfcac795bd1ccf9a703020bfa8a9962e592 100644 --- a/lib/wsapi/user_creation_status.js +++ b/lib/wsapi/user_creation_status.js @@ -22,8 +22,8 @@ exports.process = function(req, res) { // 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' }); + db.haveVerificationSecret(req.session.pendingCreation, function (known) { + if (known) 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) diff --git a/tests/auth-with-assertion-test.js b/tests/auth-with-assertion-test.js index 6b0c58d6642ec61bc19e50b2d1b0defa07ebce44..4ec2c50b89e0b5b6da2efa14e99a23307f467fdd 100755 --- a/tests/auth-with-assertion-test.js +++ b/tests/auth-with-assertion-test.js @@ -44,13 +44,9 @@ start_stop = require('./lib/start-stop.js'), wsapi = require('./lib/wsapi.js'), db = require('../lib/db.js'), config = require('../lib/configuration.js'), -jwk = require('jwcrypto/jwk.js'), -jwt = require('jwcrypto/jwt.js'), -vep = require('jwcrypto/vep.js'), -jwcert = require('jwcrypto/jwcert.js'), http = require('http'), querystring = require('querystring'), -path = require("path"); +primary = require('./lib/primary.js'); var suite = vows.describe('auth-with-assertion'); @@ -63,61 +59,19 @@ const TEST_DOMAIN = 'example.domain', TEST_EMAIL = 'testuser@' + TEST_DOMAIN, TEST_ORIGIN = 'http://127.0.0.1:10002'; -var token = undefined; - // here we go! let's authenticate with an assertion from // a primary. -// now we need to generate a keypair and a certificate -// signed by our in tree authority -var g_keypair, g_cert; - -suite.addBatch({ - "generating a keypair": { - topic: function() { - return jwk.KeyPair.generate("DS", 256) - }, - "succeeds": function(r, err) { - assert.isObject(r); - assert.isObject(r.publicKey); - assert.isObject(r.secretKey); - g_keypair = r; - } - } -}); - -// for this trick we'll need the "secret" key of our built in -// primary -var g_privKey = jwk.SecretKey.fromSimpleObject( - JSON.parse(require('fs').readFileSync( - path.join(__dirname, '..', 'example', 'primary', 'sample.privatekey')))); - - -suite.addBatch({ - "generting a certificate": { - topic: function() { - var domain = process.env['SHIMMED_DOMAIN']; - - var expiration = new Date(); - expiration.setTime(new Date().valueOf() + 60 * 60 * 1000); - g_cert = new jwcert.JWCert(TEST_DOMAIN, expiration, new Date(), - g_keypair.publicKey, {email: TEST_EMAIL}).sign(g_privKey); - return g_cert; - }, - "works swimmingly": function(cert, err) { - assert.isString(cert); - assert.lengthOf(cert.split('.'), 3); - } - } +var primaryUser = new primary({ + email: TEST_EMAIL, + domain: TEST_DOMAIN }); -// now let's generate an assertion using the cert +// now let's generate an assertion using this user suite.addBatch({ "generating an assertion": { topic: function() { - var expirationDate = new Date(new Date().getTime() + (2 * 60 * 1000)); - var tok = new jwt.JWT(null, expirationDate, TEST_ORIGIN); - return vep.bundleCertsAndAssertion([g_cert], tok.sign(g_keypair.secretKey)); + return primaryUser.getAssertion(TEST_ORIGIN); }, "succeeds": function(r, err) { assert.isString(r); diff --git a/tests/db-test.js b/tests/db-test.js index f9ae288704b614a7b0c6a7b5cc79c1c22ca91cb6..59d4b70cf73457b3fb5ca426e13fd95bcafea579 100755 --- a/tests/db-test.js +++ b/tests/db-test.js @@ -113,8 +113,8 @@ suite.addBatch({ topic: function(secret) { db.emailForVerificationSecret(secret, this.callback); }, - "matches expected email": function(storedEmail) { - assert.strictEqual('lloyd@nowhe.re', storedEmail); + "matches expected email": function(err, r) { + assert.strictEqual(r.email, 'lloyd@nowhe.re'); } }, "fetch secret for email": { diff --git a/tests/lib/primary.js b/tests/lib/primary.js new file mode 100644 index 0000000000000000000000000000000000000000..404f422dfad3b1b44792db8ac1120583f3b10839 --- /dev/null +++ b/tests/lib/primary.js @@ -0,0 +1,30 @@ +const +jwk = require('jwcrypto/jwk.js'), +jwt = require('jwcrypto/jwt.js'), +vep = require('jwcrypto/vep.js'), +jwcert = require('jwcrypto/jwcert.js'), +path = require("path"); + +// the private secret of our built-in primary +const g_privKey = jwk.SecretKey.fromSimpleObject( + JSON.parse(require('fs').readFileSync( + path.join(__dirname, '..', '..', 'example', 'primary', 'sample.privatekey')))); + + +function User(options) { + // upon allocation of a user, we'll gen a keypair and get a signed cert + this._keyPair = jwk.KeyPair.generate("DS", 256); + var expiration = new Date(); + expiration.setTime(new Date().valueOf() + 60 * 60 * 1000); + this._cert = new jwcert.JWCert( + options.domain, expiration, new Date(), + this._keyPair.publicKey, {email: options.email}).sign(g_privKey); +} + +User.prototype.getAssertion = function(origin) { + var expirationDate = new Date(new Date().getTime() + (2 * 60 * 1000)); + var tok = new jwt.JWT(null, expirationDate, origin); + return vep.bundleCertsAndAssertion([this._cert], tok.sign(this._keyPair.secretKey)); +} + +module.exports = User; \ No newline at end of file diff --git a/tests/lib/start-stop.js b/tests/lib/start-stop.js index 0b40ec633083c41d2734c828c8f32c82cdb8ee45..d25ff3fb0c5f22bfbaae3a6772a43c1a38b14fc0 100644 --- a/tests/lib/start-stop.js +++ b/tests/lib/start-stop.js @@ -54,7 +54,8 @@ var tokenStack = []; exports.waitForToken = function(cb) { if (tokenStack.length) { - cb(tokenStack.shift()); + var t = tokenStack.shift(); + process.nextTick(function() { cb(t); }); } else { if (nextTokenFunction) throw "can't wait for a verification token when someone else is!"; diff --git a/tests/primary-then-secondary-test.js b/tests/primary-then-secondary-test.js new file mode 100755 index 0000000000000000000000000000000000000000..93607786fc5b43ff79b6b328ceadfde3c90d1472 --- /dev/null +++ b/tests/primary-then-secondary-test.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +/* ***** 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 ***** */ + +require('./lib/test_env.js'); + +const +assert = require('assert'), +vows = require('vows'), +start_stop = require('./lib/start-stop.js'), +wsapi = require('./lib/wsapi.js'), +primary = require('./lib/primary.js'); + +var suite = vows.describe('primary-then-secondary'); + +// start up a pristine server +start_stop.addStartupBatches(suite); + +// this test excercises the codepath whereby a user adds +// a primary email address, then a secondary, then another +// secondary. It checks that the critical wsapi calls +// along the way perform as expected + +// first we'll need to authenticate a user with an assertion from a +// primary IdP + +const TEST_DOMAIN = 'example.domain', + TEST_EMAIL = 'testuser@' + TEST_DOMAIN, + TEST_ORIGIN = 'http://127.0.0.1:10002', + TEST_PASS = 'fakepass', + SECONDARY_EMAIL = 'second@fakeemail.com'; + +var primaryUser = new primary({ + email: TEST_EMAIL, + domain: TEST_DOMAIN +}); + +// now let's generate an assertion using this user +suite.addBatch({ + "generating an assertion": { + topic: function() { + return primaryUser.getAssertion(TEST_ORIGIN); + }, + "succeeds": function(r, err) { + assert.isString(r); + }, + "and logging in with the assertion succeeds": { + topic: function(assertion) { + wsapi.post('/wsapi/auth_with_assertion', { + email: TEST_EMAIL, + assertion: assertion + }).call(this); + }, + "works": function(r, err) { + var resp = JSON.parse(r.body); + assert.isObject(resp); + assert.isTrue(resp.success); + } + } + } +}); + +var token; + +// now we have a new account. let's add a secondary to it +suite.addBatch({ + "add a new email address to our account": { + topic: wsapi.post('/wsapi/stage_email', { + email: SECONDARY_EMAIL, + site:'fakesite.com' + }), + "works": function(r, err) { + assert.strictEqual(r.code, 200); + }, + "and get a token": { + topic: function() { + start_stop.waitForToken(this.callback); + }, + "successfully": function (t) { + this._token = t; + assert.strictEqual(typeof t, 'string'); + }, + "and complete": { + topic: function(t) { + wsapi.get('/wsapi/email_for_token', { + token: t + }).call(this); + }, + "we need to set our password": function (r) { + r = JSON.parse(r.body); + assert.ok(r.needs_password); + }, + "with": { + topic: function() { + wsapi.post('/wsapi/complete_email_addition', { token: this._token }).call(this); + }, + "no password fails": function(r, err) { + assert.equal(r.code, 200); + assert.strictEqual(JSON.parse(r.body).success, false); + }, + "a password": { + topic: function() { + wsapi.post('/wsapi/complete_email_addition', { + token: this._token, + pass: TEST_PASS + }).call(this); + }, + "succeeds": function(r, err) { + assert.equal(r.code, 200); + assert.strictEqual(JSON.parse(r.body).success, true); + } + } + } + } + } + } +}); + +suite.addBatch({ + "authentication with first email": { + topic: wsapi.post('/wsapi/authenticate_user', { + email: TEST_EMAIL, + passwd: TEST_PASS + }), + "works": function(r, err) { + assert.strictEqual(r.code, 200); + }, + }, + "authentication with second email": { + topic: wsapi.post('/wsapi/authenticate_user', { + email: SECONDARY_EMAIL, + passwd: TEST_PASS + }), + "works": function(r, err) { + assert.strictEqual(r.code, 200); + }, + } +}); + + +// shut the server down and cleanup +start_stop.addShutdownBatches(suite); + +// run or export the suite. +if (process.argv[1] === __filename) suite.run(); +else suite.export(module);