Refactored email resolvers

* If the email middleware does not send mails, it will give a warning
similar to sentry middleware
* Eliminated GrapphQLClient in one more test
* Rename code => nonce
This commit is contained in:
roschaefer 2019-09-06 18:41:10 +02:00
parent 80d1e03c03
commit e751571981
8 changed files with 201 additions and 149 deletions

View File

@ -3,6 +3,19 @@ import nodemailer from 'nodemailer'
import { resetPasswordMail, wrongAccountMail } from './templates/passwordReset' import { resetPasswordMail, wrongAccountMail } from './templates/passwordReset'
import { signupTemplate } from './templates/signup' 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 transporter = () => {
const configs = { const configs = {
host: CONFIG.SMTP_HOST, host: CONFIG.SMTP_HOST,
@ -17,41 +30,24 @@ const transporter = () => {
return nodemailer.createTransport(configs) 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 sendSignupMail = async (resolve, root, args, context, resolveInfo) => {
const { email } = args const response = await resolve(root, args, context, resolveInfo)
const { response, nonce } = await resolve(root, args, context, resolveInfo) const { email, nonce } = response
await sendMail(signupTemplate({ email, nonce }))
delete response.nonce delete response.nonce
await transporter().sendMail(signupTemplate({ email, nonce }))
return response return response
} }
export default function({ isEnabled }) { export default {
if (!isEnabled) Mutation: {
return { requestPasswordReset: async (resolve, root, args, context, resolveInfo) => {
Mutation: { const { email } = args
requestPasswordReset: returnResponse, const { email: emailFound, nonce, name } = await resolve(root, args, context, resolveInfo)
Signup: returnResponse, const mailTemplate = emailFound ? resetPasswordMail : wrongAccountMail
SignupByInvitation: returnResponse, await sendMail(mailTemplate({ email, nonce, name }))
}, return true
}
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,
}, },
} Signup: sendSignupMail,
SignupByInvitation: sendSignupMail,
},
} }

View File

@ -6,12 +6,12 @@ export const resetPasswordMail = options => {
const { const {
name, name,
email, email,
code, nonce,
subject = 'Use this link to reset your password. The link is only valid for 24 hours.', subject = 'Use this link to reset your password. The link is only valid for 24 hours.',
supportUrl = 'https://human-connection.org/en/contact/', supportUrl = 'https://human-connection.org/en/contact/',
} = options } = options
const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI) 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) actionUrl.searchParams.set('email', email)
return { return {
@ -37,7 +37,7 @@ The Human Connection Team
If you're having trouble with the link above, you can manually copy and If you're having trouble with the link above, you can manually copy and
paste the following code into your browser window: paste the following code into your browser window:
${code} ${nonce}
Human Connection gemeinnützige GmbH Human Connection gemeinnützige GmbH
Bahnhofstr. 11 Bahnhofstr. 11

View File

@ -22,7 +22,7 @@ and create a user account:
${actionUrl} ${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} ${nonce}

View File

@ -33,9 +33,7 @@ export default schema => {
user, user,
includedFields, includedFields,
orderBy, orderBy,
email: email({ email,
isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT,
}),
} }
let order = [ let order = [

View File

@ -2,39 +2,47 @@ import uuid from 'uuid/v4'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
export async function createPasswordReset(options) { 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 session = driver.session()
const cypher = ` let response = {}
try {
const cypher = `
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email}) 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) MERGE (u)-[:REQUESTED]->(pr)
RETURN u RETURN e, pr, u
` `
const transactionRes = await session.run(cypher, { const transactionRes = await session.run(cypher, {
issuedAt: issuedAt.toISOString(), issuedAt: issuedAt.toISOString(),
code, nonce,
email, email,
}) })
const users = transactionRes.records.map(record => record.get('u')) const records = transactionRes.records.map(record => {
session.close() const { email } = record.get('e').properties
return users 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 { export default {
Mutation: { Mutation: {
requestPasswordReset: async (_, { email }, { driver }) => { requestPasswordReset: async (_, { email }, { driver }) => {
const code = uuid().substring(0, 6) const nonce = uuid().substring(0, 6)
const [user] = await createPasswordReset({ driver, code, email }) return createPasswordReset({ driver, nonce, email })
const name = (user && user.name) || ''
return { user, code, name, response: true }
}, },
resetPassword: async (_, { email, code, newPassword }, { driver }) => { resetPassword: async (_, { email, nonce, newPassword }, { driver }) => {
const session = driver.session() const session = driver.session()
const stillValid = new Date() const stillValid = new Date()
stillValid.setDate(stillValid.getDate() - 1) stillValid.setDate(stillValid.getDate() - 1)
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
const cypher = ` const cypher = `
MATCH (pr:PasswordReset {code: $code}) MATCH (pr:PasswordReset {nonce: $nonce})
MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr) MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
SET pr.usedAt = datetime() SET pr.usedAt = datetime()
@ -44,13 +52,13 @@ export default {
const transactionRes = await session.run(cypher, { const transactionRes = await session.run(cypher, {
stillValid, stillValid,
email, email,
code, nonce,
encryptedNewPassword, encryptedNewPassword,
}) })
const [reset] = transactionRes.records.map(record => record.get('pr')) const [reset] = transactionRes.records.map(record => record.get('pr'))
const result = !!(reset && reset.properties.usedAt) const response = !!(reset && reset.properties.usedAt)
session.close() session.close()
return result return response
}, },
}, },
} }

View File

@ -1,12 +1,17 @@
import { GraphQLClient } from 'graphql-request'
import Factory from '../../seed/factories' import Factory from '../../seed/factories'
import { host } from '../../jest/helpers' import { gql } from '../../jest/helpers'
import { getDriver } from '../../bootstrap/neo4j' import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import { createPasswordReset } from './passwordReset' import { createPasswordReset } from './passwordReset'
import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing'
const factory = Factory() const neode = getNeode()
let client
const driver = getDriver() const driver = getDriver()
const factory = Factory()
let mutate
let authenticatedUser
let variables
const getAllPasswordResets = async () => { const getAllPasswordResets = async () => {
const session = driver.session() const session = driver.session()
@ -16,120 +21,168 @@ const getAllPasswordResets = async () => {
return resets 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', () => { describe('passwordReset', () => {
beforeEach(async () => { describe('given a user', () => {
client = new GraphQLClient(host) beforeEach(async () => {
await factory.create('User', { await factory.create('User', {
email: 'user@example.org', email: 'user@example.org',
role: 'user', })
password: '1234',
}) })
})
afterEach(async () => { describe('requestPasswordReset', () => {
await factory.cleanDatabase() const mutation = gql`
}) mutation($email: String!) {
requestPasswordReset(email: $email)
}
`
describe('requestPasswordReset', () => { describe('with invalid email', () => {
const mutation = `mutation($email: String!) { requestPasswordReset(email: $email) }` beforeEach(() => {
variables = { ...variables, email: 'non-existent@example.org' }
})
describe('with invalid email', () => { it('resolves anyways', async () => {
const variables = { email: 'non-existent@example.org' } await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { requestPasswordReset: true },
})
})
it('resolves anyways', async () => { it('creates no node', async () => {
await expect(client.request(mutation, variables)).resolves.toEqual({ await mutate({ mutation, variables })
requestPasswordReset: true, const resets = await getAllPasswordResets()
expect(resets).toHaveLength(0)
}) })
}) })
it('creates no node', async () => { describe('with a valid email', () => {
await client.request(mutation, variables) beforeEach(() => {
const resets = await getAllPasswordResets() variables = { ...variables, email: 'user@example.org' }
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,
}) })
})
it('creates node with label `PasswordReset`', async () => { it('resolves', async () => {
await client.request(mutation, variables) await expect(mutate({ mutation, variables })).resolves.toMatchObject({
const resets = await getAllPasswordResets() data: { requestPasswordReset: true },
expect(resets).toHaveLength(1) })
}) })
it('creates a reset code', async () => { it('creates node with label `PasswordReset`', async () => {
await client.request(mutation, variables) let resets = await getAllPasswordResets()
const resets = await getAllPasswordResets() expect(resets).toHaveLength(0)
const [reset] = resets await mutate({ mutation, variables })
const { code } = reset.properties resets = await getAllPasswordResets()
expect(code).toHaveLength(6) 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', () => { describe('resetPassword', () => {
const setup = async (options = {}) => { const setup = async (options = {}) => {
const { email = 'user@example.org', issuedAt = new Date(), code = 'abcdef' } = options const { email = 'user@example.org', issuedAt = new Date(), nonce = 'abcdef' } = options
const session = driver.session() const session = driver.session()
await createPasswordReset({ driver, email, issuedAt, code }) await createPasswordReset({ driver, email, issuedAt, nonce })
session.close() 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) }` describe('given a user', () => {
const email = 'user@example.org' beforeEach(async () => {
const code = 'abcdef' await factory.create('User', {
const newPassword = 'supersecret' email: 'user@example.org',
let variables role: 'user',
password: '1234',
})
})
describe('invalid email', () => { describe('invalid email', () => {
it('resolves to false', async () => { it('resolves to false', async () => {
await setup() await setup()
variables = { newPassword, email: 'non-existent@example.org', code } variables = { ...variables, email: 'non-existent@example.org', nonce }
await expect(client.request(mutation, variables)).resolves.toEqual({ resetPassword: false }) await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { resetPassword: false },
})
}) })
}) })
describe('valid email', () => { 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 () => { it('resolves to false', async () => {
await setup() await setup()
variables = { newPassword, email, code: 'slkdjf' } await expect(mutate({ mutation, variables })).resolves.toMatchObject({
await expect(client.request(mutation, variables)).resolves.toEqual({ data: { resetPassword: false },
resetPassword: false,
}) })
}) })
}) })
describe('and valid code', () => { describe('and valid nonce', () => {
beforeEach(() => { beforeEach(() => {
variables = { variables = {
newPassword, ...variables,
email: 'user@example.org', nonce: 'abcdef',
code: 'abcdef',
} }
}) })
describe('and code not expired', () => { describe('and nonce not expired', () => {
beforeEach(async () => { beforeEach(async () => {
await setup() await setup()
}) })
it('resolves to true', async () => { it('resolves to true', async () => {
await expect(client.request(mutation, variables)).resolves.toEqual({ await expect(mutate({ mutation, variables })).resolves.toMatchObject({
resetPassword: true, data: { resetPassword: true },
}) })
}) })
it('updates PasswordReset `usedAt` property', async () => { it('updates PasswordReset `usedAt` property', async () => {
await client.request(mutation, variables) await mutate({ mutation, variables })
const requests = await getAllPasswordResets() const requests = await getAllPasswordResets()
const [request] = requests const [request] = requests
const { usedAt } = request.properties const { usedAt } = request.properties
@ -137,23 +190,20 @@ describe('passwordReset', () => {
}) })
it('updates password of the user', async () => { it('updates password of the user', async () => {
await client.request(mutation, variables) await mutate({ mutation, variables })
const checkLoginMutation = ` const checkLoginMutation = gql`
mutation($email: String!, $password: String!) { mutation($email: String!, $password: String!) {
login(email: $email, password: $password) login(email: $email, password: $password)
} }
` `
const expected = expect.objectContaining({ login: expect.any(String) }) variables = { ...variables, email: 'user@example.org', password: 'supersecret' }
await expect( await expect(
client.request(checkLoginMutation, { mutate({ mutation: checkLoginMutation, variables }),
email: 'user@example.org', ).resolves.toMatchObject({ data: { login: expect.any(String) } })
password: 'supersecret',
}),
).resolves.toEqual(expected)
}) })
}) })
describe('but expired code', () => { describe('but expired nonce', () => {
beforeEach(async () => { beforeEach(async () => {
const issuedAt = new Date() const issuedAt = new Date()
issuedAt.setDate(issuedAt.getDate() - 1) issuedAt.setDate(issuedAt.getDate() - 1)
@ -161,13 +211,13 @@ describe('passwordReset', () => {
}) })
it('resolves to false', async () => { it('resolves to false', async () => {
await expect(client.request(mutation, variables)).resolves.toEqual({ await expect(mutate({ mutation, variables })).resolves.toMatchObject({
resetPassword: false, data: { resetPassword: false },
}) })
}) })
it('does not update PasswordReset `usedAt` property', async () => { it('does not update PasswordReset `usedAt` property', async () => {
await client.request(mutation, variables) await mutate({ mutation, variables })
const requests = await getAllPasswordResets() const requests = await getAllPasswordResets()
const [request] = requests const [request] = requests
const { usedAt } = request.properties const { usedAt } = request.properties

View File

@ -43,7 +43,7 @@ export default {
await checkEmailDoesNotExist({ email: args.email }) await checkEmailDoesNotExist({ email: args.email })
try { try {
const emailAddress = await instance.create('EmailAddress', args) const emailAddress = await instance.create('EmailAddress', args)
return { response: emailAddress.toJson(), nonce } return emailAddress.toJson()
} catch (e) { } catch (e) {
throw new UserInputError(e.message) throw new UserInputError(e.message)
} }
@ -71,7 +71,7 @@ export default {
throw new UserInputError('Invitation code already used or does not exist.') throw new UserInputError('Invitation code already used or does not exist.')
const emailAddress = await instance.create('EmailAddress', args) const emailAddress = await instance.create('EmailAddress', args)
await validInvitationCode.relateTo(emailAddress, 'activated') await validInvitationCode.relateTo(emailAddress, 'activated')
return { response: emailAddress.toJson(), nonce } return emailAddress.toJson()
} catch (e) { } catch (e) {
throw new UserInputError(e) throw new UserInputError(e)
} }

View File

@ -23,7 +23,7 @@ type Mutation {
login(email: String!, password: String!): String! login(email: String!, password: String!): String!
changePassword(oldPassword: String!, newPassword: String!): String! changePassword(oldPassword: String!, newPassword: String!): String!
requestPasswordReset(email: String!): Boolean! 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 report(id: ID!, description: String): Report
disable(id: ID!): ID disable(id: ID!): ID
enable(id: ID!): ID enable(id: ID!): ID