Add emailstore plugin, based on credentials
This commit is contained in:
parent
0ca0fdf699
commit
09673a93a2
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
9
plugins/config-emailstore.js
Normal file
9
plugins/config-emailstore.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
email: {
|
||||
service: 'Gmail',
|
||||
auth: {
|
||||
user: 'john@gmail.com',
|
||||
pass: 'mypassword'
|
||||
}
|
||||
}
|
||||
};
|
||||
315
plugins/emailstore.js
Normal file
315
plugins/emailstore.js
Normal file
@ -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 <tt>error.message</tt>, 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 = '<h1>Activation code is ' + secret + '</h1>'; // TODO: Use a template!
|
||||
|
||||
var mailOptions = {
|
||||
from: 'Insight Services <insight@bitpay.com>',
|
||||
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;
|
||||
|
||||
})();
|
||||
175
test/test.EmailStore.js
Normal file
175
test/test.EmailStore.js
Normal file
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user