0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-10 23:36:14 -05:00
ghost/core/server/services/auth/passwordreset.js
Naz c84866dda7
Improved password reset and session invalidation for "locked" users (#11790)
- Fixed session invalidation for "locked" user
  - Currently Ghost API was returning 404 for users having status set to "locked". This lead the user to be stuck in Ghost-Admin with "Rousource Not Found" error message.
  - By returning 401 for non-"active" users it allows for the Ghost-Admin to redirect the user to "signin" screen where they would be instructed to reset their password

- Fixed error message returned by session API
  - Instead of returning generic 'access' denied message when error happens during `User.check` we want to return more specific error thrown inside of the method, e.g.: 'accountLocked' or 'accountSuspended'
  - Fixed messaging for 'accountLocked' i18n, which not corresponds to the
actual UI available to the end user

- Added automatic password reset email to locked users on sign-in
  - uses alternative email for required password reset so it's clear that this is a security related reset and not a user-requested reset

- Backported the auto sending of required password reset email to v2 sign-in route
  - used by 3rd party clients where the email is necessary for users to know why login is failing

Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk>
2020-05-05 19:37:53 +01:00

181 lines
5.5 KiB
JavaScript

const _ = require('lodash');
const security = require('../../lib/security');
const constants = require('../../lib/constants');
const errors = require('@tryghost/errors');
const {i18n} = require('../../lib/common');
const models = require('../../models');
const urlUtils = require('../../lib/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.common.invalidTokenStructure')
}));
}
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 tokenIsCorrect = 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')
}));
}
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 resetUrl = urlUtils.urlJoin(adminUrl, 'reset', security.url.encodeBase64(data.resetToken), '/');
const content = await mail.utils.generateContent({
data: {
resetUrl: 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}});
}
async function sendRequiredResetNotification(data, mailAPI) {
const adminUrl = urlUtils.urlFor('admin', true);
const resetUrl = urlUtils.urlJoin(adminUrl, 'reset', security.url.encodeBase64(data.resetToken), '/');
const content = await mail.utils.generateContent({
data: {
resetUrl: resetUrl
},
template: 'reset-password-required'
});
const payload = {
mail: [{
message: {
to: data.email,
subject: i18n.t('common.api.authentication.mail.resetPasswordRequired'),
html: content.html,
text: content.text
},
options: {}
}]
};
return mailAPI.send(payload, {context: {internal: true}});
}
module.exports = {
generateToken,
extractTokenParts,
protectBruteForce,
doReset,
sendResetNotification,
sendRequiredResetNotification
};