diff --git a/core/server/models/index.js b/core/server/models/index.js index a68b148cd5..83e1ae4100 100644 --- a/core/server/models/index.js +++ b/core/server/models/index.js @@ -35,7 +35,8 @@ const models = [ 'email', 'email-batch', 'email-recipient', - 'label' + 'label', + 'single-use-token' ]; function init() { diff --git a/core/server/models/single-use-token.js b/core/server/models/single-use-token.js new file mode 100644 index 0000000000..5317ad2433 --- /dev/null +++ b/core/server/models/single-use-token.js @@ -0,0 +1,45 @@ +const ghostBookshelf = require('./base'); +const crypto = require('crypto'); + +const SingleUseToken = ghostBookshelf.Model.extend({ + tableName: 'tokens', + + defaults() { + return { + token: crypto + .randomBytes(192 / 8) + .toString('base64') + // base64url encoding means the tokens are URL safe + .replace('+', '-') + .replace('/', '_') + }; + } +}, { + async findOne(data, unfilteredOptions = {}) { + if (!unfilteredOptions.transacting) { + return ghostBookshelf.transaction((transacting) => { + return this.findOne(data, Object.assign({transacting}, unfilteredOptions)); + }); + } + const model = await ghostBookshelf.Model.findOne.call(this, data, unfilteredOptions); + + if (model) { + await this.destroy(Object.assign({ + destroyBy: { + id: model.id + } + }, unfilteredOptions)); + } + + return model; + } +}); + +const SingleUseTokens = ghostBookshelf.Collection.extend({ + model: SingleUseToken +}); + +module.exports = { + SingleUseToken: ghostBookshelf.model('SingleUseToken', SingleUseToken), + SingleUseTokens: ghostBookshelf.collection('SingleUseTokens', SingleUseTokens) +}; diff --git a/test/regression/models/model_single_use_token_spec.js b/test/regression/models/model_single_use_token_spec.js new file mode 100644 index 0000000000..f79ad950c0 --- /dev/null +++ b/test/regression/models/model_single_use_token_spec.js @@ -0,0 +1,30 @@ +const models = require('../../../core/server/models'); +const should = require('should'); + +describe('Regression: models/single-use-token', function () { + before(function () { + models.init(); + }); + + describe('findOne', function () { + it('Does not allow the same token to be read twice', async function () { + const insertedToken = await models.SingleUseToken.add({ + data: 'some_data' + }, {}); + + const tokenFirstRead = await models.SingleUseToken.findOne({ + token: insertedToken.get('token') + }); + + should.exist(tokenFirstRead); + should.equal(tokenFirstRead.id, insertedToken.id); + + const tokenSecondRead = await models.SingleUseToken.findOne({ + token: insertedToken.get('token') + }); + + should.not.exist(tokenSecondRead); + }); + }); +}); +