From 2e013c33ac1e834bbae94bd470f64cbf18390f21 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 19 Apr 2018 17:19:14 -0600 Subject: [PATCH] GDPR Email Support - Email and password can be used to create a new local profile - Email can be changed with accounts that already have a local profile --- plugins/talk-plugin-auth/README.md | 1 + plugins/talk-plugin-auth/index.js | 12 +- plugins/talk-plugin-auth/server/errors.js | 25 +++ plugins/talk-plugin-auth/server/mutators.js | 158 ++++++++++++++++++ plugins/talk-plugin-auth/server/resolvers.js | 10 ++ .../talk-plugin-auth/server/translations.yml | 8 + .../talk-plugin-auth/server/typeDefs.graphql | 47 ++++++ plugins/talk-plugin-auth/server/typeDefs.js | 7 + 8 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 plugins/talk-plugin-auth/server/errors.js create mode 100644 plugins/talk-plugin-auth/server/mutators.js create mode 100644 plugins/talk-plugin-auth/server/resolvers.js create mode 100644 plugins/talk-plugin-auth/server/translations.yml create mode 100644 plugins/talk-plugin-auth/server/typeDefs.graphql create mode 100644 plugins/talk-plugin-auth/server/typeDefs.js diff --git a/plugins/talk-plugin-auth/README.md b/plugins/talk-plugin-auth/README.md index fd5218365..0a00db5ea 100644 --- a/plugins/talk-plugin-auth/README.md +++ b/plugins/talk-plugin-auth/README.md @@ -7,6 +7,7 @@ plugin: default: true provides: - Client + - Server --- This provides the base plugin that is the basis for all auth based plugins that diff --git a/plugins/talk-plugin-auth/index.js b/plugins/talk-plugin-auth/index.js index f053ebf79..7e4ee6a3c 100644 --- a/plugins/talk-plugin-auth/index.js +++ b/plugins/talk-plugin-auth/index.js @@ -1 +1,11 @@ -module.exports = {}; +const typeDefs = require('./server/typeDefs'); +const resolvers = require('./server/resolvers'); +const mutators = require('./server/mutators'); +const path = require('path'); + +module.exports = { + translations: path.join(__dirname, 'server', 'translations.yml'), + typeDefs, + mutators, + resolvers, +}; diff --git a/plugins/talk-plugin-auth/server/errors.js b/plugins/talk-plugin-auth/server/errors.js new file mode 100644 index 000000000..808ceb3a6 --- /dev/null +++ b/plugins/talk-plugin-auth/server/errors.js @@ -0,0 +1,25 @@ +const { TalkError } = require('errors'); + +// ErrNoLocalProfile is returned when there is no existing local profile +// attached to a user. +class ErrNoLocalProfile extends TalkError { + constructor() { + super('No local profile associated with account', { + translation_key: 'NO_LOCAL_PROFILE', + status: 400, + }); + } +} + +// ErrLocalProfile is returned when a profile is already attached to a user and +// the user is trying to attach a new profile to it. +class ErrLocalProfile extends TalkError { + constructor() { + super('Local profile already associated with account', { + translation_key: 'LOCAL_PROFILE', + status: 400, + }); + } +} + +module.exports = { ErrLocalProfile, ErrNoLocalProfile }; diff --git a/plugins/talk-plugin-auth/server/mutators.js b/plugins/talk-plugin-auth/server/mutators.js new file mode 100644 index 000000000..9fe0dd0db --- /dev/null +++ b/plugins/talk-plugin-auth/server/mutators.js @@ -0,0 +1,158 @@ +const { + ErrNotAuthorized, + ErrPasswordTooShort, + ErrNotFound, + ErrEmailTaken, +} = require('errors'); +const { ErrNoLocalProfile, ErrLocalProfile } = require('./errors'); +const { get } = require('lodash'); +const bcrypt = require('bcryptjs'); + +function hasLocalProfile(user) { + return Boolean( + get(user, 'profiles', []).find(({ provider }) => provider === 'local') + ); +} + +// updateUserEmailAddress will verify that the user has sent the correct +// password followed by executing the email change and notifying the emails +// about that change. +async function updateUserEmailAddress(ctx, email, confirmPassword) { + const { + user, + loaders: { Settings }, + connectors: { models: { User }, services: { Mailer, I18n, Users } }, + } = ctx; + + // Ensure that the user has a local profile associated with their account. + if (!hasLocalProfile(user)) { + throw new ErrNoLocalProfile(); + } + + // Ensure that the password provided matches what we have on file. + if (!await user.verifyPassword(confirmPassword)) { + throw new ErrNotAuthorized(); + } + + // Cleanup the email address. + email = email.toLowerCase().trim(); + + // Update the Users email address. + await User.update( + { + id: user.id, + profiles: { $elemMatch: { provider: 'local' } }, + }, + { + $set: { 'profiles.$.id': email }, + } + ); + + // Get some context for the email to be sent. + const { organizationContactEmail } = await Settings.load([ + 'organizationContactEmail', + ]); + + // Send off the email to the old email address that we have changed it. + await Mailer.send({ + email: user.firstEmail, + template: 'plain', + locals: { + body: I18n.t( + 'email.email_change_original.body', + user.email, + email, + organizationContactEmail + ), + }, + subject: I18n.t('email.email_change_original.subject'), + }); + + // Send off the email to the new email address that we need to verify the new + // address. + await Users.sendEmailConfirmation(user, email); +} + +// attachUserLocalAuth will attach a new local profile to an existing user. +async function attachUserLocalAuth(ctx, email, password) { + const { user, connectors: { models: { User }, services: { Users } } } = ctx; + + // Ensure that the current user doesn't already have a local account + // associated with them. + if (hasLocalProfile(user)) { + throw new ErrLocalProfile(); + } + + // Cleanup the email address. + email = email.toLowerCase().trim(); + + // Validate the password. + if (!password || password.length < 8) { + throw new ErrPasswordTooShort(); + } + + // Hash the new password. + const hashedPassword = await bcrypt.hash(password, 10); + + try { + // Associate the account with the user. + const updatedUser = await User.findOneAndUpdate( + { + id: user.id, + 'profiles.provider': { $ne: 'local' }, + }, + { + $push: { + profiles: { + provider: 'local', + id: email, + }, + }, + $set: { + password: hashedPassword, + }, + }, + { new: true } + ); + if (!updatedUser) { + const foundUser = await User.findOne({ id: user.id }); + if (foundUser === null) { + throw new ErrNotFound(); + } + + if (hasLocalProfile(foundUser)) { + throw new ErrLocalProfile(); + } + + throw new Error('local auth attachment failed due to unexpected reason'); + } + + // Send off the email to the new email address that we need to verify the + // new address. + await Users.sendEmailConfirmation(updatedUser, email); + } catch (err) { + if (err.code === 11000) { + throw new ErrEmailTaken(); + } + throw err; + } +} + +module.exports = ctx => { + const mutators = { + User: { + updateEmailAddress: () => Promise.reject(new ErrNotAuthorized()), + attachLocalAuth: () => Promise.reject(new ErrNotAuthorized()), + }, + }; + + if (ctx.user) { + mutators.User.updateEmailAddress = ({ email, confirmPassword }) => + updateUserEmailAddress(ctx, email, confirmPassword); + + mutators.User.attachLocalAuth = ({ email, password }) => + attachUserLocalAuth(ctx, email, password); + } + + return mutators; +}; diff --git a/plugins/talk-plugin-auth/server/resolvers.js b/plugins/talk-plugin-auth/server/resolvers.js new file mode 100644 index 000000000..23815de00 --- /dev/null +++ b/plugins/talk-plugin-auth/server/resolvers.js @@ -0,0 +1,10 @@ +module.exports = { + RootMutation: { + updateEmailAddress: async (root, { input }, { mutators: { User } }) => { + await User.updateEmailAddress(input); + }, + attachLocalAuth: async (root, { input }, { mutators: { User } }) => { + await User.attachLocalAuth(input); + }, + }, +}; diff --git a/plugins/talk-plugin-auth/server/translations.yml b/plugins/talk-plugin-auth/server/translations.yml new file mode 100644 index 000000000..d35b02525 --- /dev/null +++ b/plugins/talk-plugin-auth/server/translations.yml @@ -0,0 +1,8 @@ +en: + email: + email_change_original: + subject: Email change + body: Your email address has been changed from {0} to {1}. If you did not initiate this change, please contact {2}. + error: + NO_LOCAL_PROFILE: No existing email address is associated with this account. + LOCAL_PROFILE: An email address is already associated with this account. diff --git a/plugins/talk-plugin-auth/server/typeDefs.graphql b/plugins/talk-plugin-auth/server/typeDefs.graphql new file mode 100644 index 000000000..6202e8842 --- /dev/null +++ b/plugins/talk-plugin-auth/server/typeDefs.graphql @@ -0,0 +1,47 @@ +# UpdateEmailAddressInput provides input for changing a users email address +# associated with their account. +input UpdateEmailAddressInput { + + # email is the Users email address that they want to update to. + email: String! + + # confirmPassword is the Users current password. + confirmPassword: String! +} + +# UpdateEmailAddressResponse is returned when you try to update a users email +# address. +type UpdateEmailAddressResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# AttachLocalAuthInput provides the input for attaching a new local +# authentication profile. +input AttachLocalAuthInput { + + # email is the Users email address that they want to add. + email: String! + + # password is the Users password that they want to add. + password: String! +} + +# AttachLocalAuthResponse returns any errors for when the user attempts to +# attach a new local authentication profile. +type AttachLocalAuthResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +type RootMutation { + + # updateEmailAddress changes the email address of the current logged in user. + updateEmailAddress(input: UpdateEmailAddressInput!): UpdateEmailAddressResponse + + # attachLocalAuth will attach a new local authentication profile to an + # account. + attachLocalAuth(input: AttachLocalAuthInput!): AttachLocalAuthResponse +} diff --git a/plugins/talk-plugin-auth/server/typeDefs.js b/plugins/talk-plugin-auth/server/typeDefs.js new file mode 100644 index 000000000..7ab1954e1 --- /dev/null +++ b/plugins/talk-plugin-auth/server/typeDefs.js @@ -0,0 +1,7 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = fs.readFileSync( + path.join(__dirname, 'typeDefs.graphql'), + 'utf8' +);