diff --git a/backend/src/middleware/email/emailMiddleware.js b/backend/src/middleware/email/emailMiddleware.js index 0b7cfd058..809ca4072 100644 --- a/backend/src/middleware/email/emailMiddleware.js +++ b/backend/src/middleware/email/emailMiddleware.js @@ -3,6 +3,22 @@ import nodemailer from 'nodemailer' import { resetPasswordMail, wrongAccountMail } from './templates/passwordReset' import { signupTemplate } from './templates/signup' +let sendMail +if (CONFIG.SMTP_HOST && CONFIG.SMTP_PORT) { + sendMail = async templateArgs => { + await transporter().sendMail({ + from: '"Human Connection" ', + ...templateArgs, + }) + } +} else { + sendMail = () => {} + if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console + console.log('Warning: Email middleware will not try to send mails.') + } +} + const transporter = () => { const configs = { host: CONFIG.SMTP_HOST, @@ -17,41 +33,24 @@ const transporter = () => { return nodemailer.createTransport(configs) } -const returnResponse = async (resolve, root, args, context, resolveInfo) => { - const { response } = await resolve(root, args, context, resolveInfo) - delete response.nonce - return response -} - const sendSignupMail = async (resolve, root, args, context, resolveInfo) => { - const { email } = args - const { response, nonce } = await resolve(root, args, context, resolveInfo) + const response = await resolve(root, args, context, resolveInfo) + const { email, nonce } = response + await sendMail(signupTemplate({ email, nonce })) delete response.nonce - await transporter().sendMail(signupTemplate({ email, nonce })) return response } -export default function({ isEnabled }) { - if (!isEnabled) - return { - Mutation: { - requestPasswordReset: returnResponse, - Signup: returnResponse, - SignupByInvitation: returnResponse, - }, - } - - return { - Mutation: { - requestPasswordReset: async (resolve, root, args, context, resolveInfo) => { - const { email } = args - const { response, user, code, name } = await resolve(root, args, context, resolveInfo) - const mailTemplate = user ? resetPasswordMail : wrongAccountMail - await transporter().sendMail(mailTemplate({ email, code, name })) - return response - }, - Signup: sendSignupMail, - SignupByInvitation: sendSignupMail, +export default { + Mutation: { + requestPasswordReset: async (resolve, root, args, context, resolveInfo) => { + const { email } = args + const { email: emailFound, nonce, name } = await resolve(root, args, context, resolveInfo) + const mailTemplate = emailFound ? resetPasswordMail : wrongAccountMail + await sendMail(mailTemplate({ email, nonce, name })) + return true }, - } + Signup: sendSignupMail, + SignupByInvitation: sendSignupMail, + }, } diff --git a/backend/src/middleware/email/templates/passwordReset.js b/backend/src/middleware/email/templates/passwordReset.js index 8508adccc..c977594b5 100644 --- a/backend/src/middleware/email/templates/passwordReset.js +++ b/backend/src/middleware/email/templates/passwordReset.js @@ -1,17 +1,15 @@ import CONFIG from '../../../config' -export const from = '"Human Connection" ' - export const resetPasswordMail = options => { const { name, email, - code, + nonce, subject = 'Use this link to reset your password. The link is only valid for 24 hours.', supportUrl = 'https://human-connection.org/en/contact/', } = options const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI) - actionUrl.searchParams.set('code', code) + actionUrl.searchParams.set('nonce', nonce) actionUrl.searchParams.set('email', email) return { @@ -37,7 +35,7 @@ The Human Connection Team If you're having trouble with the link above, you can manually copy and paste the following code into your browser window: -${code} +${nonce} Human Connection gemeinnützige GmbH Bahnhofstr. 11 diff --git a/backend/src/middleware/email/templates/signup.js b/backend/src/middleware/email/templates/signup.js index 7751f0e67..54cc51be2 100644 --- a/backend/src/middleware/email/templates/signup.js +++ b/backend/src/middleware/email/templates/signup.js @@ -1,12 +1,10 @@ import CONFIG from '../../../config' -export const from = '"Human Connection" ' - export const signupTemplate = options => { const { email, nonce, - subject = 'Signup link', + subject = 'Welcome to Human Connection! Here is your signup link.', supportUrl = 'https://human-connection.org/en/contact/', } = options const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI) @@ -17,12 +15,33 @@ export const signupTemplate = options => { to: email, subject, text: ` +Willkommen bei Human Connection! Klick auf diesen Link, um den +Registrierungsprozess abzuschließen und um ein Benutzerkonto zu erstellen! + +${actionUrl} + +Alternativ kannst du diesen Code auch kopieren und im Browserfenster einfügen: + +${nonce} + +Bitte ignoriere diese Mail, falls du dich nicht bei Human Connection angemeldet +hast. Bei Fragen kontaktiere gerne unseren Support: + +${supportUrl} + +Danke, +Das Human Connection Team + + +English Version +=============== + Welcome to Human Connection! Use this link to complete the registration process and create a user account: ${actionUrl} -You can also copy+paste this verification code in your browser window: +You can also copy+paste this verification nonce in your browser window: ${nonce} diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 1dd630ebc..0c68ef4d9 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -33,9 +33,7 @@ export default schema => { user, includedFields, orderBy, - email: email({ - isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT, - }), + email, } let order = [ diff --git a/backend/src/schema/resolvers/passwordReset.js b/backend/src/schema/resolvers/passwordReset.js index d2012c0fd..3c5f4636c 100644 --- a/backend/src/schema/resolvers/passwordReset.js +++ b/backend/src/schema/resolvers/passwordReset.js @@ -2,39 +2,47 @@ import uuid from 'uuid/v4' import bcrypt from 'bcryptjs' export async function createPasswordReset(options) { - const { driver, code, email, issuedAt = new Date() } = options + const { driver, nonce, email, issuedAt = new Date() } = options const session = driver.session() - const cypher = ` + let response = {} + try { + const cypher = ` MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email}) - CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL}) + CREATE(pr:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL}) MERGE (u)-[:REQUESTED]->(pr) - RETURN u + RETURN e, pr, u ` - const transactionRes = await session.run(cypher, { - issuedAt: issuedAt.toISOString(), - code, - email, - }) - const users = transactionRes.records.map(record => record.get('u')) - session.close() - return users + const transactionRes = await session.run(cypher, { + issuedAt: issuedAt.toISOString(), + nonce, + email, + }) + const records = transactionRes.records.map(record => { + const { email } = record.get('e').properties + const { nonce } = record.get('pr').properties + const { name } = record.get('u').properties + return { email, nonce, name } + }) + response = records[0] || {} + } finally { + session.close() + } + return response } export default { Mutation: { - requestPasswordReset: async (_, { email }, { driver }) => { - const code = uuid().substring(0, 6) - const [user] = await createPasswordReset({ driver, code, email }) - const name = (user && user.name) || '' - return { user, code, name, response: true } + requestPasswordReset: async (_parent, { email }, { driver }) => { + const nonce = uuid().substring(0, 6) + return createPasswordReset({ driver, nonce, email }) }, - resetPassword: async (_, { email, code, newPassword }, { driver }) => { + resetPassword: async (_parent, { email, nonce, newPassword }, { driver }) => { const session = driver.session() const stillValid = new Date() stillValid.setDate(stillValid.getDate() - 1) const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) const cypher = ` - MATCH (pr:PasswordReset {code: $code}) + MATCH (pr:PasswordReset {nonce: $nonce}) MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr) WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL SET pr.usedAt = datetime() @@ -44,13 +52,13 @@ export default { const transactionRes = await session.run(cypher, { stillValid, email, - code, + nonce, encryptedNewPassword, }) const [reset] = transactionRes.records.map(record => record.get('pr')) - const result = !!(reset && reset.properties.usedAt) + const response = !!(reset && reset.properties.usedAt) session.close() - return result + return response }, }, } diff --git a/backend/src/schema/resolvers/passwordReset.spec.js b/backend/src/schema/resolvers/passwordReset.spec.js index b54b25a80..fabee1c7e 100644 --- a/backend/src/schema/resolvers/passwordReset.spec.js +++ b/backend/src/schema/resolvers/passwordReset.spec.js @@ -1,12 +1,17 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../../seed/factories' -import { host } from '../../jest/helpers' -import { getDriver } from '../../bootstrap/neo4j' +import { gql } from '../../jest/helpers' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' import { createPasswordReset } from './passwordReset' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' -const factory = Factory() -let client +const neode = getNeode() const driver = getDriver() +const factory = Factory() + +let mutate +let authenticatedUser +let variables const getAllPasswordResets = async () => { const session = driver.session() @@ -16,120 +21,167 @@ const getAllPasswordResets = async () => { return resets } +beforeEach(() => { + variables = {} +}) + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + mutate = createTestClient(server).mutate +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + describe('passwordReset', () => { - beforeEach(async () => { - client = new GraphQLClient(host) - await factory.create('User', { - email: 'user@example.org', - role: 'user', - password: '1234', + describe('given a user', () => { + beforeEach(async () => { + await factory.create('User', { + email: 'user@example.org', + }) }) - }) - afterEach(async () => { - await factory.cleanDatabase() - }) + describe('requestPasswordReset', () => { + const mutation = gql` + mutation($email: String!) { + requestPasswordReset(email: $email) + } + ` - describe('requestPasswordReset', () => { - const mutation = `mutation($email: String!) { requestPasswordReset(email: $email) }` + describe('with invalid email', () => { + beforeEach(() => { + variables = { ...variables, email: 'non-existent@example.org' } + }) - describe('with invalid email', () => { - const variables = { email: 'non-existent@example.org' } + it('resolves anyways', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { requestPasswordReset: true }, + }) + }) - it('resolves anyways', async () => { - await expect(client.request(mutation, variables)).resolves.toEqual({ - requestPasswordReset: true, + it('creates no node', async () => { + await mutate({ mutation, variables }) + const resets = await getAllPasswordResets() + expect(resets).toHaveLength(0) }) }) - it('creates no node', async () => { - await client.request(mutation, variables) - const resets = await getAllPasswordResets() - expect(resets).toHaveLength(0) - }) - }) - - describe('with a valid email', () => { - const variables = { email: 'user@example.org' } - - it('resolves', async () => { - await expect(client.request(mutation, variables)).resolves.toEqual({ - requestPasswordReset: true, + describe('with a valid email', () => { + beforeEach(() => { + variables = { ...variables, email: 'user@example.org' } }) - }) - it('creates node with label `PasswordReset`', async () => { - await client.request(mutation, variables) - const resets = await getAllPasswordResets() - expect(resets).toHaveLength(1) - }) + it('resolves', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { requestPasswordReset: true }, + }) + }) - it('creates a reset code', async () => { - await client.request(mutation, variables) - const resets = await getAllPasswordResets() - const [reset] = resets - const { code } = reset.properties - expect(code).toHaveLength(6) + it('creates node with label `PasswordReset`', async () => { + let resets = await getAllPasswordResets() + expect(resets).toHaveLength(0) + await mutate({ mutation, variables }) + resets = await getAllPasswordResets() + expect(resets).toHaveLength(1) + }) + + it('creates a reset nonce', async () => { + await mutate({ mutation, variables }) + const resets = await getAllPasswordResets() + const [reset] = resets + const { nonce } = reset.properties + expect(nonce).toHaveLength(6) + }) }) }) }) +}) - describe('resetPassword', () => { - const setup = async (options = {}) => { - const { email = 'user@example.org', issuedAt = new Date(), code = 'abcdef' } = options +describe('resetPassword', () => { + const setup = async (options = {}) => { + const { email = 'user@example.org', issuedAt = new Date(), nonce = 'abcdef' } = options - const session = driver.session() - await createPasswordReset({ driver, email, issuedAt, code }) - session.close() + const session = driver.session() + await createPasswordReset({ driver, email, issuedAt, nonce }) + session.close() + } + + const mutation = gql` + mutation($nonce: String!, $email: String!, $newPassword: String!) { + resetPassword(nonce: $nonce, email: $email, newPassword: $newPassword) } + ` + beforeEach(() => { + variables = { ...variables, newPassword: 'supersecret' } + }) - const mutation = `mutation($code: String!, $email: String!, $newPassword: String!) { resetPassword(code: $code, email: $email, newPassword: $newPassword) }` - const email = 'user@example.org' - const code = 'abcdef' - const newPassword = 'supersecret' - let variables + describe('given a user', () => { + beforeEach(async () => { + await factory.create('User', { + email: 'user@example.org', + role: 'user', + password: '1234', + }) + }) describe('invalid email', () => { it('resolves to false', async () => { await setup() - variables = { newPassword, email: 'non-existent@example.org', code } - await expect(client.request(mutation, variables)).resolves.toEqual({ resetPassword: false }) + variables = { ...variables, email: 'non-existent@example.org', nonce: 'abcdef' } + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { resetPassword: false }, + }) }) }) describe('valid email', () => { - describe('but invalid code', () => { + beforeEach(() => { + variables = { ...variables, email: 'user@example.org' } + }) + + describe('but invalid nonce', () => { + beforeEach(() => { + variables = { ...variables, nonce: 'slkdjf' } + }) + it('resolves to false', async () => { await setup() - variables = { newPassword, email, code: 'slkdjf' } - await expect(client.request(mutation, variables)).resolves.toEqual({ - resetPassword: false, + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { resetPassword: false }, }) }) }) - describe('and valid code', () => { + describe('and valid nonce', () => { beforeEach(() => { variables = { - newPassword, - email: 'user@example.org', - code: 'abcdef', + ...variables, + nonce: 'abcdef', } }) - describe('and code not expired', () => { + describe('and nonce not expired', () => { beforeEach(async () => { await setup() }) it('resolves to true', async () => { - await expect(client.request(mutation, variables)).resolves.toEqual({ - resetPassword: true, + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { resetPassword: true }, }) }) it('updates PasswordReset `usedAt` property', async () => { - await client.request(mutation, variables) + await mutate({ mutation, variables }) const requests = await getAllPasswordResets() const [request] = requests const { usedAt } = request.properties @@ -137,23 +189,20 @@ describe('passwordReset', () => { }) it('updates password of the user', async () => { - await client.request(mutation, variables) - const checkLoginMutation = ` - mutation($email: String!, $password: String!) { - login(email: $email, password: $password) - } + await mutate({ mutation, variables }) + const checkLoginMutation = gql` + mutation($email: String!, $password: String!) { + login(email: $email, password: $password) + } ` - const expected = expect.objectContaining({ login: expect.any(String) }) + variables = { ...variables, email: 'user@example.org', password: 'supersecret' } await expect( - client.request(checkLoginMutation, { - email: 'user@example.org', - password: 'supersecret', - }), - ).resolves.toEqual(expected) + mutate({ mutation: checkLoginMutation, variables }), + ).resolves.toMatchObject({ data: { login: expect.any(String) } }) }) }) - describe('but expired code', () => { + describe('but expired nonce', () => { beforeEach(async () => { const issuedAt = new Date() issuedAt.setDate(issuedAt.getDate() - 1) @@ -161,13 +210,13 @@ describe('passwordReset', () => { }) it('resolves to false', async () => { - await expect(client.request(mutation, variables)).resolves.toEqual({ - resetPassword: false, + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { resetPassword: false }, }) }) it('does not update PasswordReset `usedAt` property', async () => { - await client.request(mutation, variables) + await mutate({ mutation, variables }) const requests = await getAllPasswordResets() const [request] = requests const { usedAt } = request.properties diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js index 92c6c3a3e..f96767006 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -18,7 +18,7 @@ const checkEmailDoesNotExist = async ({ email }) => { export default { Mutation: { - CreateInvitationCode: async (parent, args, context, resolveInfo) => { + CreateInvitationCode: async (_parent, args, context, _resolveInfo) => { args.token = uuid().substring(0, 6) const { user: { id: userId }, @@ -37,18 +37,18 @@ export default { } return response }, - Signup: async (parent, args, context, resolveInfo) => { + Signup: async (_parent, args, _context, _resolveInfo) => { const nonce = uuid().substring(0, 6) args.nonce = nonce await checkEmailDoesNotExist({ email: args.email }) try { const emailAddress = await instance.create('EmailAddress', args) - return { response: emailAddress.toJson(), nonce } + return emailAddress.toJson() } catch (e) { throw new UserInputError(e.message) } }, - SignupByInvitation: async (parent, args, context, resolveInfo) => { + SignupByInvitation: async (_parent, args, _context, _resolveInfo) => { const { token } = args const nonce = uuid().substring(0, 6) args.nonce = nonce @@ -71,12 +71,12 @@ export default { throw new UserInputError('Invitation code already used or does not exist.') const emailAddress = await instance.create('EmailAddress', args) await validInvitationCode.relateTo(emailAddress, 'activated') - return { response: emailAddress.toJson(), nonce } + return emailAddress.toJson() } catch (e) { throw new UserInputError(e) } }, - SignupVerification: async (object, args, context, resolveInfo) => { + SignupVerification: async (_parent, args, _context, _resolveInfo) => { const { termsAndConditionsAgreedVersion } = args const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) if (!regEx.test(termsAndConditionsAgreedVersion)) { diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index eb78cabfe..c641763f0 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -23,7 +23,7 @@ type Mutation { login(email: String!, password: String!): String! changePassword(oldPassword: String!, newPassword: String!): String! requestPasswordReset(email: String!): Boolean! - resetPassword(email: String!, code: String!, newPassword: String!): Boolean! + resetPassword(email: String!, nonce: String!, newPassword: String!): Boolean! report(id: ID!, description: String): Report disable(id: ID!): ID enable(id: ID!): ID diff --git a/webapp/components/PasswordReset/ChangePassword.spec.js b/webapp/components/PasswordReset/ChangePassword.spec.js index a6722f016..e93d5d00d 100644 --- a/webapp/components/PasswordReset/ChangePassword.spec.js +++ b/webapp/components/PasswordReset/ChangePassword.spec.js @@ -39,10 +39,10 @@ describe('ChangePassword ', () => { }) } - describe('given email and verification code', () => { + describe('given email and verification nonce', () => { beforeEach(() => { propsData.email = 'mail@example.org' - propsData.code = '123456' + propsData.nonce = '123456' }) describe('submitting new password', () => { @@ -59,14 +59,14 @@ describe('ChangePassword ', () => { it('delivers new password to backend', () => { const expected = expect.objectContaining({ - variables: { code: '123456', email: 'mail@example.org', password: 'supersecret' }, + variables: { nonce: '123456', email: 'mail@example.org', password: 'supersecret' }, }) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) }) describe('password reset successful', () => { it('displays success message', () => { - const expected = 'verify-code.form.change-password.success' + const expected = 'verify-nonce.form.change-password.success' expect(mocks.$t).toHaveBeenCalledWith(expected) }) diff --git a/webapp/components/PasswordReset/ChangePassword.vue b/webapp/components/PasswordReset/ChangePassword.vue index f59edffa1..3de4f048a 100644 --- a/webapp/components/PasswordReset/ChangePassword.vue +++ b/webapp/components/PasswordReset/ChangePassword.vue @@ -1,5 +1,5 @@ @@ -64,7 +64,7 @@ export default { }, props: { email: { type: String, required: true }, - code: { type: String, required: true }, + nonce: { type: String, required: true }, }, data() { const passwordForm = PasswordForm({ translate: this.$t }) @@ -82,13 +82,13 @@ export default { methods: { async handleSubmitPassword() { const mutation = gql` - mutation($code: String!, $email: String!, $password: String!) { - resetPassword(code: $code, email: $email, newPassword: $password) + mutation($nonce: String!, $email: String!, $password: String!) { + resetPassword(nonce: $nonce, email: $email, newPassword: $password) } ` const { password } = this.formData - const { email, code } = this - const variables = { password, email, code } + const { email, nonce } = this + const variables = { password, email, nonce } try { const { data: { resetPassword }, diff --git a/webapp/components/PasswordReset/VerifyCode.spec.js b/webapp/components/PasswordReset/VerifyNonce.spec.js similarity index 62% rename from webapp/components/PasswordReset/VerifyCode.spec.js rename to webapp/components/PasswordReset/VerifyNonce.spec.js index 22cdfd885..ebe552f0d 100644 --- a/webapp/components/PasswordReset/VerifyCode.spec.js +++ b/webapp/components/PasswordReset/VerifyNonce.spec.js @@ -1,12 +1,12 @@ import { mount, createLocalVue } from '@vue/test-utils' -import VerifyCode from './VerifyCode' +import VerifyNonce from './VerifyNonce.vue' import Styleguide from '@human-connection/styleguide' const localVue = createLocalVue() localVue.use(Styleguide) -describe('VerifyCode ', () => { +describe('VerifyNonce ', () => { let wrapper let Wrapper let mocks @@ -25,27 +25,27 @@ describe('VerifyCode ', () => { beforeEach(jest.useFakeTimers) Wrapper = () => { - return mount(VerifyCode, { + return mount(VerifyNonce, { mocks, localVue, propsData, }) } - it('renders a verify code form', () => { + it('renders a verify nonce form', () => { wrapper = Wrapper() - expect(wrapper.find('.verify-code').exists()).toBe(true) + expect(wrapper.find('.verify-nonce').exists()).toBe(true) }) - describe('after verification code given', () => { + describe('after verification nonce given', () => { beforeEach(() => { wrapper = Wrapper() - wrapper.find('input#code').setValue('123456') + wrapper.find('input#nonce').setValue('123456') wrapper.find('form').trigger('submit') }) - it('emits `verifyCode`', () => { - const expected = [[{ code: '123456', email: 'mail@example.org' }]] + it('emits `verification`', () => { + const expected = [[{ nonce: '123456', email: 'mail@example.org' }]] expect(wrapper.emitted('verification')).toEqual(expected) }) }) diff --git a/webapp/components/PasswordReset/VerifyCode.vue b/webapp/components/PasswordReset/VerifyNonce.vue similarity index 70% rename from webapp/components/PasswordReset/VerifyCode.vue rename to webapp/components/PasswordReset/VerifyNonce.vue index de1495e36..94ae13564 100644 --- a/webapp/components/PasswordReset/VerifyCode.vue +++ b/webapp/components/PasswordReset/VerifyNonce.vue @@ -1,5 +1,5 @@