From 7c5a3bb537963ff20070bc77d9595394d8b562db Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Fri, 18 Sep 2020 17:32:18 +0100 Subject: [PATCH] Updated magic links to use shorter, single us, longer lived tokens (#12218) no-issue * Added SingleUseTokenProvider to members service This implements the TokenProvider interface required by members-api to generate magic links. It handles checking if the token is expired and pulls out any associated data. Future improvments may include the email in the error for expired tokens, which would make resending a token simpler. * Passed SingleUseTokenProvider to members-api This sets up the members-api module to use the new single use tokens * Installed @tryghost/members-api@0.30.0 This includes the change to allow us to pass a token provider to the members-api --- .../members/SingleUseTokenProvider.js | 67 +++++++++++++++++++ core/server/services/members/api.js | 4 +- package.json | 2 +- yarn.lock | 22 ++---- 4 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 core/server/services/members/SingleUseTokenProvider.js diff --git a/core/server/services/members/SingleUseTokenProvider.js b/core/server/services/members/SingleUseTokenProvider.js new file mode 100644 index 0000000000..f7bb694c30 --- /dev/null +++ b/core/server/services/members/SingleUseTokenProvider.js @@ -0,0 +1,67 @@ +// @ts-check +const {UnauthorizedError} = require('@tryghost/errors'); + +class SingleUseTokenProvider { + /** + * @param {import('../../models/base')} SingleUseTokenModel - A model for creating and retrieving tokens. + * @param {number} validity - How long a token is valid for from it's creation in milliseconds. + */ + constructor(SingleUseTokenModel, validity) { + this.model = SingleUseTokenModel; + this.validity = validity; + } + + /** + * @method create + * Creates and stores a token, with the passed data associated with it. + * Returns the created token value. + * + * @param {Object} data + * + * @returns {Promise} + */ + async create(data) { + const model = await this.model.add({ + data: JSON.stringify(data) + }); + + return model.get('token'); + } + + /** + * @method validate + * Validates a token, returning any parsable data associated. + * If the token is invalid the returned Promise will reject. + * + * @param {string} token + * + * @returns {Promise>} + */ + async validate(token) { + const model = await this.model.findOne({token}); + + if (!model) { + throw new UnauthorizedError({ + message: 'Invalid token provided' + }); + } + + const createdAtEpoch = model.get('created_at').getTime(); + + const tokenLifetimeMilliseconds = Date.now() - createdAtEpoch; + + if (tokenLifetimeMilliseconds > this.validity) { + throw new UnauthorizedError({ + message: 'Token expired' + }); + } + + try { + return JSON.parse(model.get('data')); + } catch (err) { + return {}; + } + } +} + +module.exports = SingleUseTokenProvider; diff --git a/core/server/services/members/api.js b/core/server/services/members/api.js index 8b7878d4d3..1a2f3d773a 100644 --- a/core/server/services/members/api.js +++ b/core/server/services/members/api.js @@ -6,6 +6,8 @@ const models = require('../../models'); const signinEmail = require('./emails/signin'); const signupEmail = require('./emails/signup'); const subscribeEmail = require('./emails/subscribe'); +const SingleUseTokenProvider = require('./SingleUseTokenProvider'); +const MAGIC_LINK_TOKEN_VALIDITY = 4 * 60 * 60 * 1000; const ghostMailer = new mail.GhostMailer(); @@ -17,7 +19,7 @@ function createApiInstance(config) { auth: { getSigninURL: config.getSigninURL.bind(config), allowSelfSignup: config.getAllowSelfSignup(), - secret: config.getAuthSecret() + tokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY) }, mail: { transporter: { diff --git a/package.json b/package.json index 929637b881..4c72a2ab00 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@tryghost/kg-markdown-html-renderer": "2.0.2", "@tryghost/kg-mobiledoc-html-renderer": "3.0.1", "@tryghost/magic-link": "0.6.0", - "@tryghost/members-api": "0.28.2", + "@tryghost/members-api": "0.30.0", "@tryghost/members-csv": "0.3.0", "@tryghost/members-ssr": "0.8.5", "@tryghost/mw-session-from-token": "0.1.7", diff --git a/yarn.lock b/yarn.lock index 18e3d2bc08..f87d2ab775 100644 --- a/yarn.lock +++ b/yarn.lock @@ -481,7 +481,7 @@ dependencies: "@tryghost/kg-clean-basic-html" "^1.0.6" -"@tryghost/magic-link@0.6.0": +"@tryghost/magic-link@0.6.0", "@tryghost/magic-link@^0.6.0": version "0.6.0" resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.6.0.tgz#a656e30385b60f44e5678819d6184c65f0757794" integrity sha512-KeE1dpCCAhZy34pjXirn1w1Oq4+dNsX9XqyZMvdruiYEB+88WkL/x//aNGCo84RoerTr1W8TGQF/1lh4ulrsZg== @@ -491,22 +491,12 @@ jsonwebtoken "^8.5.1" lodash "^4.17.15" -"@tryghost/magic-link@^0.4.13": - version "0.4.13" - resolved "https://registry.yarnpkg.com/@tryghost/magic-link/-/magic-link-0.4.13.tgz#266940cec6f0fe837071621b43dfaa08f552d23c" - integrity sha512-m5rz9rDEeRwrDePRiw/5t3D/yfDkqtvhjWNRUqL0NXhvD/m8f8soP1m1fY46JVaUd35xGDa4BQRnFhXerNId3w== +"@tryghost/members-api@0.30.0": + version "0.30.0" + resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.30.0.tgz#459a82369786110e80e9d0287e1a1acf80edc688" + integrity sha512-meu0ipF0AGpY8f80yRbllW1XSpjQWeFwTR0Coq0Mpzsw9u6Ryodppq2uUSxFCUKyb6BtJLW6q+pJAvgB+eV2Gg== dependencies: - bluebird "^3.5.5" - ghost-ignition "4.2.2" - jsonwebtoken "^8.5.1" - lodash "^4.17.15" - -"@tryghost/members-api@0.28.2": - version "0.28.2" - resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.28.2.tgz#9ed4464754f9b8e70d1d4e62e8e50ca139346b6d" - integrity sha512-LgfhjLpSUusyjrDIDreXEH8PqdIsQ97q77opl3MRITMhTUS9qz0Q6GfCpuZfq0nVQ1qGx79pPC4K3CQqNRK/4w== - dependencies: - "@tryghost/magic-link" "^0.4.13" + "@tryghost/magic-link" "^0.6.0" bluebird "^3.5.4" body-parser "^1.19.0" cookies "^0.8.0"