diff --git a/backend/src/models/EmailAddress.js b/backend/src/models/EmailAddress.js index ddd56c297..6afccd1ed 100644 --- a/backend/src/models/EmailAddress.js +++ b/backend/src/models/EmailAddress.js @@ -8,5 +8,6 @@ module.exports = { relationship: 'BELONGS_TO', target: 'User', direction: 'out', + eager: true, }, } diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 02ce04513..cac8fd7a6 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -4,7 +4,6 @@ module.exports = { id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests actorId: { type: 'string', allow: [null] }, name: { type: 'string', min: 3 }, - email: { type: 'string', lowercase: true, email: true }, slug: 'string', encryptedPassword: 'string', avatar: { type: 'string', allow: [null] }, diff --git a/backend/src/schema/resolvers/passwordReset.js b/backend/src/schema/resolvers/passwordReset.js index 415eb6f21..88d82846a 100644 --- a/backend/src/schema/resolvers/passwordReset.js +++ b/backend/src/schema/resolvers/passwordReset.js @@ -5,7 +5,7 @@ export async function createPasswordReset(options) { const { driver, code, email, issuedAt = new Date() } = options const session = driver.session() const cypher = ` - MATCH (u:User) WHERE u.email = $email + MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email}) CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL}) MERGE (u)-[:REQUESTED]->(pr) RETURN u @@ -35,7 +35,7 @@ export default { const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) const cypher = ` MATCH (pr:PasswordReset {code: $code}) - MATCH (u:User {email: $email})-[: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 SET pr.usedAt = datetime() SET u.encryptedPassword = $encryptedNewPassword diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js index 3c8243d8a..20c54a49b 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -12,8 +12,8 @@ const instance = neode() */ const checkEmailDoesNotExist = async ({ email }) => { email = email.toLowerCase() - const users = await instance.all('User', { email }) - if (users.length > 0) throw new UserInputError('User account with this email already exists.') + const emails = await instance.all('EmailAddress', { email }) + if (emails.length > 0) throw new UserInputError('User account with this email already exists.') } export default { diff --git a/backend/src/schema/resolvers/registration.spec.js b/backend/src/schema/resolvers/registration.spec.js index 2cbce9a36..dc2e96348 100644 --- a/backend/src/schema/resolvers/registration.spec.js +++ b/backend/src/schema/resolvers/registration.spec.js @@ -166,11 +166,12 @@ describe('SignupByInvitation', () => { await expect(action()).rejects.toThrow('"email" must be a valid email') }) - it('creates no EmailAddress node', async done => { + it('creates no additional EmailAddress node', async done => { try { await action() } catch (e) { - const emailAddresses = await instance.all('EmailAddress') + let emailAddresses = await instance.all('EmailAddress') + emailAddresses = await emailAddresses.toJson expect(emailAddresses).toHaveLength(0) done() } @@ -191,16 +192,16 @@ describe('SignupByInvitation', () => { describe('creates a EmailAddress node', () => { it('with a `createdAt` attribute', async () => { await action() - const emailAddresses = await instance.all('EmailAddress') - const emailAddress = await emailAddresses.first().toJson() + let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' }) + emailAddress = await emailAddress.toJson() expect(emailAddress.createdAt).toBeTruthy() expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number)) }) it('with a cryptographic `nonce`', async () => { await action() - const emailAddresses = await instance.all('EmailAddress') - const emailAddress = await emailAddresses.first().toJson() + let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' }) + emailAddress = await emailAddress.toJson() expect(emailAddress.nonce).toEqual(expect.any(String)) }) @@ -220,6 +221,7 @@ describe('SignupByInvitation', () => { it('rejects because codes can be used only once', async done => { await action() try { + variables.email = 'yetanotheremail@example.org' await action() } catch (e) { expect(e.message).toMatch(/Invitation code already used/) @@ -282,8 +284,8 @@ describe('Signup', () => { it('creates a Signup with a cryptographic `nonce`', async () => { await action() - const emailAddresses = await instance.all('EmailAddress') - const emailAddress = await emailAddresses.first().toJson() + let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' }) + emailAddress = await emailAddress.toJson() expect(emailAddress.nonce).toEqual(expect.any(String)) }) }) diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index b62f9a609..7ed84586b 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -2,6 +2,9 @@ import encode from '../../jwt/encode' import bcrypt from 'bcryptjs' import { AuthenticationError } from 'apollo-server' import { neo4jgraphql } from 'neo4j-graphql-js' +import { neode } from '../../bootstrap/neo4j' + +const instance = neode() export default { Query: { @@ -21,8 +24,8 @@ export default { // } const session = driver.session() const result = await session.run( - 'MATCH (user:User {email: $userEmail}) ' + - 'RETURN user {.id, .slug, .name, .avatar, .email, .encryptedPassword, .role, .disabled} as user LIMIT 1', + 'MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})' + + 'RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1', { userEmail: email, }, @@ -46,41 +49,24 @@ export default { } }, changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => { - const session = driver.session() - let result = await session.run( - `MATCH (user:User {email: $userEmail}) - RETURN user {.id, .email, .encryptedPassword}`, - { - userEmail: user.email, - }, - ) + let currentUser = await instance.find('User', user.id) - const [currentUser] = result.records.map(function(record) { - return record.get('user') - }) - - if (!(await bcrypt.compareSync(oldPassword, currentUser.encryptedPassword))) { + const encryptedPassword = currentUser.get('encryptedPassword') + if (!(await bcrypt.compareSync(oldPassword, encryptedPassword))) { throw new AuthenticationError('Old password is not correct') } - if (await bcrypt.compareSync(newPassword, currentUser.encryptedPassword)) { + if (await bcrypt.compareSync(newPassword, encryptedPassword)) { throw new AuthenticationError('Old password and new password should be different') - } else { - const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10) - session.run( - `MATCH (user:User {email: $userEmail}) - SET user.encryptedPassword = $newEncryptedPassword - RETURN user - `, - { - userEmail: user.email, - newEncryptedPassword, - }, - ) - session.close() - - return encode(currentUser) } + + const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10) + await currentUser.update({ + encryptedPassword: newEncryptedPassword, + updatedAt: new Date().toISOString(), + }) + + return encode(await currentUser.toJson()) }, }, } diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index ea076d005..820688a1a 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -65,6 +65,13 @@ export const hasOne = obj => { export default { Query: { User: async (object, args, context, resolveInfo) => { + const { email } = args + if (email) { + const e = await instance.first('EmailAddress', { email }) + let user = e.get('belongsTo') + user = await user.toJson() + return [user.node] + } return neo4jgraphql(object, args, context, resolveInfo, false) }, }, @@ -104,6 +111,14 @@ export default { }, }, User: { + email: async (parent, params, context, resolveInfo) => { + if (typeof parent.email !== 'undefined') return parent.email + const { id } = parent + const statement = `MATCH(u:User {id: {id}})-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e` + const result = await instance.cypher(statement, { id }) + let [{ email }] = result.records.map(r => r.get('e').properties) + return email + }, ...undefinedToNull([ 'actorId', 'avatar', diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 30baa65a3..81bf3e782 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -2,7 +2,7 @@ type User { id: ID! actorId: String name: String - email: String! + email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email") slug: String! avatar: String coverImg: String diff --git a/backend/src/seed/factories/users.js b/backend/src/seed/factories/users.js index 8bdf03b9f..af1699253 100644 --- a/backend/src/seed/factories/users.js +++ b/backend/src/seed/factories/users.js @@ -21,7 +21,11 @@ export default function create() { ...args, } args = await encryptPassword(args) - return neodeInstance.create('User', args) + const user = await neodeInstance.create('User', args) + const email = await neodeInstance.create('EmailAddress', { email: args.email }) + await user.relateTo(email, 'primaryEmail') + await email.relateTo(user, 'belongsTo') + return user }, } } diff --git a/deployment/legacy-migration/maintenance-worker/migration/neo4j/users/users.cql b/deployment/legacy-migration/maintenance-worker/migration/neo4j/users/users.cql index 84eb7074b..7574fd3b2 100644 --- a/deployment/legacy-migration/maintenance-worker/migration/neo4j/users/users.cql +++ b/deployment/legacy-migration/maintenance-worker/migration/neo4j/users/users.cql @@ -111,6 +111,13 @@ u.createdAt = user.createdAt.`$date`, u.updatedAt = user.updatedAt.`$date`, u.deleted = user.deletedAt IS NOT NULL, u.disabled = false +MERGE (e:EmailAddress { + email: user.email, + createdAt: toString(datetime()), + verifiedAt: toString(datetime()) +}) +MERGE (e)-[:BELONGS_TO]->(u) +MERGE (u)<-[:PRIMARY_EMAIL]-(e) WITH u, user, user.badgeIds AS badgeIds UNWIND badgeIds AS badgeId MATCH (b:Badge {id: badgeId}) diff --git a/neo4j/db_setup.sh b/neo4j/db_setup.sh index 21ed54571..d4c7b9af8 100755 --- a/neo4j/db_setup.sh +++ b/neo4j/db_setup.sh @@ -34,6 +34,8 @@ CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE; CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE; CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE; CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE; + +CREATE CONSTRAINT ON (e:EmailAddress) ASSERT e.email IS UNIQUE; ' | cypher-shell echo '