diff --git a/backend/package.json b/backend/package.json index 68a19d652..40f33b234 100644 --- a/backend/package.json +++ b/backend/package.json @@ -101,6 +101,7 @@ "slug": "~1.1.0", "trunc-html": "~1.1.2", "uuid": "~3.3.3", + "validator": "^12.0.0", "wait-on": "~3.3.0", "xregexp": "^4.2.4" }, diff --git a/backend/src/schema/resolvers/emails.js b/backend/src/schema/resolvers/emails.js index ce93a28e9..06c0dbd1a 100644 --- a/backend/src/schema/resolvers/emails.js +++ b/backend/src/schema/resolvers/emails.js @@ -3,11 +3,14 @@ import Resolver from './helpers/Resolver' import existingEmailAddress from './helpers/existingEmailAddress' import { UserInputError } from 'apollo-server' import Validator from 'neode/build/Services/Validator.js' +import { normalizeEmail } from 'validator' export default { Mutation: { AddEmailAddress: async (_parent, args, context, _resolveInfo) => { let response + args.email = normalizeEmail(args.email) + try { const { neode } = context await new Validator(neode, neode.model('UnverifiedEmailAddress'), args) @@ -16,13 +19,13 @@ export default { } // check email does not belong to anybody - await existingEmailAddress(_parent, args, context) + await existingEmailAddress({ args, context }) const nonce = generateNonce() const { user: { id: userId }, } = context - const { email } = args + const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async txc => { const result = await txc.run( @@ -32,7 +35,7 @@ export default { SET email.createdAt = toString(datetime()) RETURN email, user `, - { userId, email, nonce }, + { userId, email: args.email, nonce }, ) return result.records.map(record => ({ name: record.get('user').properties.name, diff --git a/backend/src/schema/resolvers/helpers/createPasswordReset.js b/backend/src/schema/resolvers/helpers/createPasswordReset.js new file mode 100644 index 000000000..8d575abfc --- /dev/null +++ b/backend/src/schema/resolvers/helpers/createPasswordReset.js @@ -0,0 +1,31 @@ +import { normalizeEmail } from 'validator' + +export default async function createPasswordReset(options) { + const { driver, nonce, email, issuedAt = new Date() } = options + const normalizedEmail = normalizeEmail(email) + const session = driver.session() + let response = {} + try { + const cypher = ` + MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email}) + CREATE(pr:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL}) + MERGE (u)-[:REQUESTED]->(pr) + RETURN e, pr, u + ` + const transactionRes = await session.run(cypher, { + issuedAt: issuedAt.toISOString(), + nonce, + email: normalizedEmail, + }) + 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 +} diff --git a/backend/src/schema/resolvers/helpers/createPasswordReset.spec.js b/backend/src/schema/resolvers/helpers/createPasswordReset.spec.js new file mode 100644 index 000000000..a566e225a --- /dev/null +++ b/backend/src/schema/resolvers/helpers/createPasswordReset.spec.js @@ -0,0 +1,35 @@ +import createPasswordReset from './createPasswordReset' + +describe('createPasswordReset', () => { + const issuedAt = new Date() + const nonce = 'abcdef' + + describe('email lookup', () => { + let driver + let mockSession + beforeEach(() => { + mockSession = { + close() {}, + run: jest.fn().mockReturnValue({ + records: { + map: jest.fn(() => []), + }, + }), + } + driver = { session: () => mockSession } + }) + + it('lowercases email address', async () => { + const email = 'stRaNGeCaSiNG@ExAmplE.ORG' + await createPasswordReset({ driver, email, issuedAt, nonce }) + expect(mockSession.run.mock.calls).toEqual([ + [ + expect.any(String), + expect.objectContaining({ + email: 'strangecasing@example.org', + }), + ], + ]) + }) + }) +}) diff --git a/backend/src/schema/resolvers/helpers/existingEmailAddress.js b/backend/src/schema/resolvers/helpers/existingEmailAddress.js index 007d2de6b..ee1a6af82 100644 --- a/backend/src/schema/resolvers/helpers/existingEmailAddress.js +++ b/backend/src/schema/resolvers/helpers/existingEmailAddress.js @@ -1,7 +1,6 @@ import { UserInputError } from 'apollo-server' -export default async function alreadyExistingMail(_parent, args, context) { - let { email } = args - email = email.toLowerCase() + +export default async function alreadyExistingMail({ args, context }) { const cypher = ` MATCH (email:EmailAddress {email: $email}) OPTIONAL MATCH (email)-[:BELONGS_TO]-(user) @@ -10,7 +9,7 @@ export default async function alreadyExistingMail(_parent, args, context) { let transactionRes const session = context.driver.session() try { - transactionRes = await session.run(cypher, { email }) + transactionRes = await session.run(cypher, { email: args.email }) } finally { session.close() } diff --git a/backend/src/schema/resolvers/passwordReset.js b/backend/src/schema/resolvers/passwordReset.js index 3c5f4636c..7c0d9e747 100644 --- a/backend/src/schema/resolvers/passwordReset.js +++ b/backend/src/schema/resolvers/passwordReset.js @@ -1,34 +1,6 @@ import uuid from 'uuid/v4' import bcrypt from 'bcryptjs' - -export async function createPasswordReset(options) { - const { driver, nonce, email, issuedAt = new Date() } = options - const session = driver.session() - let response = {} - try { - const cypher = ` - MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email}) - CREATE(pr:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL}) - MERGE (u)-[:REQUESTED]->(pr) - RETURN e, pr, u - ` - 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 -} +import createPasswordReset from './helpers/createPasswordReset' export default { Mutation: { diff --git a/backend/src/schema/resolvers/passwordReset.spec.js b/backend/src/schema/resolvers/passwordReset.spec.js index fabee1c7e..b8633e9c3 100644 --- a/backend/src/schema/resolvers/passwordReset.spec.js +++ b/backend/src/schema/resolvers/passwordReset.spec.js @@ -1,7 +1,7 @@ import Factory from '../../seed/factories' import { gql } from '../../jest/helpers' import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' -import { createPasswordReset } from './passwordReset' +import createPasswordReset from './helpers/createPasswordReset' import createServer from '../../server' import { createTestClient } from 'apollo-server-testing' @@ -109,10 +109,7 @@ describe('passwordReset', () => { 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, nonce }) - session.close() } const mutation = gql` diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js index bd62b32c3..d425357c3 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -4,6 +4,7 @@ import fileUpload from './fileUpload' import encryptPassword from '../../helpers/encryptPassword' import generateNonce from './helpers/generateNonce' import existingEmailAddress from './helpers/existingEmailAddress' +import { normalizeEmail } from 'validator' const instance = neode() @@ -29,9 +30,9 @@ export default { return response }, Signup: async (_parent, args, context) => { - const nonce = generateNonce() - args.nonce = nonce - let emailAddress = await existingEmailAddress(_parent, args, context) + args.nonce = generateNonce() + args.email = normalizeEmail(args.email) + let emailAddress = await existingEmailAddress({ args, context }) if (emailAddress) return emailAddress try { emailAddress = await instance.create('EmailAddress', args) @@ -42,9 +43,9 @@ export default { }, SignupByInvitation: async (_parent, args, context) => { const { token } = args - const nonce = generateNonce() - args.nonce = nonce - let emailAddress = await existingEmailAddress(_parent, args, context) + args.nonce = generateNonce() + args.email = normalizeEmail(args.email) + let emailAddress = await existingEmailAddress({ args, context }) if (emailAddress) return emailAddress try { const result = await instance.cypher( @@ -78,7 +79,7 @@ export default { args.termsAndConditionsAgreedAt = new Date().toISOString() let { nonce, email } = args - email = email.toLowerCase() + email = normalizeEmail(email) const result = await instance.cypher( ` MATCH(email:EmailAddress {nonce: {nonce}, email: {email}}) diff --git a/backend/yarn.lock b/backend/yarn.lock index 4438da749..adce77e97 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -8464,6 +8464,11 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +validator@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-12.0.0.tgz#fb33221f5320abe2422cda2f517dc3838064e813" + integrity sha512-r5zA1cQBEOgYlesRmSEwc9LkbfNLTtji+vWyaHzRZUxCTHdsX3bd+sdHfs5tGZ2W6ILGGsxWxCNwT/h3IY/3ng== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" diff --git a/webapp/components/PasswordReset/Request.spec.js b/webapp/components/PasswordReset/Request.spec.js index 594d6628d..4a6dbde9f 100644 --- a/webapp/components/PasswordReset/Request.spec.js +++ b/webapp/components/PasswordReset/Request.spec.js @@ -84,5 +84,18 @@ describe('Request', () => { }) }) }) + + describe('capital letters in a gmail address', () => { + beforeEach(async () => { + wrapper = Wrapper() + wrapper.find('input#email').setValue('mAiL@gmail.com') + await wrapper.find('form').trigger('submit') + }) + + it('normalizes email to lower case letters', () => { + const expected = expect.objectContaining({ variables: { email: 'mail@gmail.com' } }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + }) }) }) diff --git a/webapp/components/PasswordReset/Request.vue b/webapp/components/PasswordReset/Request.vue index bcececcca..1cf575574 100644 --- a/webapp/components/PasswordReset/Request.vue +++ b/webapp/components/PasswordReset/Request.vue @@ -46,6 +46,7 @@