diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index ad2787579..dbcde849c 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -148,6 +148,7 @@ const permissions = shield( DeleteComment: isAuthor, DeleteUser: isDeletingOwnAccount, requestPasswordReset: allow, + resetPassword: allow, }, User: { email: isMyOwn, diff --git a/backend/src/schema/resolvers/passwordReset.js b/backend/src/schema/resolvers/passwordReset.js index f3e1d32d2..d6d2a6e6c 100644 --- a/backend/src/schema/resolvers/passwordReset.js +++ b/backend/src/schema/resolvers/passwordReset.js @@ -1,21 +1,46 @@ -export default { - Mutation: { - requestPasswordReset: async (_, { email }, { driver }) => { - const session = driver.session() - let validUntil = new Date() - validUntil += 3*60*1000 - const cypher = ` - MATCH(u:User) WHERE u.email = $email - CREATE(pr:PasswordReset {id: apoc.create.uuid(), validUntil: $validUntil, redeemedAt: NULL}) +import uuid from 'uuid/v4' +import bcrypt from 'bcryptjs' + +export async function createPasswordReset({ driver, token, email, validUntil }) { + const session = driver.session() + const cypher = ` + MATCH (u:User) WHERE u.email = $email + CREATE(pr:PasswordReset {token: $token, validUntil: $validUntil, redeemedAt: NULL}) MERGE (u)-[:REQUESTED]->(pr) RETURN u,pr ` - await session.run(cypher, { email, validUntil }) - session.close() + const transactionRes = await session.run(cypher, { token, email, validUntil }) + const resets = transactionRes.records.map(record => record.get('pr')) + session.close() + return resets +} + +export default { + Mutation: { + requestPasswordReset: async (_, { email }, { driver }) => { + let validUntil = new Date() + validUntil += 3 * 60 * 1000 + const token = uuid() + await createPasswordReset({ driver, token, email, validUntil }) return true }, resetPassword: async (_, { email, token, newPassword }, { driver }) => { - throw Error('Not Implemented') - } - } + const session = driver.session() + const now = new Date().getTime() + const newHashedPassword = await bcrypt.hashSync(newPassword, 10) + const cypher = ` + MATCH (r:PasswordReset {token: $token}) + MATCH (u:User {email: $email})-[:REQUESTED]->(r) + WHERE r.validUntil > $now AND r.redeemedAt IS NULL + SET r.redeemedAt = $now + SET u.password = $newHashedPassword + RETURN r + ` + let transactionRes = await session.run(cypher, { now, email, token, newHashedPassword }) + const [reset] = transactionRes.records.map(record => record.get('r')) + const result = !!(reset && reset.properties.redeemedAt) + session.close() + return result + }, + }, } diff --git a/backend/src/schema/resolvers/passwordReset.spec.js b/backend/src/schema/resolvers/passwordReset.spec.js index 4bd29c9c6..89b724fca 100644 --- a/backend/src/schema/resolvers/passwordReset.spec.js +++ b/backend/src/schema/resolvers/passwordReset.spec.js @@ -1,7 +1,8 @@ import { GraphQLClient } from 'graphql-request' import Factory from '../../seed/factories' -import { host, login } from '../../jest/helpers' +import { host } from '../../jest/helpers' import { getDriver } from '../../bootstrap/neo4j' +import { createPasswordReset } from './passwordReset' const factory = Factory() let client @@ -36,7 +37,9 @@ describe('passwordReset', () => { const variables = { email: 'non-existent@example.org' } it('resolves anyways', async () => { - await expect(client.request(mutation, variables)).resolves.toEqual({"requestPasswordReset": true}) + await expect(client.request(mutation, variables)).resolves.toEqual({ + requestPasswordReset: true, + }) }) it('creates no node', async () => { @@ -50,7 +53,9 @@ describe('passwordReset', () => { const variables = { email: 'user@example.org' } it('resolves', async () => { - await expect(client.request(mutation, variables)).resolves.toEqual({"requestPasswordReset": true}) + await expect(client.request(mutation, variables)).resolves.toEqual({ + requestPasswordReset: true, + }) }) it('creates node with label `PasswordReset`', async () => { @@ -59,21 +64,139 @@ describe('passwordReset', () => { expect(resets).toHaveLength(1) }) - it('creates an id used as a reset token', async () => { + it('creates a reset token', async () => { await client.request(mutation, variables) - const [reset] = await getAllPasswordResets() - const { id: token } = reset.properties + const resets = await getAllPasswordResets() + const [reset] = resets + const { token } = reset.properties expect(token).toMatch(/^........-....-....-....-............$/) }) it('created PasswordReset is valid for less than 4 minutes', async () => { await client.request(mutation, variables) - const [reset] = await getAllPasswordResets() + const resets = await getAllPasswordResets() + const [reset] = resets let { validUntil } = reset.properties validUntil = Date.parse(validUntil) - const now = (new Date()).getTime() - expect(validUntil).toBeGreaterThan(now - 60*1000) - expect(validUntil).toBeLessThan(now + 4*60*1000) + const now = new Date().getTime() + expect(validUntil).toBeGreaterThan(now - 60 * 1000) + expect(validUntil).toBeLessThan(now + 4 * 60 * 1000) + }) + }) + }) + + describe('resetPassword', () => { + const setup = async (options = {}) => { + const { + email = 'user@example.org', + validUntil = new Date().getTime() + 3 * 60 * 1000, + token = 'abcdefgh-ijkl-mnop-qrst-uvwxyz123456', + } = options + + const session = driver.session() + await createPasswordReset({ driver, email, validUntil, token }) + session.close() + } + + const mutation = `mutation($token: String!, $email: String!, $newPassword: String!) { resetPassword(token: $token, email: $email, newPassword: $newPassword) }` + let email = 'user@example.org' + let token = 'abcdefgh-ijkl-mnop-qrst-uvwxyz123456' + let newPassword = 'supersecret' + let variables + + describe('invalid email', () => { + it('resolves to false', async () => { + await setup() + variables = { newPassword, email: 'non-existent@example.org', token } + await expect(client.request(mutation, variables)).resolves.toEqual({ resetPassword: false }) + }) + }) + + describe('valid email', () => { + describe('but invalid token', () => { + it('resolves to false', async () => { + await setup() + variables = { newPassword, email, token: 'slkdjfldsjflsdjfsjdfl' } + await expect(client.request(mutation, variables)).resolves.toEqual({ + resetPassword: false, + }) + }) + }) + + describe('but invalid token', () => { + it('resolves to false', async () => { + variables = { newPassword, email: 'user@example.org', token: 'lksjdflksjdflksjdlkfjsf' } + await expect(client.request(mutation, variables)).resolves.toEqual({ + resetPassword: false, + }) + }) + }) + + describe('and valid token', () => { + beforeEach(() => { + variables = { + newPassword, + email: 'user@example.org', + token: 'abcdefgh-ijkl-mnop-qrst-uvwxyz123456', + } + }) + + describe('and token not expired', () => { + beforeEach(async () => { + await setup() + }) + + it('resolves to true', async () => { + await expect(client.request(mutation, variables)).resolves.toEqual({ + resetPassword: true, + }) + }) + + it('updates PasswordReset `redeemedAt` property', async () => { + await client.request(mutation, variables) + const requests = await getAllPasswordResets() + const [request] = requests + const { redeemedAt } = request.properties + expect(redeemedAt).not.toBeNull() + }) + + it('updates password of the user', async () => { + await client.request(mutation, variables) + const checkLoginMutation = ` + mutation($email: String!, $password: String!) { + login(email: $email, password: $password) + } + ` + const expected = expect.objectContaining({ login: expect.any(String) }) + await expect( + client.request(checkLoginMutation, { + email: 'user@example.org', + password: 'supersecret', + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but expired token', () => { + beforeEach(async () => { + const validUntil = new Date().getTime() - 1000 + await setup({ validUntil }) + }) + + it('resolves to false', async () => { + await expect(client.request(mutation, variables)).resolves.toEqual({ + resetPassword: false, + }) + }) + + it('does not update PasswordReset `redeemedAt` property', async () => { + await client.request(mutation, variables) + const requests = await getAllPasswordResets() + const [request] = requests + const { redeemedAt } = request.properties + expect(redeemedAt).toBeUndefined() + }) + }) }) }) }) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 358797631..ae77ef8e8 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -26,7 +26,7 @@ type Mutation { signup(email: String!, password: String!): Boolean! changePassword(oldPassword: String!, newPassword: String!): String! requestPasswordReset(email: String!): Boolean! - resetPassword(email: String!, resetToken: String!, newPassword: String!): String! + resetPassword(email: String!, token: String!, newPassword: String!): Boolean! report(id: ID!, description: String): Report disable(id: ID!): ID enable(id: ID!): ID