diff --git a/plugins/emailstore.js b/plugins/emailstore.js index c660d498..bd8212d4 100644 --- a/plugins/emailstore.js +++ b/plugins/emailstore.js @@ -35,7 +35,7 @@ * 2. Send a GET request to resource /email/retrieve?secret=...... * 3. Decrypt the data received */ -(function() { +(function () { 'use strict'; @@ -52,7 +52,7 @@ var emailPlugin = {}; /** * Constant enum with the errors that the application may return */ -var errors = { +emailPlugin.errors = { MISSING_PARAMETER: { code: 400, message: 'Missing required parameter' @@ -65,6 +65,10 @@ var errors = { code: 404, message: 'Credentials were not found' }, + INTERNAL_ERROR: { + code: 500, + message: 'Unable to save to database' + }, EMAIL_TAKEN: { code: 409, message: 'That email is already registered' @@ -77,8 +81,9 @@ var errors = { var NAMESPACE = 'credentials-store-'; var VALIDATION_NAMESPACE = 'validation-code-'; +var MAP_EMAIL_TO_SECRET = 'map-email-'; var EMAIL_NAMESPACE = 'validated-email-'; -var MAX_ALLOWED_STORAGE = 1024 /* no more than 1 kb */; +var MAX_ALLOWED_STORAGE = 1024 * 100 /* no more than 100 kb */; /** * Initializes the plugin @@ -86,7 +91,7 @@ var MAX_ALLOWED_STORAGE = 1024 /* no more than 1 kb */; * @param {Express} expressApp * @param {Object} config */ -emailPlugin.init = function(expressApp, config) { +emailPlugin.init = function (expressApp, config) { logger.info('Using emailstore plugin'); var path = globalConfig.leveldb + '/emailstore' + (globalConfig.name ? ('-' + globalConfig.name) : ''); @@ -111,7 +116,7 @@ emailPlugin.init = function(expressApp, config) { * @param {Express.Response} response - the express.js response. the methods status, json, and end * will be called, terminating the request. */ -var returnError = function(error, response) { +emailPlugin.returnError = function (error, response) { response.status(error.code).json({error: error.message}).end(); }; @@ -121,7 +126,7 @@ var returnError = function(error, response) { * @param {string} email - the user's email * @param {string} secret - the verification secret */ -var sendVerificationEmail = function(email, secret) { +emailPlugin.sendVerificationEmail = function (email, secret) { var emailBody = 'Activation code is ' + secret; // TODO: Use a template! var emailBodyHTML = '

Activation code is ' + secret + '

'; // TODO: Use a template! @@ -144,6 +149,65 @@ var sendVerificationEmail = function(email, secret) { }); }; +/** + * @param {string} email + * @param {Function(err, boolean)} callback + */ +emailPlugin.exists = function(email, callback) { + emailPlugin.db.get(EMAIL_NAMESPACE + email, function(err, value) { + if (err && err.notFound) { + return callback(null, false); + } else if (err) { + return callback(err); + } + return callback(null, true); + }); +}; + +/** + * @param {string} email + * @param {string} passphrase + * @param {Function(err, boolean)} callback + */ +emailPlugin.checkPassphrase = function(email, passphrase, callback) { + emailPlugin.db.get(MAP_EMAIL_TO_SECRET + email, function(err, retrievedPassphrase) { + if (err) { + return callback(err); + } + return callback(err, passphrase === retrievedPassphrase); + }); +}; + +/** + * @param {string} email + * @param {string} passphrase + * @param {Function(err)} callback + */ +emailPlugin.savePassphrase = function(email, passphrase, callback) { + emailPlugin.db.put(MAP_EMAIL_TO_SECRET + email, passphrase, callback); +}; + +/** + * @param {string} email + * @param {string} record + * @param {Function(err)} callback + */ +emailPlugin.saveEncryptedData = function(email, record, callback) { + emailPlugin.db.put(NAMESPACE + email, record, callback); +}; + +emailPlugin.createVerificationSecretAndSendEmail = function (email, callback) { + emailPlugin.createVerificationSecret(email, function(err, secret) { + if (err) { + return callback(err); + } + if (secret) { + emailPlugin.sendVerificationEmail(email, secret); + } + callback(); + }); +}; + /** * Store a record in the database. The underlying database is merely a levelup instance (a key * value store) that uses the email concatenated with the secret as a key to store the record. @@ -155,94 +219,134 @@ var sendVerificationEmail = function(email, secret) { * @param {Express.Request} request * @param {Express.Response} response */ -emailPlugin.post = function(request, response) { +emailPlugin.post = function (request, response) { var queryData = ''; - request.on('data', function(data) { + request.on('data', function (data) { queryData += data; if (queryData.length > MAX_ALLOWED_STORAGE) { queryData = ''; response.writeHead(413, {'Content-Type': 'text/plain'}).end(); request.connection.destroy(); } - }).on('end', function() { + }).on('end', function () { var params = querystring.parse(queryData); var email = params.email; var secret = params.secret; var record = params.record; if (!email || !secret || !record) { - return returnError(errors.MISSING_PARAMETER, response); + return emailPlugin.returnError(emailPlugin.errors.MISSING_PARAMETER, response); } - async.series([ - /** - * Try to fetch this user's email. If it exists, fail. - */ - function (callback) { - emailPlugin.db.get(VALIDATION_NAMESPACE + email, function(err, dbValue) { - if (!dbValue) { - emailPlugin.db.get(EMAIL_NAMESPACE + email, function(err, dbValue) { - if (!dbValue) { - callback(); - } else { - callback(errors.EMAIL_TAKEN); - } - }); - } else { - callback(errors.EMAIL_TAKEN); - } - }); - }, - /** - * Save the encrypted private key in the storage. - */ - function (callback) { - emailPlugin.db.put(NAMESPACE + secret, record, function (err) { - if (err) { - callback({code: 500, message: err}); - } else { - callback(); - } - }); - }, - /** - * Create and store the verification secret. If successful, send a verification email. - */ - function(callback) { - emailPlugin.createVerificationSecret(email, function(err, secret) { - if (err) { - callback({code: 500, message: err}); - } else { - sendVerificationEmail(email, secret); - callback(); - } - }); - } - ], function(err) { - if (err) { - returnError(err, response); - } else { - response.json({success: true}).end(); - } - } - ); + emailPlugin.processPost(request, response, email, secret, record); }); }; +emailPlugin.processPost = function(request, response, email, secret, record) { + async.series([ + /** + * Try to fetch this user's email. If it exists, check the secret is the same. + */ + function (callback) { + emailPlugin.exists(email, function(err, exists) { + if (err) { + return callback({code: 500, message: err}); + } else if (exists) { + emailPlugin.checkPassphrase(email, secret, function(err, match) { + if (err) { + return callback({code: 500, message: err}); + } + if (match) { + return callback(); + } else { + return callback(emailPlugin.errors.EMAIL_TAKEN); + } + }); + } else { + emailPlugin.savePassphrase(email, secret, function(err) { + if (err) { + return callback({code: 500, message: err}); + } + return callback(); + }); + } + }); + }, + /** + * Save the encrypted private key in the storage. + */ + function (callback) { + emailPlugin.saveEncryptedData(email, record, function(err) { + if (err) { + return callback({code: 500, message: err}); + } + return callback(); + }); + }, + /** + * Create and store the verification secret. If successful, send a verification email. + */ + function (callback) { + emailPlugin.createVerificationSecretAndSendEmail(email, function (err) { + if (err) { + callback({code: 500, message: err}); + } else { + callback(); + } + }); + } + ], function (err) { + if (err) { + emailPlugin.returnError(err, response); + } else { + response.json({success: true}).end(); + } + } + ); +}; + /** * Creates and stores a verification secret in the database. * * @param {string} email - the user's email * @param {Function} callback - will be called with params (err, secret) */ -emailPlugin.createVerificationSecret = function(email, callback) { - var secret = crypto.randomBytes(16).toString('hex'); - emailPlugin.db.put(VALIDATION_NAMESPACE + email, secret, function(err, value) { +emailPlugin.createVerificationSecret = function (email, callback) { + emailPlugin.db.get(VALIDATION_NAMESPACE + email, function(err, value) { + if (err && err.notFound) { + var secret = crypto.randomBytes(16).toString('hex'); + emailPlugin.db.put(VALIDATION_NAMESPACE + email, secret, function (err, value) { + if (err) { + return callback(err); + } + callback(err, secret); + }); + } else { + callback(err, null); + } + }); +}; + + +/** + * @param {string} email + * @param {Function(err)} callback + */ +emailPlugin.retrieveByEmail = function(email, callback) { + emailPlugin.db.get(NAMESPACE + email, callback); +}; + +emailPlugin.retrieveDataByEmailAndPassphrase = function(email, passphrase, callback) { + emailPlugin.checkPassphrase(email, passphrase, function(err, matches) { if (err) { return callback(err); } - callback(err, secret); + if (matches) { + return emailPlugin.retrieveByEmail(email, callback); + } else { + return callback(emailPlugin.errors.INVALID_CODE); + } }); }; @@ -255,18 +359,19 @@ emailPlugin.createVerificationSecret = function(email, callback) { * @param {Express.Request} request * @param {Express.Response} response */ -emailPlugin.get = function(request, response) { +emailPlugin.get = function (request, response) { + var email = request.param('email'); var secret = request.param('secret'); if (!secret) { - return returnError(errors.MISSING_PARAMETER, response); + return emailPlugin.returnError(emailPlugin.errors.MISSING_PARAMETER, response); } - emailPlugin.db.get(NAMESPACE + secret, function (err, value) { + emailPlugin.retrieveDataByEmailAndPassphrase(email, secret, function (err, value) { if (err) { if (err.notFound) { - return returnError(errors.NOT_FOUND, response); + return emailPlugin.returnError(emailPlugin.errors.NOT_FOUND, response); } - return returnError({code: 500, message: err}, response); + return emailPlugin.returnError({code: 500, message: err}, response); } response.send(value).end(); }); @@ -282,26 +387,26 @@ emailPlugin.get = function(request, response) { * @param {Express.Request} request * @param {Express.Response} response */ -emailPlugin.validate = function(request, response) { +emailPlugin.validate = function (request, response) { var email = request.param('email'); var secret = request.param('verification_code'); if (!email || !secret) { - return returnError(errors.MISSING_PARAMETER, response); + return emailPlugin.returnError(emailPlugin.errors.MISSING_PARAMETER, response); } emailPlugin.db.get(VALIDATION_NAMESPACE + email, function (err, value) { logger.info('Recibido: ' + value); if (err) { if (err.notFound) { - return returnError(errors.NOT_FOUND, response); + return emailPlugin.returnError(emailPlugin.errors.NOT_FOUND, response); } - return returnError({code: 500, message: err}, response); + return emailPlugin.returnError({code: 500, message: err}, response); } else if (value !== secret) { - return returnError(errors.INVALID_CODE, response); + return emailPlugin.returnError(emailPlugin.errors.INVALID_CODE, response); } else { - emailPlugin.db.put(EMAIL_NAMESPACE + email, true, function(err, value) { + emailPlugin.db.put(EMAIL_NAMESPACE + email, true, function (err, value) { if (err) { - return returnError({code: 500, message: err}, response); + return emailPlugin.returnError({code: 500, message: err}, response); } else { response.json({success: true}).end(); } diff --git a/test/test.EmailStore.js b/test/test.EmailStore.js index f99aa6af..850c0d9c 100644 --- a/test/test.EmailStore.js +++ b/test/test.EmailStore.js @@ -50,51 +50,57 @@ describe('emailstore test', function() { describe('on registration', function() { + var emailParam = 'email'; + var secretParam = 'secret'; + var recordParam = 'record'; beforeEach(function() { - var data = 'email=1&secret=2&record=3'; + var data = 'email=' + emailParam + '&secret=' + secretParam + '&record=' + recordParam; request.on.onFirstCall().callsArgWith(1, data); request.on.onFirstCall().returnsThis(); request.on.onSecondCall().callsArg(1); - leveldb_stub.get.onFirstCall().callsArg(1); - leveldb_stub.get.onSecondCall().callsArg(1); - leveldb_stub.put.onFirstCall().callsArg(2); - leveldb_stub.put.onSecondCall().callsArg(2); response.json.returnsThis(); }); - it('should store the credentials correctly and generate a secret', function() { + it('should allow new registrations', function() { + plugin.exists = sinon.stub(); + plugin.exists.onFirstCall().callsArgWith(1, null, false); + plugin.savePassphrase = sinon.stub(); + plugin.savePassphrase.onFirstCall().callsArg(2); + plugin.saveEncryptedData = sinon.stub(); + plugin.saveEncryptedData.onFirstCall().callsArg(2); + plugin.createVerificationSecretAndSendEmail = sinon.stub(); + plugin.createVerificationSecretAndSendEmail.onFirstCall().callsArg(1); + response.send.onFirstCall().returnsThis(); plugin.post(request, response); - assert(leveldb_stub.put.getCall(0).args[0] === 'credentials-store-2'); - assert(leveldb_stub.put.getCall(0).args[1] === '3'); - assert(leveldb_stub.put.getCall(1).args[0].indexOf('validation-code-1') === 0); - assert(leveldb_stub.put.getCall(1).args[1]); - assert(response.json.calledWith({success: true})); + assert(plugin.exists.firstCall.args[0] === emailParam); + assert(plugin.savePassphrase.firstCall.args[0] === emailParam); + assert(plugin.savePassphrase.firstCall.args[1] === secretParam); + assert(plugin.saveEncryptedData.firstCall.args[0] === emailParam); + assert(plugin.saveEncryptedData.firstCall.args[1] === recordParam); + assert(plugin.createVerificationSecretAndSendEmail.firstCall.args[0] === emailParam); }); - it('should send an email on registration', function() { + it('should allow to overwrite data', function() { + plugin.exists = sinon.stub(); + plugin.exists.onFirstCall().callsArgWith(1, null, true); + plugin.checkPassphrase = sinon.stub(); + plugin.checkPassphrase.onFirstCall().callsArgWith(2, null, true); + plugin.saveEncryptedData = sinon.stub(); + plugin.saveEncryptedData.onFirstCall().callsArg(2); + plugin.createVerificationSecretAndSendEmail = sinon.stub(); + plugin.createVerificationSecretAndSendEmail.onFirstCall().callsArg(1); + response.send.onFirstCall().returnsThis(); plugin.post(request, response); - assert(plugin.email.sendMail); - assert(plugin.email.sendMail.firstCall.args.length === 2); - assert(plugin.email.sendMail.firstCall.args[0].to === '1'); - }); - - it('should allow the user to retrieve credentials', function() { - request.param.onFirstCall().returns('secret'); - leveldb_stub.get.reset(); - - var returnValue = '!@#$%'; - leveldb_stub.get.onFirstCall().callsArgWith(1, null, returnValue); - response.send.returnsThis(); - - plugin.get(request, response); - - assert(leveldb_stub.get.firstCall.args[0] === 'credentials-store-secret'); - assert(response.send.calledWith(returnValue)); - assert(response.end.calledOnce); + assert(plugin.exists.firstCall.args[0] === emailParam); + assert(plugin.checkPassphrase.firstCall.args[0] === emailParam); + assert(plugin.checkPassphrase.firstCall.args[1] === secretParam); + assert(plugin.saveEncryptedData.firstCall.args[0] === emailParam); + assert(plugin.saveEncryptedData.firstCall.args[1] === recordParam); + assert(plugin.createVerificationSecretAndSendEmail.firstCall.args[0] === emailParam); }); }); @@ -134,42 +140,24 @@ describe('emailstore test', function() { }); }); - describe('when validating registration data', function() { + describe('when retrieving data', function() { - beforeEach(function() { - var data = 'email=1&secret=2&record=3'; - request.on.onFirstCall().callsArgWith(1, data); - request.on.onFirstCall().returnsThis(); - request.on.onSecondCall().callsArg(1); - leveldb_stub.put = sinon.stub(); - leveldb_stub.get = sinon.stub(); - leveldb_stub.put.onFirstCall().callsArg(2); - leveldb_stub.put.onSecondCall().callsArg(2); - response.status.returnsThis(); - response.json.returnsThis(); - }); + it('should validate the secret and return the data', function() { + request.param.onFirstCall().returns('email'); + request.param.onSecondCall().returns('secret'); + plugin.retrieveDataByEmailAndPassphrase = sinon.stub(); + plugin.retrieveDataByEmailAndPassphrase.onFirstCall().callsArgWith(2, null, 'encrypted'); + response.send.onFirstCall().returnsThis(); - it('should\'nt allow the user to register with an already validated email', function() { - leveldb_stub.get.onFirstCall().callsArgWith(1, null, {}); + plugin.get(request, response); - plugin.post(request, response); - - assert(response.status.firstCall.calledWith(409)); - assert(response.json.firstCall.calledWith({error: 'That email is already registered'})); + assert(request.param.firstCall.args[0] === 'email'); + assert(request.param.secondCall.args[0] === 'secret'); + assert(plugin.retrieveDataByEmailAndPassphrase.firstCall.args[0] === 'email'); + assert(plugin.retrieveDataByEmailAndPassphrase.firstCall.args[1] === 'secret'); + assert(response.send.firstCall.args[0] === 'encrypted'); assert(response.end.calledOnce); }); - - it('should\'nt allow the user to register with a pending validation email', function() { - leveldb_stub.get.onFirstCall().callsArg(1); - leveldb_stub.get.onSecondCall().callsArgWith(1, null, {}); - - plugin.post(request, response); - - assert(response.status.firstCall.args[0] === 409); - assert(response.json.firstCall.calledWith({error: 'That email is already registered'})); - assert(response.end.calledOnce); - }); - }); });