From 09673a93a2e4015eaa574b6ec9f7a08bb5eab439 Mon Sep 17 00:00:00 2001 From: Esteban Ordano Date: Thu, 25 Sep 2014 18:19:05 -0300 Subject: [PATCH] Add emailstore plugin, based on credentials --- config/config.js | 5 +- insight.js | 4 + package.json | 4 +- plugins/config-emailstore.js | 9 + plugins/emailstore.js | 315 +++++++++++++++++++++++++++++++++++ test/test.EmailStore.js | 175 +++++++++++++++++++ 6 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 plugins/config-emailstore.js create mode 100644 plugins/emailstore.js create mode 100644 test/test.EmailStore.js diff --git a/config/config.js b/config/config.js index b0bf216..f0da3f9 100644 --- a/config/config.js +++ b/config/config.js @@ -84,6 +84,7 @@ var enableCleaner = process.env.ENABLE_CLEANER === 'true'; var enableMailbox = process.env.ENABLE_MAILBOX === 'true'; var enableRatelimiter = process.env.ENABLE_RATELIMITER === 'true'; var enableCredentialstore = process.env.ENABLE_CREDSTORE === 'true'; +var enableEmailstore = process.env.ENABLE_EMAILSTORE === 'true'; var loggerLevel = process.env.LOGGER_LEVEL || 'info'; var enableHTTPS = process.env.ENABLE_HTTPS === 'true'; @@ -102,6 +103,8 @@ module.exports = { ratelimiter: require('../plugins/config-ratelimiter.js'), enableCredentialstore: enableCredentialstore, credentialstore: require('../plugins/config-credentialstore'), + enableEmailstore: enableEmailstore, + emailstore: require('../plugins/config-emailstore'), loggerLevel: loggerLevel, enableHTTPS: enableHTTPS, version: version, @@ -124,4 +127,4 @@ module.exports = { }, safeConfirmations: safeConfirmations, // PLEASE NOTE THAT *FULL RESYNC* IS NEEDED TO CHANGE safeConfirmations ignoreCache: ignoreCache, -}; \ No newline at end of file +}; diff --git a/insight.js b/insight.js index 12b64d4..8ca809b 100755 --- a/insight.js +++ b/insight.js @@ -147,6 +147,10 @@ if (config.enableCredentialstore) { require('./plugins/credentialstore').init(expressApp, config.credentialstore); } +if (config.enableEmailstore) { + require('./plugins/emailstore').init(expressApp, config.emailstore); +} + // express settings require('./config/express')(expressApp, historicSync, peerSync); require('./config/routes')(expressApp); diff --git a/package.json b/package.json index 7644e97..6189684 100644 --- a/package.json +++ b/package.json @@ -67,8 +67,8 @@ "microtime": "^0.6.0", "mkdirp": "^0.5.0", "moment": "~2.5.0", + "nodemailer": "^1.3.0", "preconditions": "^1.0.7", - "should": "~2.1.1", "socket.io": "1.0.6", "socket.io-client": "1.0.6", "soop": "=0.1.5", @@ -87,7 +87,7 @@ "grunt-mocha-test": "~0.8.1", "grunt-nodemon": "~0.2.0", "memdown": "^0.10.2", - "should": "2.1.1", + "should": "^2.1.1", "sinon": "^1.10.3" } } diff --git a/plugins/config-emailstore.js b/plugins/config-emailstore.js new file mode 100644 index 0000000..dfccb7a --- /dev/null +++ b/plugins/config-emailstore.js @@ -0,0 +1,9 @@ +module.exports = { + email: { + service: 'Gmail', + auth: { + user: 'john@gmail.com', + pass: 'mypassword' + } + } +}; diff --git a/plugins/emailstore.js b/plugins/emailstore.js new file mode 100644 index 0000000..c660d49 --- /dev/null +++ b/plugins/emailstore.js @@ -0,0 +1,315 @@ +/** + * Email-credentials-storage service + * + * Allows users to store encrypted data on the server, useful to store the user's credentials. + * + * Triggers an email to the user's provided email account. Note that the service may decide to + * remove information associated with unconfirmed email addresses! + * + * Steps for the user would be: + * + * 1. Select an email to use + * 2. Choose a password + * 3. Create a strong key for encryption using PBKDF2 or scrypt with the email and password + * 4. Use that key to AES-CRT encrypt the private key + * 5. Take the double SHA256 hash of "salt"+"email"+"password" and use that as a secret + * 6. Send a POST request to resource /email/register with the params: + * email=johndoe@email.com + * secret=2413fb3709b05939f04cf2e92f7d0897fc2596f9ad0b8a9ea855c7bfebaae892 + * record=YjU1MTI2YTM5ZjliMTE3MGEzMmU2ZjYxZTRhNjk0YzQ1MjM1ZTVhYzExYzA1ZWNkNmZm + * NjM5NWRlNmExMTE4NzIzYzYyYWMwODU1MTdkNWMyNjRiZTVmNmJjYTMxMGQyYmFiNjc4YzdiODV + * lZjg5YWIxYzQ4YjJmY2VkYWJjMDQ2NDYzODhkODFiYTU1NjZmMzgwYzhiODdiMzlmYjQ5ZTc1Nz + * FjYzQzYjk1YTEyYWU1OGMxYmQ3OGFhOTZmNGMz + * + * To verify an email: + * + * 1. Check the email sent by the insight server + * 2. Click on the link provided, or take the verification secret to make a request + * 3. The request done can be a POST or GET request to /email/validate with the params: + * email=johndoe@email.com + * verification_code=M5NWRlNmExMTE4NzIzYzYyYWMwODU1MT + * + * To retrieve data: + * + * 1. Recover the secret from the double sha256 of the salt, email, and password + * 2. Send a GET request to resource /email/retrieve?secret=...... + * 3. Decrypt the data received + */ +(function() { + +'use strict'; + +var logger = require('../lib/logger').logger, + levelup = require('levelup'), + async = require('async'), + crypto = require('crypto'), + querystring = require('querystring'), + nodemailer = require('nodemailer'), + globalConfig = require('../config/config'); + +var emailPlugin = {}; + +/** + * Constant enum with the errors that the application may return + */ +var errors = { + MISSING_PARAMETER: { + code: 400, + message: 'Missing required parameter' + }, + INVALID_REQUEST: { + code: 400, + message: 'Invalid request parameter' + }, + NOT_FOUND: { + code: 404, + message: 'Credentials were not found' + }, + EMAIL_TAKEN: { + code: 409, + message: 'That email is already registered' + }, + INVALID_CODE: { + code: 400, + message: 'The provided code is invalid' + } +}; + +var NAMESPACE = 'credentials-store-'; +var VALIDATION_NAMESPACE = 'validation-code-'; +var EMAIL_NAMESPACE = 'validated-email-'; +var MAX_ALLOWED_STORAGE = 1024 /* no more than 1 kb */; + +/** + * Initializes the plugin + * + * @param {Express} expressApp + * @param {Object} config + */ +emailPlugin.init = function(expressApp, config) { + logger.info('Using emailstore plugin'); + + var path = globalConfig.leveldb + '/emailstore' + (globalConfig.name ? ('-' + globalConfig.name) : ''); + emailPlugin.db = config.db || globalConfig.db || levelup(path); + + emailPlugin.email = config.emailTransport || nodemailer.createTransport(config.email); + + expressApp.post(globalConfig.apiPrefix + '/email/register', emailPlugin.post); + expressApp.get(globalConfig.apiPrefix + '/email/retrieve/:email', emailPlugin.get); + expressApp.post(globalConfig.apiPrefix + '/email/validate', emailPlugin.validate); + expressApp.get(globalConfig.apiPrefix + '/email/validate', emailPlugin.validate); +}; + +/** + * Helper function that ends a requests showing the user an error. The response body will be a JSON + * encoded object with only one property with key "error" and value error.message, one of + * the parameters of the function + * + * @param {Object} error - The error that caused the request to be terminated + * @param {number} error.code - the HTTP code to return + * @param {string} error.message - the message to send in the body + * @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) { + response.status(error.code).json({error: error.message}).end(); +}; + +/** + * Helper that sends a verification email. + * + * @param {string} email - the user's email + * @param {string} secret - the verification secret + */ +var sendVerificationEmail = function(email, secret) { + + var emailBody = 'Activation code is ' + secret; // TODO: Use a template! + var emailBodyHTML = '

Activation code is ' + secret + '

'; // TODO: Use a template! + + var mailOptions = { + from: 'Insight Services ', + to: email, + subject: 'Your Insight account has been created', + text: emailBody, + html: emailBodyHTML + }; + + // send mail with defined transport object + emailPlugin.email.sendMail(mailOptions, function (err, info) { + if (err) { + logger.error('An error occurred when trying to send email to ' + email, err); + } else { + logger.debug('Message sent: ' + info.response); + } + }); +}; + +/** + * 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. + * The request is expected to contain the parameters: + * * email + * * secret + * * record + * + * @param {Express.Request} request + * @param {Express.Response} response + */ +emailPlugin.post = function(request, response) { + + var queryData = ''; + + 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() { + 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); + } + + 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(); + } + } + ); + }); +}; + +/** + * 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) { + if (err) { + return callback(err); + } + callback(err, secret); + }); +}; + +/** + * Retrieve a record from the database. + * + * The request is expected to contain the parameters: + * * secret + * + * @param {Express.Request} request + * @param {Express.Response} response + */ +emailPlugin.get = function(request, response) { + var secret = request.param('secret'); + if (!secret) { + return returnError(errors.MISSING_PARAMETER, response); + } + + emailPlugin.db.get(NAMESPACE + secret, function (err, value) { + if (err) { + if (err.notFound) { + return returnError(errors.NOT_FOUND, response); + } + return returnError({code: 500, message: err}, response); + } + response.send(value).end(); + }); +}; + +/** + * Marks an email as validated + * + * The two expected params are: + * * email + * * verification_code + * + * @param {Express.Request} request + * @param {Express.Response} 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); + } + + 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 returnError({code: 500, message: err}, response); + } else if (value !== secret) { + return returnError(errors.INVALID_CODE, response); + } else { + emailPlugin.db.put(EMAIL_NAMESPACE + email, true, function(err, value) { + if (err) { + return returnError({code: 500, message: err}, response); + } else { + response.json({success: true}).end(); + } + }); + } + }); +}; + +module.exports = emailPlugin; + +})(); diff --git a/test/test.EmailStore.js b/test/test.EmailStore.js new file mode 100644 index 0000000..32a4dfe --- /dev/null +++ b/test/test.EmailStore.js @@ -0,0 +1,175 @@ +'use strict'; + +var chai = require('chai'), + assert = require('assert'), + sinon = require('sinon'), + logger = require('../lib/logger').logger, + should = chai.should(), + expect = chai.expect; + +logger.transports.console.level = 'warn'; + +describe('emailstore test', function() { + + var globalConfig = require('../config/config'); + + // Mock components of plugin + var leveldb_stub = sinon.stub(); + leveldb_stub.put = sinon.stub(); + leveldb_stub.get = sinon.stub(); + var email_stub = sinon.stub(); + email_stub.sendMail = sinon.stub(); + + var plugin = require('../plugins/emailstore'); + var express_mock = null; + var request = null; + var response = null; + + beforeEach(function() { + + // Mock request and response objects (but don't configure behavior) + express_mock = sinon.stub(); + express_mock.post = sinon.stub(); + express_mock.get = sinon.stub(); + + plugin.init(express_mock, {db: leveldb_stub, emailTransport: email_stub}); + + request = sinon.stub(); + request.on = sinon.stub(); + request.param = sinon.stub(); + response = sinon.stub(); + response.send = sinon.stub(); + response.status = sinon.stub(); + response.json = sinon.stub(); + response.end = sinon.stub(); + }); + + it('initializes correctly', function() { + assert(plugin.db === leveldb_stub); + }); + + describe('on registration', 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.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() { + + 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})); + }); + + it('should send an email on registration', function() { + + 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); + }); + }); + + describe('when validating email', function() { + + var email = '1'; + var secret = '2'; + beforeEach(function() { + + request.param.onFirstCall().returns(email); + request.param.onSecondCall().returns(secret); + leveldb_stub.put = sinon.stub(); + leveldb_stub.get = sinon.stub(); + leveldb_stub.put.onFirstCall().callsArg(2); + response.json.returnsThis(); + }); + + it('should validate correctly an email if the secret matches', function() { + leveldb_stub.get.onFirstCall().callsArgWith(1, null, secret); + + plugin.validate(request, response); + + assert(response.json.firstCall.calledWith({success: true})); + }); + + it('should fail to validate an email if the secrent doesn\'t match', function() { + var invalid = '3'; + leveldb_stub.get.onFirstCall().callsArgWith(1, null, invalid); + response.status.returnsThis(); + response.json.returnsThis(); + + plugin.validate(request, response); + + assert(response.status.firstCall.calledWith(400)); + assert(response.json.firstCall.calledWith({error: 'The provided code is invalid'})); + assert(response.end.calledOnce); + }); + }); + + describe('when validating registration 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\'nt allow the user to register with an already validated email', function() { + leveldb_stub.get.onFirstCall().callsArgWith(1, null, {}); + + plugin.post(request, response); + + assert(response.status.firstCall.calledWith(409)); + assert(response.json.firstCall.calledWith({error: 'That email is already registered'})); + 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); + }); + + }); +}); +