mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-30 06:12:03 +03:00
32b37d7ba8
refs #11878 - When password reset link is invalid previous messaging left the user without clear information about why the reset failed and what they could do about it. - Updated messaging around password reset tokens including detection of when password token has invalid structure, has expired or has already been used
171 lines
5.6 KiB
JavaScript
171 lines
5.6 KiB
JavaScript
const _ = require('lodash');
|
|
const security = require('@tryghost/security');
|
|
const constants = require('@tryghost/constants');
|
|
const errors = require('@tryghost/errors');
|
|
const {i18n} = require('../../lib/common');
|
|
const models = require('../../models');
|
|
const urlUtils = require('../../../shared/url-utils');
|
|
const mail = require('../mail');
|
|
|
|
const tokenSecurity = {};
|
|
|
|
function generateToken(email, settingsAPI) {
|
|
const options = {context: {internal: true}};
|
|
let dbHash;
|
|
let token;
|
|
|
|
return settingsAPI.read(_.merge({key: 'db_hash'}, options))
|
|
.then((response) => {
|
|
dbHash = response.settings[0].value;
|
|
|
|
return models.User.getByEmail(email, options);
|
|
})
|
|
.then((user) => {
|
|
if (!user) {
|
|
throw new errors.NotFoundError({message: i18n.t('errors.api.users.userNotFound')});
|
|
}
|
|
|
|
token = security.tokens.resetToken.generateHash({
|
|
expires: Date.now() + constants.ONE_DAY_MS,
|
|
email: email,
|
|
dbHash: dbHash,
|
|
password: user.get('password')
|
|
});
|
|
|
|
return {
|
|
email: email,
|
|
resetToken: token
|
|
};
|
|
});
|
|
}
|
|
|
|
function extractTokenParts(options) {
|
|
options.data.passwordreset[0].token = security.url.decodeBase64(options.data.passwordreset[0].token);
|
|
|
|
const tokenParts = security.tokens.resetToken.extract({
|
|
token: options.data.passwordreset[0].token
|
|
});
|
|
|
|
if (!tokenParts) {
|
|
return Promise.reject(new errors.UnauthorizedError({
|
|
message: i18n.t('errors.api.passwordReset.corruptedToken.message'),
|
|
context: i18n.t('errors.api.passwordReset.corruptedToken.context'),
|
|
help: i18n.t('errors.api.passwordReset.corruptedToken.help')
|
|
}));
|
|
}
|
|
|
|
return Promise.resolve({options, tokenParts});
|
|
}
|
|
|
|
// @TODO: use brute force middleware (see https://github.com/TryGhost/Ghost/pull/7579)
|
|
function protectBruteForce({options, tokenParts}) {
|
|
if (tokenSecurity[`${tokenParts.email}+${tokenParts.expires}`] &&
|
|
tokenSecurity[`${tokenParts.email}+${tokenParts.expires}`].count >= 10) {
|
|
return Promise.reject(new errors.NoPermissionError({
|
|
message: i18n.t('errors.models.user.tokenLocked')
|
|
}));
|
|
}
|
|
|
|
return Promise.resolve({options, tokenParts});
|
|
}
|
|
|
|
function doReset(options, tokenParts, settingsAPI) {
|
|
let dbHash;
|
|
|
|
const data = options.data.passwordreset[0];
|
|
const resetToken = data.token;
|
|
const oldPassword = data.oldPassword;
|
|
const newPassword = data.newPassword;
|
|
|
|
return settingsAPI.read(_.merge({key: 'db_hash'}, _.omit(options, 'data')))
|
|
.then((response) => {
|
|
dbHash = response.settings[0].value;
|
|
|
|
return models.User.getByEmail(tokenParts.email, options);
|
|
})
|
|
.then((user) => {
|
|
if (!user) {
|
|
throw new errors.NotFoundError({message: i18n.t('errors.api.users.userNotFound')});
|
|
}
|
|
|
|
let compareResult = security.tokens.resetToken.compare({
|
|
token: resetToken,
|
|
dbHash: dbHash,
|
|
password: user.get('password')
|
|
});
|
|
|
|
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({
|
|
oldPassword: oldPassword,
|
|
newPassword: newPassword,
|
|
user_id: user.id
|
|
}, options);
|
|
})
|
|
.then((updatedUser) => {
|
|
updatedUser.set('status', 'active');
|
|
return updatedUser.save(options);
|
|
})
|
|
.catch(errors.ValidationError, (err) => {
|
|
return Promise.reject(err);
|
|
})
|
|
.catch((err) => {
|
|
if (errors.utils.isIgnitionError(err)) {
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.reject(new errors.UnauthorizedError({err: err}));
|
|
});
|
|
}
|
|
|
|
async function sendResetNotification(data, mailAPI) {
|
|
const adminUrl = urlUtils.urlFor('admin', true);
|
|
const resetToken = security.url.encodeBase64(data.resetToken);
|
|
const resetUrl = urlUtils.urlJoin(adminUrl, 'reset', resetToken, '/');
|
|
|
|
const content = await mail.utils.generateContent({
|
|
data: {
|
|
resetUrl
|
|
},
|
|
template: 'reset-password'
|
|
});
|
|
|
|
const payload = {
|
|
mail: [{
|
|
message: {
|
|
to: data.email,
|
|
subject: i18n.t('common.api.authentication.mail.resetPassword'),
|
|
html: content.html,
|
|
text: content.text
|
|
},
|
|
options: {}
|
|
}]
|
|
};
|
|
|
|
return mailAPI.send(payload, {context: {internal: true}});
|
|
}
|
|
|
|
module.exports = {
|
|
generateToken,
|
|
extractTokenParts,
|
|
protectBruteForce,
|
|
doReset,
|
|
sendResetNotification
|
|
};
|