diff --git a/core/server/utils/index.js b/core/server/utils/index.js index e330911995..a24d40422f 100644 --- a/core/server/utils/index.js +++ b/core/server/utils/index.js @@ -105,7 +105,8 @@ utils = { zipFolder: require('./zip-folder'), readThemes: require('./read-themes'), generateAssetHash: require('./asset-hash'), - url: require('./url') + url: require('./url'), + tokens: require('./tokens') }; module.exports = utils; diff --git a/core/server/utils/tokens.js b/core/server/utils/tokens.js new file mode 100644 index 0000000000..7c402f52a0 --- /dev/null +++ b/core/server/utils/tokens.js @@ -0,0 +1,84 @@ +var crypto = require('crypto'); + +exports.resetToken = { + generateHash: function generateHash(options) { + options = options || {}; + + var hash = crypto.createHash('sha256'), + expires = options.expires, + email = options.email, + dbHash = options.dbHash, + password = options.password, + text = ''; + + hash.update(String(expires).toLocaleLowerCase()); + hash.update(String(email).toLocaleLowerCase()); + hash.update(String(dbHash).toLocaleLowerCase()); + hash.update(String(password).toLocaleLowerCase()); + + text += [expires, email, hash.digest('base64')].join('|'); + return new Buffer(text).toString('base64'); + }, + extract: function extract(options) { + options = options || {}; + + var token = options.token, + tokenText = new Buffer(token, 'base64').toString('ascii'), + parts, + expires, + email; + + parts = tokenText.split('|'); + + // Check if invalid structure + if (!parts || parts.length !== 3) { + return false; + } + + expires = parseInt(parts[0], 10); + email = parts[1]; + + return { + expires: expires, + email: email + }; + }, + /*jslint bitwise:true*/ + compare: function compare(options) { + options = options || {}; + + var tokenToCompare = options.token, + parts = exports.resetToken.extract({token: tokenToCompare}), + dbHash = options.dbHash, + password = options.password, + generatedToken, + diff = 0, + i; + + if (isNaN(parts.expires)) { + return false; + } + + // Check if token is expired to prevent replay attacks + if (parts.expires < Date.now()) { + return false; + } + + generatedToken = exports.resetToken.generateHash({ + email: parts.email, + expires: parts.expires, + dbHash: dbHash, + password: password + }); + + if (tokenToCompare.length !== generatedToken.length) { + diff = 1; + } + + for (i = tokenToCompare.length - 1; i >= 0; i = i - 1) { + diff |= tokenToCompare.charCodeAt(i) ^ generatedToken.charCodeAt(i); + } + + return diff === 0; + } +}; diff --git a/core/test/unit/utils/tokens_spec.js b/core/test/unit/utils/tokens_spec.js new file mode 100644 index 0000000000..2948ceb90c --- /dev/null +++ b/core/test/unit/utils/tokens_spec.js @@ -0,0 +1,108 @@ +var uuid = require('node-uuid'), + should = require('should'), + utils = require('../../../server/utils'); + +should.equal(true, true); + +describe('Utils: tokens', function () { + it('generate', function () { + var expires = Date.now() + 60 * 1000, + dbHash = uuid.v4(), token; + + token = utils.tokens.resetToken.generateHash({ + email: 'test1@ghost.org', + expires: expires, + dbHash: dbHash + }); + + should.exist(token); + token.length.should.be.above(0); + }); + + it('compare: success', function () { + var expires = Date.now() + 60 * 1000, + dbHash = uuid.v4(), token, tokenIsCorrect; + + token = utils.tokens.resetToken.generateHash({ + email: 'test1@ghost.org', + expires: expires, + password: '12345678', + dbHash: dbHash + }); + + tokenIsCorrect = utils.tokens.resetToken.compare({ + token: token, + dbHash: dbHash, + password: '12345678' + }); + + tokenIsCorrect.should.eql(true); + }); + + it('compare: error', function () { + var expires = Date.now() + 60 * 1000, + dbHash = uuid.v4(), token, tokenIsCorrect; + + token = utils.tokens.resetToken.generateHash({ + email: 'test1@ghost.org', + expires: expires, + password: '12345678', + dbHash: dbHash + }); + + tokenIsCorrect = utils.tokens.resetToken.compare({ + token: token, + dbHash: dbHash, + password: '123456' + }); + + tokenIsCorrect.should.eql(false); + }); + + it('extract', function () { + var expires = Date.now() + 60 * 1000, + dbHash = uuid.v4(), token, parts, email = 'test1@ghost.org'; + + token = utils.tokens.resetToken.generateHash({ + email: email, + expires: expires, + password: '12345678', + dbHash: dbHash + }); + + parts = utils.tokens.resetToken.extract({ + token: token + }); + + parts.email.should.eql(email); + parts.expires.should.eql(expires); + should.not.exist(parts.password); + should.not.exist(parts.dbHash); + }); + + it('can validate an URI encoded reset token', function () { + var expires = Date.now() + 60 * 1000, + dbHash = uuid.v4(), token, tokenIsCorrect; + + token = utils.tokens.resetToken.generateHash({ + email: 'test1@ghost.org', + expires: expires, + password: '12345678', + dbHash: dbHash + }); + + token = utils.encodeBase64URLsafe(token); + token = encodeURIComponent(token); + token = decodeURIComponent(token); + token = utils.decodeBase64URLsafe(token); + + tokenIsCorrect = utils.tokens.resetToken.compare({ + token: token, + dbHash: dbHash, + password: '12345678' + }); + + tokenIsCorrect.should.eql(true); + }); +}); +