diff --git a/core/server/services/auth/passwordreset.js b/core/server/services/auth/passwordreset.js index fa9796c995..cfb0d78d2e 100644 --- a/core/server/services/auth/passwordreset.js +++ b/core/server/services/auth/passwordreset.js @@ -48,7 +48,9 @@ function extractTokenParts(options) { if (!tokenParts) { return Promise.reject(new errors.UnauthorizedError({ - message: i18n.t('errors.api.common.invalidTokenStructure') + message: i18n.t('errors.api.passwordReset.corruptedToken.message'), + context: i18n.t('errors.api.passwordReset.corruptedToken.context'), + help: i18n.t('errors.api.passwordReset.corruptedToken.help') })); } @@ -86,16 +88,29 @@ function doReset(options, tokenParts, settingsAPI) { throw new errors.NotFoundError({message: i18n.t('errors.api.users.userNotFound')}); } - let tokenIsCorrect = security.tokens.resetToken.compare({ + let compareResult = security.tokens.resetToken.compare({ token: resetToken, dbHash: dbHash, password: user.get('password') }); - if (!tokenIsCorrect) { - return Promise.reject(new errors.BadRequestError({ - message: i18n.t('errors.api.common.invalidTokenStructure') - })); + if (!compareResult.correct) { + let error; + if (compareResult.reason === 'expired' || compareResult.reason === 'invalid_expiry') { + error = new errors.BadRequestError({ + message: i18n.t('errors.api.passwordReset.expired.message'), + context: i18n.t('errors.api.passwordReset.expired.context'), + help: i18n.t('errors.api.passwordReset.expired.help') + }); + } else { + error = new errors.BadRequestError({ + message: i18n.t('errors.api.passwordReset.invalidToken.message'), + context: i18n.t('errors.api.passwordReset.invalidToken.context'), + help: i18n.t('errors.api.passwordReset.invalidToken.help') + }); + } + + return Promise.reject(error); } return models.User.changePassword({ diff --git a/core/server/translations/en.json b/core/server/translations/en.json index 705a0387e6..1f1a71d182 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -302,7 +302,6 @@ }, "api": { "common": { - "invalidTokenStructure": "Invalid token structure", "notImplemented": "The server does not support the functionality required to fulfill the request." }, "authentication": { @@ -318,6 +317,23 @@ "checkEmailConfigInstructions": "Please see {url} for instructions on configuring email.", "notTheBlogOwner": "You are not the site owner." }, + "passwordReset": { + "expired": { + "message": "Cannot reset password.", + "context": "Password reset link expired.", + "help": "Request a new password reset via the login form." + }, + "invalidToken": { + "message": "Cannot reset password.", + "context": "Password reset link has already been used.", + "help": "Request a new password reset via the login form." + }, + "corruptedToken": { + "message": "Cannot reset password.", + "context": "Invalid password reset link.", + "help": "Check if password reset link has been fully copied or request new password reset via the login form." + } + }, "configuration": { "invalidKey": "Invalid key" }, diff --git a/package.json b/package.json index ee4dd2d10e..5386e98982 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@tryghost/members-ssr": "0.8.5", "@tryghost/mw-session-from-token": "0.1.7", "@tryghost/promise": "0.1.0", - "@tryghost/security": "0.1.0", + "@tryghost/security": "0.2.0", "@tryghost/session-service": "0.1.8", "@tryghost/social-urls": "0.1.12", "@tryghost/string": "0.1.11", diff --git a/test/regression/api/canary/admin/authentication_spec.js b/test/regression/api/canary/admin/authentication_spec.js index c6b12526c4..98056d3a1a 100644 --- a/test/regression/api/canary/admin/authentication_spec.js +++ b/test/regression/api/canary/admin/authentication_spec.js @@ -305,7 +305,72 @@ describe('Authentication API v3', function () { .then((res) => { should.exist(res.body.errors); res.body.errors[0].type.should.eql('UnauthorizedError'); - res.body.errors[0].message.should.eql('Invalid token structure'); + res.body.errors[0].message.should.eql('Cannot reset password.'); + res.body.errors[0].context.should.eql('Invalid password reset link.'); + }); + }); + + it('reset password: expired token', function () { + return models.User.getOwnerUser(testUtils.context.internal) + .then(function (ownerUser) { + const dateInThePast = Date.now() - (1000 * 60); + const token = security.tokens.resetToken.generateHash({ + expires: dateInThePast, + email: user.email, + dbHash: settingsCache.get('db_hash'), + password: ownerUser.get('password') + }); + + return request + .put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: token, + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(400); + }) + .then((res) => { + should.exist(res.body.errors); + res.body.errors[0].type.should.eql('BadRequestError'); + res.body.errors[0].message.should.eql('Cannot reset password.'); + res.body.errors[0].context.should.eql('Password reset link expired.'); + }); + }); + + it('reset password: unmatched token', function () { + const token = security.tokens.resetToken.generateHash({ + expires: Date.now() + (1000 * 60), + email: user.email, + dbHash: settingsCache.get('db_hash'), + password: 'invalid_password' + }); + + return request + .put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: token, + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(400) + .then((res) => { + should.exist(res.body.errors); + res.body.errors[0].type.should.eql('BadRequestError'); + res.body.errors[0].message.should.eql('Cannot reset password.'); + res.body.errors[0].context.should.eql('Password reset link has already been used.'); }); }); diff --git a/yarn.lock b/yarn.lock index 600422a592..5b4afef632 100644 --- a/yarn.lock +++ b/yarn.lock @@ -556,15 +556,15 @@ dependencies: bluebird "3.7.2" -"@tryghost/security@0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@tryghost/security/-/security-0.1.0.tgz#ce78111dd6febb7705cf62d6838b0d8cdfb7df9f" - integrity sha512-jDiLbsN9zCAZhGIibCb92Ym30BBWzVSZx2DBPL+Ige8b95pefEx6d8humbCjXhMIcJS5dlaMw4Pk70mXLCXCvQ== +"@tryghost/security@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@tryghost/security/-/security-0.2.0.tgz#3f2ffa10bd933a21b19f659be9636146fb74a790" + integrity sha512-plCxkYWD22mCQowH6boGakc2svv6Xqc7W1gJL007baBHHFR4XvWigseTxit1sb6FsI+GpzV16lBLtUUNiYTQTA== dependencies: - "@tryghost/string" "0.1.10" + "@tryghost/string" "0.1.11" bcryptjs "2.4.3" bluebird "3.7.2" - lodash "4.17.19" + lodash "4.17.20" "@tryghost/session-service@0.1.8": version "0.1.8" @@ -582,13 +582,6 @@ ghost-ignition "4.2.2" lodash "4.17.19" -"@tryghost/string@0.1.10": - version "0.1.10" - resolved "https://registry.yarnpkg.com/@tryghost/string/-/string-0.1.10.tgz#6fb6a16cc628e6c22397c5889a7a85c29171374a" - integrity sha512-TqkuNTvM6zOHIugAc3lnok4U4e4kKLvZz/Oo5Kka1jMHe70SrJZ3X1ljZEn7RloV3PdHmLUE5zN3Zvj2aC1avg== - dependencies: - unidecode "^0.1.8" - "@tryghost/string@0.1.11": version "0.1.11" resolved "https://registry.yarnpkg.com/@tryghost/string/-/string-0.1.11.tgz#259e101a6fa7d08870e5c3b12091f62eb61c3725"