From d3cce89b066092d37e605ebfb9b4414f26568af2 Mon Sep 17 00:00:00 2001 From: Tankred Hase Date: Sun, 29 May 2016 16:47:45 +0200 Subject: [PATCH] Implement Email DAO for sending verification mails --- config/integration.js | 7 ++ package.json | 2 +- src/app.js | 10 +-- src/dao/email.js | 123 +++++++++++++++++++++++++++++++-- src/route/hkp.js | 6 +- src/route/rest.js | 11 +-- src/service/public-key.js | 9 +-- src/service/util.js | 15 ++++ test/integration/email-test.js | 70 +++++++++++++++++++ test/integration/mongo-test.js | 6 +- 10 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 config/integration.js create mode 100644 test/integration/email-test.js diff --git a/config/integration.js b/config/integration.js new file mode 100644 index 0000000..9069a04 --- /dev/null +++ b/config/integration.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + log: { + level: "warn" + } +}; \ No newline at end of file diff --git a/package.json b/package.json index ef46e4b..b5174ff 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "scripts": { "start": "node index.js", - "test": "grunt test" + "test": "export NODE_ENV=integration && grunt test" }, "dependencies": { "addressparser": "^1.0.1", diff --git a/src/app.js b/src/app.js index 8dde126..714ec80 100644 --- a/src/app.js +++ b/src/app.js @@ -96,9 +96,9 @@ app.on('error', (error, ctx) => { function injectDependencies() { let credentials = readCredentials(); mongo = new Mongo({ - uri: process.env.MONGO_URI || credentials.mongoUri, - user: process.env.MONGO_USER || credentials.mongoUser, - password: process.env.MONGO_PASS || credentials.mongoPass + uri: process.env.MONGO_URI || credentials.mongo.uri, + user: process.env.MONGO_USER || credentials.mongo.user, + password: process.env.MONGO_PASS || credentials.mongo.pass }); email = new Email(nodemailer); userId = new UserId(mongo); @@ -123,14 +123,14 @@ if (!global.testing) { // don't automatically start server in tests co(function *() { let app = yield init(); app.listen(config.server.port); - log.verbose('app', 'Ready to rock! Listening on http://localhost:' + config.server.port); + log.info('app', 'Ready to rock! Listening on http://localhost:' + config.server.port); }).catch(err => log.error('app', 'Initialization failed!', err)); } function *init() { log.level = config.log.level; // set log level depending on process.env.NODE_ENV injectDependencies(); - log.verbose('app', 'Connecting to MongoDB ...'); + log.info('app', 'Connecting to MongoDB ...'); yield mongo.connect(); return app; } diff --git a/src/dao/email.js b/src/dao/email.js index 4446acf..e79e694 100644 --- a/src/dao/email.js +++ b/src/dao/email.js @@ -17,6 +17,9 @@ 'use strict'; +const log = require('npmlog'); +const util = require('../service/util'); + /** * A simple wrapper around Nodemailer to send verification emails */ @@ -30,21 +33,131 @@ class Email { this._mailer = mailer; } + /** + * Create an instance of the reusable nodemailer SMTP transport. + * @param {string} host The SMTP server's hostname e.g. 'smtp.gmail.com' + * @param {Object} auth Auth credential e.g. { user:'user@gmail.com', pass:'pass' } + * @param {Object} sender The message 'FROM' field e.g. { name:'Your Support', email:'noreply@exmple.com' } + * @param {string} port (optional) The SMTP server's SMTP port. Defaults to 465. + * @param {boolean} secure (optional) If TSL should be used. Defaults to true. + * @param {boolean} requireTLS (optional) If TSL is mandatory. Defaults to true. + */ + init(options) { + this._transport = this._mailer.createTransport({ + host: options.host, + port: options.port || 465, + auth: options.auth, + secure: options.secure || true, + requireTLS: options.requireTLS || true + }); + this._sender = options.sender; + } + + /** + * A generic method to send an email message via nodemail. + * @param {Object} from The sender user id object e.g. { name:'Jon Smith', email:'j@smith.com' } + * @param {Object} to The recipient user id object e.g. { name:'Jon Smith', email:'j@smith.com' } + * @param {string} subject The message subject + * @param {string} text The message plaintext body + * @param {string} html The message html body + * @yield {Object} The reponse object containing SMTP info + */ + *send(options) { + let mailOptions = { + from: { + name: options.from.name, + address: options.from.email + }, + to: { + name: options.to.name, + address: options.to.email + }, + subject: options.subject, + text: options.text, + html: options.html + }; + + try { + let info = yield this._transport.sendMail(mailOptions); + log.silly('email', 'Email sent.', info); + return info; + } catch(error) { + log.error('email', 'Sending email failed.', error, options); + throw error; + } + } + /** * Send the verification email to the user to verify email address * ownership. If the primary email address is provided, only one email * will be sent out. Otherwise all of the PGP key's user IDs will be * verified, resulting in an email sent per user ID. - * @param {Array} options.userIds The user id documents containing the nonces - * @param {Array} options.primaryEmail (optional) The user's primary email address + * @param {Array} userIds The user id documents containing the nonces + * @param {Array} primaryEmail (optional) The user's primary email address + * @param {Object} origin Required for links to the keyserver e.g. { protocol:'https', host:'openpgpkeys@example.com' } * @yield {undefined} */ - sendVerification() { - return Promise.resolve(); + *sendVerification(options) { + let primaryEmail = options.primaryEmail, userIds = options.userIds, origin = options.origin; + let primaryUserId = userIds.find(uid => uid.email === primaryEmail); + if (primaryUserId) { // send only one email to the primary user id + return yield this._sendVerificationHelper(primaryUserId, origin); + } + for (let uid of userIds) { + yield this._sendVerificationHelper(uid, origin); + } } - send() { + /** + * Help method to send a verification message + * @param {Object} userId The user id document + * @param {Object} origin The origin of the server + * @yield {Object} The send response from the SMTP server + */ + *_sendVerificationHelper(userId, origin) { + let message = this._createVerifyMessage(userId, origin); + try { + let info = yield this.send(message); + if (!this._checkResponse(info)) { + log.warn('email', 'Verification mail may not have been received.', info); + } + return info; + } catch(e) { + util.throw(500, 'Sending verification email failed'); + } + } + /** + * Helper function to create a verification message object. + * @param {Object} userId The user id document + * @param {Object} origin The origin of the server + * @return {Object} The message object + */ + _createVerifyMessage(userId, origin) { + let verifyLink = origin.protocol + '://' + origin.host + + '/api/v1/verify/?keyid=' + encodeURIComponent(userId.keyid) + + '&nonce=' + encodeURIComponent(userId.nonce); + let text = `Hey${userId.name ? ' ' + userId.name : ''}, + +please click here to verify your key: ${verifyLink} +`; + + return { + from: this._sender, + to: userId, + subject: 'Verify Your Key', + text: text + }; + } + + /** + * Check if the message was sent successfully according to SMTP + * reply codes: http://www.supermailer.de/smtp_reply_codes.htm + * @param {Object} info The info object return from nodemailer + * @return {boolean} If the message was received by the user + */ + _checkResponse(info) { + return /^2/.test(info.response); } } diff --git a/src/route/hkp.js b/src/route/hkp.js index 542cbe5..b953df2 100644 --- a/src/route/hkp.js +++ b/src/route/hkp.js @@ -40,10 +40,12 @@ class HKP { */ *add(ctx) { let body = yield parse.form(ctx, { limit: '1mb' }); - if (!util.validatePublicKey(body.keytext)) { + let publicKeyArmored = body.keytext; + if (!util.validatePublicKey(publicKeyArmored)) { ctx.throw(400, 'Invalid request!'); } - yield this._publicKey.put({ publicKeyArmored:body.keytext }); + let origin = util.getOrigin(ctx); + yield this._publicKey.put({ publicKeyArmored, origin }); } /** diff --git a/src/route/rest.js b/src/route/rest.js index f27e217..5b9555f 100644 --- a/src/route/rest.js +++ b/src/route/rest.js @@ -38,12 +38,15 @@ class REST { * @param {Object} ctx The koa request/response context */ *create(ctx) { - let pk = yield parse.json(ctx, { limit: '1mb' }); - if ((pk.primaryEmail && !util.validateAddress(pk.primaryEmail)) || - !util.validatePublicKey(pk.publicKeyArmored)) { + let body = yield parse.json(ctx, { limit: '1mb' }); + let primaryEmail = body.primaryEmail; + let publicKeyArmored = body.publicKeyArmored; + if ((primaryEmail && !util.validateAddress(primaryEmail)) || + !util.validatePublicKey(publicKeyArmored)) { ctx.throw(400, 'Invalid request!'); } - yield this._publicKey(pk); + let origin = util.getOrigin(ctx); + yield this._publicKey({ publicKeyArmored, primaryEmail, origin }); } *verify(ctx) { diff --git a/src/service/public-key.js b/src/service/public-key.js index 10111dc..367cc06 100644 --- a/src/service/public-key.js +++ b/src/service/public-key.js @@ -50,13 +50,14 @@ class PublicKey { /** * Persist a new public key - * @param {String} options.publicKeyArmored The ascii armored pgp key block - * @param {String} options.primaryEmail (optional) The key's primary email address + * @param {String} publicKeyArmored The ascii armored pgp key block + * @param {String} primaryEmail (optional) The key's primary email address + * @param {Object} origin Required for links to the keyserver e.g. { protocol:'https', host:'openpgpkeys@example.com' } * @yield {undefined} */ *put(options) { // parse key block - let publicKeyArmored = options.publicKeyArmored; + let publicKeyArmored = options.publicKeyArmored, primaryEmail = options.primaryEmail, origin = options.origin; let params = this.parseKey(publicKeyArmored); // check for existing verfied key by id or email addresses let verified = yield this._userid.getVerfied(params); @@ -73,7 +74,7 @@ class PublicKey { // persist new user ids let userIds = yield this._userid.batch(params); // send mails to verify user ids (send only one if primary email is provided) - yield this._email.sendVerification({ userIds, primaryEmail:options.primaryEmail }); + yield this._email.sendVerification({ userIds, primaryEmail, origin }); } /** diff --git a/src/service/util.js b/src/service/util.js index 4382a68..9a2ec23 100644 --- a/src/service/util.js +++ b/src/service/util.js @@ -107,4 +107,19 @@ exports.throw = function(status, message) { err.status = status; err.expose = true; // display message to the client throw err; +}; + +/** + * Get the server's own origin host and protocol. Required for sending + * verification links via email. If the PORT environmane variable + * is set, we assume the protocol to be 'https', since the AWS loadbalancer + * speaks 'https' externally but 'http' between the LB and the server. + * @param {Object} ctx The koa request/repsonse context + * @return {Object} The server origin + */ +exports.getOrigin = function(ctx) { + return { + protocol: process.env.PORT ? 'https' : ctx.protocol, + host: ctx.host + }; }; \ No newline at end of file diff --git a/test/integration/email-test.js b/test/integration/email-test.js new file mode 100644 index 0000000..b91fe2b --- /dev/null +++ b/test/integration/email-test.js @@ -0,0 +1,70 @@ +'use strict'; + +require('co-mocha')(require('mocha')); // monkey patch mocha for generators + +const expect = require('chai').expect; +const log = require('npmlog'); +const config = require('config'); +const Email = require('../../src/dao/email'); +const nodemailer = require('nodemailer'); + +log.level = config.log.level; + +describe('Email Integration Tests', function() { + this.timeout(20000); + + let email, credentials; + + before(function() { + try { + credentials = require('../../credentials.json'); + } catch(e) { + log.warn('email-test', 'No credentials.json found ... skipping tests.'); + this.skip(); + return; + } + email = new Email(nodemailer); + email.init({ + host: credentials.smtp.host, + auth: { + user: credentials.smtp.user, + pass: credentials.smtp.pass + }, + sender: credentials.sender + }); + }); + + describe("send", function() { + it('should work', function *() { + let mailOptions = { + from: credentials.sender, + to: credentials.sender, + subject: 'Hello ✔', // Subject line + text: 'Hello world 🐴', // plaintext body + html: 'Hello world 🐴' // html body + }; + let info = yield email.send(mailOptions); + expect(info).to.exist; + }); + }); + + describe("sendVerification", function() { + it('should work', function *() { + let options = { + userIds: [{ + name: credentials.sender.name, + email: credentials.sender.email, + keyid: '0123456789ABCDF0', + nonce: 'qwertzuioasdfghjkqwertzuio' + }], + primaryEmail: credentials.sender.email, + origin: { + protocol: 'http', + host: 'localhost:' + config.server.port + } + }; + yield email.sendVerification(options); + }); + }); + +}); \ No newline at end of file diff --git a/test/integration/mongo-test.js b/test/integration/mongo-test.js index 0dba27f..1f11566 100644 --- a/test/integration/mongo-test.js +++ b/test/integration/mongo-test.js @@ -20,9 +20,9 @@ describe('Mongo Integration Tests', function() { log.info('mongo-test', 'No credentials.json found ... using environment vars.'); } mongo = new Mongo({ - uri: process.env.MONGO_URI || credentials.mongoUri, - user: process.env.MONGO_USER || credentials.mongoUser, - password: process.env.MONGO_PASS || credentials.mongoPass + uri: process.env.MONGO_URI || credentials.mongo.uri, + user: process.env.MONGO_USER || credentials.mongo.user, + password: process.env.MONGO_PASS || credentials.mongo.pass }); yield mongo.connect(); });