diff --git a/graph/mutators/user.js b/graph/mutators/user.js index 6c673111b..4bdc746ba 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -9,6 +9,7 @@ const { SET_USER_SUSPENSION_STATUS, UPDATE_USER_ROLES, DELETE_USER, + CHANGE_PASSWORD, } = require('../../perms/constants'); const setUserUsernameStatus = async (ctx, id, status) => { @@ -143,6 +144,38 @@ const delUser = async (ctx, id) => { await user.remove(); }; +const changeUserPassword = async (ctx, oldPassword, newPassword) => { + const { + user, + loaders: { Settings }, + connectors: { services: { I18n } }, + } = ctx; + + // Verify the old password. + const validPassword = await user.verifyPassword(oldPassword); + if (!validPassword) { + throw new ErrNotAuthorized(); + } + + // Change the users password now. + await Users.changePassword(user.id, newPassword); + + // Get some context for the email to be sent. + const { organizationName, organizationContactEmail } = await Settings.load( + 'organizationName', + 'organizationContactEmail' + ); + + // Send the password change email. + await Users.sendEmail(user, { + template: 'plain', + locals: { + body: I18n.t('email.password_change.body', organizationName), + }, + subject: I18n.t('email.password_change.subject', organizationContactEmail), + }); +}; + module.exports = ctx => { let mutators = { User: { @@ -194,6 +227,11 @@ module.exports = ctx => { if (ctx.user.can(DELETE_USER)) { mutators.User.del = id => delUser(ctx, id); } + + if (ctx.user.can(CHANGE_PASSWORD)) { + mutators.User.changePassword = ({ oldPassword, newPassword }) => + changeUserPassword(ctx, oldPassword, newPassword); + } } return mutators; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index f94322344..2400f4e0b 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -1436,6 +1436,21 @@ type DelUserResponse implements Response { errors: [UserError!] } +input ChangePasswordInput { + # oldPassword is the previous password set on the account. An incorrect + # password here will result in an unauthorized error being thrown. + oldPassword: String! + + # newPassword is the password we're changing it to. + newPassword: String! +} + +type ChangePasswordResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + # All mutations for the application are defined on this object. type RootMutation { @@ -1536,6 +1551,10 @@ type RootMutation { # delUser will delete the user with the specified id. delUser(id: ID!): DelUserResponse + + # changePassword allows the current user to change their password that have an + # associated local user account. + changePassword(input: ChangePasswordInput!): ChangePasswordResponse } type UsernameChangedPayload { diff --git a/locales/en.yml b/locales/en.yml index 50845b96f..5524095d0 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -210,6 +210,9 @@ en: we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email." if_you_did: "If you did," please_click: "please click here to reset password" + password_change: + subject: "{0} password change" + body: "Password was changed on your account.\n\nIf you did not request this change, please contact us at {0}." embedlink: copy: "Copy to Clipboard" error: diff --git a/perms/constants/mutation.js b/perms/constants/mutation.js index d2ebe73f1..57aa254e7 100644 --- a/perms/constants/mutation.js +++ b/perms/constants/mutation.js @@ -19,4 +19,5 @@ module.exports = { UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS', UPDATE_SETTINGS: 'UPDATE_SETTINGS', DELETE_USER: 'DELETE_USER', + CHANGE_PASSWORD: 'CHANGE_PASSWORD', }; diff --git a/perms/reducers/mutation.js b/perms/reducers/mutation.js index 73ee7ef28..d0841ef99 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -1,8 +1,17 @@ +const { isString } = require('lodash'); const { check } = require('../utils'); const types = require('../constants'); module.exports = (user, perm) => { switch (perm) { + case types.CHANGE_PASSWORD: + // Only users with a local account where they have a password set can + // actually change their password. + return ( + user.profiles.some(({ provider }) => provider === 'local') && + isString(user.password) && + user.password.length > 0 + ); case types.CHANGE_USERNAME: return user.status.username.status === 'REJECTED'; diff --git a/routes/api/v1/account.js b/routes/api/v1/account.js index 3909d5dce..bc642f93f 100644 --- a/routes/api/v1/account.js +++ b/routes/api/v1/account.js @@ -109,20 +109,11 @@ router.put( async (req, res, next) => { const { token, password } = req.body; - if (!password || password.length < 8) { - return next(errors.ErrPasswordTooShort); - } - try { - let [user, redirect] = await UsersService.verifyPasswordResetToken(token); - - // Change the users' password. - await UsersService.changePassword(user.id, password); - + const { redirect } = await UsersService.resetPassword(token, password); res.json({ redirect }); - } catch (e) { - console.error(e); - return next(errors.ErrNotAuthorized); + } catch (err) { + return next(err); } } ); diff --git a/services/users.js b/services/users.js index 93d6fd753..657be737a 100644 --- a/services/users.js +++ b/services/users.js @@ -132,7 +132,7 @@ class Users { locals: { body: message, }, - subject: 'Your account has been suspended', + subject: 'Your account has been suspended', // TODO: replace with translation }); } @@ -490,6 +490,10 @@ class Users { } static async changePassword(id, password) { + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); return User.update( @@ -725,18 +729,13 @@ class Users { }); } - /** - * Verifies a jwt and returns the associated user. Throws an error when the - * token isn't valid. - * - * @param {String} token the JSON Web Token to verify - */ + // TODO: update doc static async verifyPasswordResetToken(token) { if (!token) { throw new Error('cannot verify an empty token'); } - const { userId, loc, version } = await Users.verifyToken(token, { + const { userId, loc: redirect, version } = await Users.verifyToken(token, { subject: PASSWORD_RESET_JWT_SUBJECT, }); @@ -746,7 +745,33 @@ class Users { throw new Error('password reset token has expired'); } - return [user, loc]; + return { user, redirect, version }; + } + + // TODO: update doc + static async resetPassword(token, password) { + const { user, redirect, version } = await this.verifyPasswordResetToken( + token + ); + + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } + + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + + // Update the user's password. + await User.update( + { id: user.id, __v: version }, + { + $inc: { __v: 1 }, + $set: { + password: hashedPassword, + }, + } + ); + + return { user, redirect }; } /**