diff --git a/backend/src/middleware/email/emailMiddleware.js b/backend/src/middleware/email/emailMiddleware.js index 0b7cfd058..7bfd174dc 100644 --- a/backend/src/middleware/email/emailMiddleware.js +++ b/backend/src/middleware/email/emailMiddleware.js @@ -3,6 +3,19 @@ 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 template => { + await transporter().sendMail(template) + } +} 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 +30,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..9f6d3eff2 100644 --- a/backend/src/middleware/email/templates/passwordReset.js +++ b/backend/src/middleware/email/templates/passwordReset.js @@ -6,12 +6,12 @@ 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 +37,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..922be425d 100644 --- a/backend/src/middleware/email/templates/signup.js +++ b/backend/src/middleware/email/templates/signup.js @@ -22,7 +22,7 @@ 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..134c4e08e 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 } + const nonce = uuid().substring(0, 6) + return createPasswordReset({ driver, nonce, email }) }, - resetPassword: async (_, { email, code, newPassword }, { driver }) => { + resetPassword: async (_, { 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..0d82fe039 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,168 @@ 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) } + ` + const nonce = 'abcdef' + 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 } + 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 +190,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 +211,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 423ce7580..6101309a7 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -43,7 +43,7 @@ export default { 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) } @@ -71,7 +71,7 @@ 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) } 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