UNPKG

waigo

Version:

Node.js ES6 framework for reactive, data-driven apps and APIs (Koa, RethinkDB)

175 lines (129 loc) 4.15 kB
"use strict"; const crypto = require('crypto'); const waigo = global.waigo, _ = waigo._, logger = waigo.load('support/logger'), errors = waigo.load('support/errors'); const ActionTokensError = exports.ActionTokensError = errors.define('ActionTokensError'); const _throw = function(msg, status, data) { throw new ActionTokensError(msg, status, data); }; class ActionTokens { constructor (App, config) { this.App = App; this.config = config; this.logger = logger.create('ActionTokens'); this.logger.debug(`encryption key is: ${this.config.encryptionKey}`); } /** * Create a token representing the given action. * * @param {String} type email action type. * @param {User} user User this action is for. * @param {Object} [data] Additional data to associate with this action (must be JSON-stringify-able) * @param {Object} [options] Additional options. * @param {Number} [options.validForSeconds] Override default `validForSeconds` settings with this. * * @return {String} the action token. */ * create (type, user, data, options) { data = data || ''; options = _.extend({ validForSeconds: this.config.validForSeconds }, options); this.logger.debug(`Creating action token: ${type} for user ${user.id}`, data); // every token is uniquely identied by a salt (this is also doubles up as // a factor for more secure encryption) let salt = _.uuid.v4(); let plaintext = JSON.stringify([ Date.now() + (options.validForSeconds * 1000), salt, type, user.id, data ]); this.logger.trace('Encrypt: ' + plaintext); let cipher = crypto.createCipher( 'aes256', this.config.encryptionKey ); return cipher.update(plaintext, 'utf8', 'hex') + cipher.final('hex'); } /** * Process the given action token. * * Note: A given token can only be processed once. * * @param {String} token the token to process. * @param {Object} [options] Additional options. * @param {String} [options.type] Expected token type. * * @param {Object} token `type`, `user` and `data`. */ * process (token, options) { options = options || {}; this.logger.debug(`Processing action token: ${token}`); let json = null; try { let decipher = crypto.createDecipher( 'aes256', this.config.encryptionKey ); let plaintext = decipher.update(token, 'hex', 'utf8') + decipher.final('utf8'); this.logger.trace(`Decrypted: ${plaintext}`); json = JSON.parse(plaintext); } catch (err) { _throw('Error parsing action token', 400, { error: err.stack }); } let ts = json[0], salt = json[1], type = json[2], userId = json[3], data = json[4]; if (!_.isEmpty(options.type) && type !== options.type) { _throw(`Action token type mismatch: ${type}`, 400); } // check if action still valid if (Date.now() > ts) { _throw('This action token has expired.', 403); } var user = yield this.App.models.User.get(userId); if (!user) { _throw('Unable to find user information related to action token', 404); } // check if we've already executed this request before var activity = yield this.App.models.Activity.getByFilter({ verb: 'action_token_processed', details: { type: type, salt: salt, }, }); if (activity.length) { _throw('This action token has already been processed and is no longer valid.', 403); } // record activity yield this.App.models.Activity.record('action_token_processed', user, { type: type, salt: salt, }); this.logger.debug(`Action token processed: ${token}`); return { type: type, user: user, data: data, }; } } /** * Initialise action tokens manager. * * @param {App} App The App. * @param {Object} actionTokensConfig Config. * * @return {ActionTokens} */ exports.init = function*(App, actionTokensConfig) { return new ActionTokens(App, actionTokensConfig); };