diff --git a/backend/src/jwt/decode.js b/backend/src/jwt/decode.js index b98357103..d022f3512 100644 --- a/backend/src/jwt/decode.js +++ b/backend/src/jwt/decode.js @@ -13,7 +13,7 @@ export default async (driver, authorizationHeader) => { } const session = driver.session() const query = ` - MATCH (user:User {id: {id} }) + MATCH (user:User {id: $id, deleted: false, disabled: false }) RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId} LIMIT 1 ` @@ -23,7 +23,6 @@ export default async (driver, authorizationHeader) => { return record.get('user') }) if (!currentUser) return null - if (currentUser.disabled) return null return { token, ...currentUser, diff --git a/backend/src/jwt/decode.spec.js b/backend/src/jwt/decode.spec.js new file mode 100644 index 000000000..df6914f25 --- /dev/null +++ b/backend/src/jwt/decode.spec.js @@ -0,0 +1,104 @@ +import Factory from '../seed/factories/index' +import { getDriver } from '../bootstrap/neo4j' +import decode from './decode' + +const factory = Factory() +const driver = getDriver() + +// here is the decoded JWT token: +// { +// role: 'user', +// locationName: null, +// name: 'Jenny Rostock', +// about: null, +// avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', +// id: 'u3', +// email: 'user@example.org', +// slug: 'jenny-rostock', +// iat: 1550846680, +// exp: 1637246680, +// aud: 'http://localhost:3000', +// iss: 'http://localhost:4000', +// sub: 'u3' +// } +export const validAuthorizationHeader = + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImxvY2F0aW9uTmFtZSI6bnVsbCwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJhYm91dCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9zYXNoYV9zaGVzdGFrb3YvMTI4LmpwZyIsImlkIjoidTMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5vcmciLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTU1MDg0NjY4MCwiZXhwIjoxNjM3MjQ2NjgwLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.eZ_mVKas4Wzoc_JrQTEWXyRn7eY64cdIg4vqQ-F_7Jc' + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('decode', () => { + let authorizationHeader + const returnsNull = async () => { + await expect(decode(driver, authorizationHeader)).resolves.toBeNull() + } + + describe('given `null` as JWT Bearer token', () => { + beforeEach(() => { + authorizationHeader = null + }) + it('returns null', returnsNull) + }) + + describe('given no JWT Bearer token', () => { + beforeEach(() => { + authorizationHeader = undefined + }) + it('returns null', returnsNull) + }) + + describe('given malformed JWT Bearer token', () => { + beforeEach(() => { + authorizationHeader = 'blah' + }) + it('returns null', returnsNull) + }) + + describe('given valid JWT Bearer token', () => { + beforeEach(() => { + authorizationHeader = validAuthorizationHeader + }) + it('returns null', returnsNull) + + describe('and corresponding user in the database', () => { + let user + beforeEach(async () => { + user = await factory.create('User', { + role: 'user', + name: 'Jenny Rostock', + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', + id: 'u3', + email: 'user@example.org', + slug: 'jenny-rostock', + }) + }) + + it('returns user object except email', async () => { + await expect(decode(driver, authorizationHeader)).resolves.toMatchObject({ + role: 'user', + name: 'Jenny Rostock', + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', + id: 'u3', + email: null, + slug: 'jenny-rostock', + }) + }) + + describe('but user is deleted', () => { + beforeEach(async () => { + await user.update({ updatedAt: new Date().toISOString(), deleted: true }) + }) + + it('returns null', returnsNull) + }) + describe('but user is disabled', () => { + beforeEach(async () => { + await user.update({ updatedAt: new Date().toISOString(), disabled: true }) + }) + + it('returns null', returnsNull) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index be790ca3a..a634eaf85 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -24,11 +24,11 @@ export default { // } const session = driver.session() const result = await session.run( - '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, - }, + ` + MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail}) + RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1 + `, + { userEmail: email }, ) session.close() const [currentUser] = await result.records.map(record => { diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index ff0c0db4e..ad088d4aa 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -1,50 +1,54 @@ -import { GraphQLClient, request } from 'graphql-request' import jwt from 'jsonwebtoken' import CONFIG from './../../config' import Factory from '../../seed/factories' -import { host, login } from '../../jest/helpers' +import { gql } from '../../jest/helpers' +import { createTestClient } from 'apollo-server-testing' +import createServer, { context } from '../../server' const factory = Factory() +let query +let mutate +let variables +let req +let user -// here is the decoded JWT token: -// { -// role: 'user', -// locationName: null, -// name: 'Jenny Rostock', -// about: null, -// avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', -// id: 'u3', -// email: 'user@example.org', -// slug: 'jenny-rostock', -// iat: 1550846680, -// exp: 1637246680, -// aud: 'http://localhost:3000', -// iss: 'http://localhost:4000', -// sub: 'u3' -// } -const jennyRostocksHeaders = { - authorization: - 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImxvY2F0aW9uTmFtZSI6bnVsbCwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJhYm91dCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9zYXNoYV9zaGVzdGFrb3YvMTI4LmpwZyIsImlkIjoidTMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5vcmciLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTU1MDg0NjY4MCwiZXhwIjoxNjM3MjQ2NjgwLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.eZ_mVKas4Wzoc_JrQTEWXyRn7eY64cdIg4vqQ-F_7Jc', -} +// This is a bearer token of a user with id `u3`: +const userBearerToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsIm5hbWUiOiJKZW5ueSBSb3N0b2NrIiwiZGlzYWJsZWQiOmZhbHNlLCJhdmF0YXIiOiJodHRwczovL3MzLmFtYXpvbmF3cy5jb20vdWlmYWNlcy9mYWNlcy90d2l0dGVyL2tleXVyaTg1LzEyOC5qcGciLCJpZCI6InUzIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUub3JnIiwic2x1ZyI6Implbm55LXJvc3RvY2siLCJpYXQiOjE1Njc0NjgyMDIsImV4cCI6MTU2NzU1NDYwMiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDozMDAwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo0MDAwIiwic3ViIjoidTMifQ.RkmrdJDL1kIqGnMWUBl_sJJ4grzfpTEGdT6doMsbLW8' + +// This is a bearer token of a user with id `u2`: +const moderatorBearerToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoibW9kZXJhdG9yIiwibmFtZSI6IkJvYiBkZXIgQmF1bWVpc3RlciIsImRpc2FibGVkIjpmYWxzZSwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9hbmRyZXdvZmZpY2VyLzEyOC5qcGciLCJpZCI6InUyIiwiZW1haWwiOiJtb2RlcmF0b3JAZXhhbXBsZS5vcmciLCJzbHVnIjoiYm9iLWRlci1iYXVtZWlzdGVyIiwiaWF0IjoxNTY3NDY4MDUwLCJleHAiOjE1Njc1NTQ0NTAsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDAwMCIsInN1YiI6InUyIn0.LdVFPKqIcoY0a7_kFZSTgnc8NzmZD7CrR3vkWLSqedM' const disable = async id => { - const moderatorParams = { email: 'moderator@example.org', role: 'moderator', password: '1234' } - const asModerator = Factory() - await asModerator.create('User', moderatorParams) - await asModerator.authenticateAs(moderatorParams) - await asModerator.mutate('mutation($id: ID!) { disable(id: $id) }', { id }) + await factory.create('User', { id: 'u2', role: 'moderator' }) + req = { headers: { authorization: `Bearer ${moderatorBearerToken}` } } + await mutate({ + mutation: gql` + mutation($id: ID!) { + disable(id: $id) + } + `, + variables: { id }, + }) + req = { headers: {} } } -beforeEach(async () => { - await factory.create('User', { - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', - id: 'acb2d923-f3af-479e-9f00-61b12e864666', - name: 'Matilde Hermiston', - slug: 'matilde-hermiston', - role: 'user', - email: 'test@example.org', - password: '1234', +beforeEach(() => { + user = null + req = { headers: {} } +}) + +beforeAll(() => { + const { server } = createServer({ + context: () => { + // One of the rare occasions where we test + // the actual `context` implementation here + return context({ req }) + }, }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate }) afterEach(async () => { @@ -52,261 +56,266 @@ afterEach(async () => { }) describe('isLoggedIn', () => { - const query = '{ isLoggedIn }' + const isLoggedInQuery = gql` + { + isLoggedIn + } + ` + const respondsWith = async expected => { + await expect(query({ query: isLoggedInQuery })).resolves.toMatchObject(expected) + } + describe('unauthenticated', () => { it('returns false', async () => { - await expect(request(host, query)).resolves.toEqual({ - isLoggedIn: false, - }) + await respondsWith({ data: { isLoggedIn: false } }) }) }) - describe('with malformed JWT Bearer token', () => { - const headers = { authorization: 'blah' } - const client = new GraphQLClient(host, { headers }) - - it('returns false', async () => { - await expect(client.request(query)).resolves.toEqual({ - isLoggedIn: false, - }) + describe('authenticated', () => { + beforeEach(async () => { + user = await factory.create('User', { id: 'u3' }) + req = { headers: { authorization: `Bearer ${userBearerToken}` } } }) - }) - describe('with valid JWT Bearer token', () => { - const client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) + it('returns true', async () => { + await respondsWith({ data: { isLoggedIn: true } }) + }) - it('returns false', async () => { - await expect(client.request(query)).resolves.toEqual({ - isLoggedIn: false, + describe('but user is disabled', () => { + beforeEach(async () => { + await disable('u3') + }) + + it('returns false', async () => { + await respondsWith({ data: { isLoggedIn: false } }) }) }) - describe('and a corresponding user in the database', () => { - describe('user is enabled', () => { - it('returns true', async () => { - // see the decoded token above - await factory.create('User', { id: 'u3' }) - await expect(client.request(query)).resolves.toEqual({ - isLoggedIn: true, - }) - }) + describe('but user is deleted', () => { + beforeEach(async () => { + await user.update({ updatedAt: new Date().toISOString(), deleted: true }) }) - describe('user is disabled', () => { - beforeEach(async () => { - await factory.create('User', { id: 'u3' }) - await disable('u3') - }) - - it('returns false', async () => { - await expect(client.request(query)).resolves.toEqual({ - isLoggedIn: false, - }) - }) + it('returns false', async () => { + await respondsWith({ data: { isLoggedIn: false } }) }) }) }) }) describe('currentUser', () => { - const query = `{ - currentUser { - id - slug - name - avatar - email - role + const currentUserQuery = gql` + { + currentUser { + id + slug + name + avatar + email + role + } } - }` + ` + + const respondsWith = async expected => { + await expect(query({ query: currentUserQuery, variables })).resolves.toMatchObject(expected) + } describe('unauthenticated', () => { it('returns null', async () => { - const expected = { currentUser: null } - await expect(request(host, query)).resolves.toEqual(expected) + await respondsWith({ data: { currentUser: null } }) }) }) - describe('with valid JWT Bearer Token', () => { - let client - let headers - - describe('but no corresponding user in the database', () => { - beforeEach(async () => { - client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) - }) - - it('returns null', async () => { - const expected = { currentUser: null } - await expect(client.request(query)).resolves.toEqual(expected) - }) - }) - + describe('authenticated', () => { describe('and corresponding user in the database', () => { beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + await factory.create('User', { + id: 'u3', + // the `id` is the only thing that has to match the decoded JWT bearer token + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', + email: 'test@example.org', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user', + }) + req = { headers: { authorization: `Bearer ${userBearerToken}` } } }) it('returns the whole user object', async () => { const expected = { - currentUser: { - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', - email: 'test@example.org', - id: 'acb2d923-f3af-479e-9f00-61b12e864666', - name: 'Matilde Hermiston', - slug: 'matilde-hermiston', - role: 'user', + data: { + currentUser: { + id: 'u3', + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', + email: 'test@example.org', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user', + }, }, } - await expect(client.request(query)).resolves.toEqual(expected) + await respondsWith(expected) }) }) }) }) describe('login', () => { - const mutation = params => { - const { email, password } = params - return ` - mutation { - login(email:"${email}", password:"${password}") - }` + const loginMutation = gql` + mutation($email: String!, $password: String!) { + login(email: $email, password: $password) + } + ` + + const respondsWith = async expected => { + await expect(mutate({ mutation: loginMutation, variables })).resolves.toMatchObject(expected) } + beforeEach(async () => { + variables = { email: 'test@example.org', password: '1234' } + user = await factory.create('User', { + ...variables, + id: 'acb2d923-f3af-479e-9f00-61b12e864666', + }) + }) + describe('ask for a `token`', () => { - describe('with valid email/password combination', () => { - it('responds with a JWT token', async () => { - const data = await request( - host, - mutation({ - email: 'test@example.org', - password: '1234', - }), - ) - const token = data.login + describe('with a valid email/password combination', () => { + it('responds with a JWT bearer token', async done => { + const { + data: { login: token }, + } = await mutate({ mutation: loginMutation, variables }) jwt.verify(token, CONFIG.JWT_SECRET, (err, data) => { expect(data.email).toEqual('test@example.org') expect(err).toBeNull() + done() + }) + }) + + describe('but user account is deleted', () => { + beforeEach(async () => { + await user.update({ updatedAt: new Date().toISOString(), deleted: true }) + }) + + it('responds with "Incorrect email address or password."', async () => { + await respondsWith({ + data: null, + errors: [{ message: 'Incorrect email address or password.' }], + }) + }) + }) + + describe('but user account is disabled', () => { + beforeEach(async () => { + await disable('acb2d923-f3af-479e-9f00-61b12e864666') + }) + + it('responds with "Your account has been disabled."', async () => { + await respondsWith({ + data: null, + errors: [{ message: 'Your account has been disabled.' }], + }) }) }) }) - describe('valid email/password but user is disabled', () => { - it('responds with "Your account has been disabled."', async () => { - await disable('acb2d923-f3af-479e-9f00-61b12e864666') - await expect( - request( - host, - mutation({ - email: 'test@example.org', - password: '1234', - }), - ), - ).rejects.toThrow('Your account has been disabled.') - }) - }) - describe('with a valid email but incorrect password', () => { + beforeEach(() => { + variables = { ...variables, email: 'test@example.org', password: 'wrong' } + }) + it('responds with "Incorrect email address or password."', async () => { - await expect( - request( - host, - mutation({ - email: 'test@example.org', - password: 'wrong', - }), - ), - ).rejects.toThrow('Incorrect email address or password.') + await respondsWith({ + errors: [{ message: 'Incorrect email address or password.' }], + }) }) }) describe('with a non-existing email', () => { + beforeEach(() => { + variables = { + ...variables, + email: 'non-existent@example.org', + password: '1234', + } + }) + it('responds with "Incorrect email address or password."', async () => { - await expect( - request( - host, - mutation({ - email: 'non-existent@example.org', - password: 'wrong', - }), - ), - ).rejects.toThrow('Incorrect email address or password.') + await respondsWith({ + errors: [{ message: 'Incorrect email address or password.' }], + }) }) }) }) }) describe('change password', () => { - let headers - let client + const changePasswordMutation = gql` + mutation($oldPassword: String!, $newPassword: String!) { + changePassword(oldPassword: $oldPassword, newPassword: $newPassword) + } + ` - beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) - }) - - const mutation = params => { - const { oldPassword, newPassword } = params - return ` - mutation { - changePassword(oldPassword:"${oldPassword}", newPassword:"${newPassword}") - }` + const respondsWith = async expected => { + await expect(mutate({ mutation: changePasswordMutation, variables })).resolves.toMatchObject( + expected, + ) } - describe('should be authenticated before changing password', () => { + beforeEach(async () => { + variables = { ...variables, oldPassword: 'what', newPassword: 'ever' } + }) + + describe('unauthenticated', () => { it('throws "Not Authorised!"', async () => { - await expect( - request( - host, - mutation({ - oldPassword: '1234', - newPassword: '1234', - }), - ), - ).rejects.toThrow('Not Authorised!') + await respondsWith({ errors: [{ message: 'Not Authorised!' }] }) }) }) - describe('old and new password should not match', () => { - it('responds with "Old password and new password should be different"', async () => { - await expect( - client.request( - mutation({ - oldPassword: '1234', - newPassword: '1234', - }), - ), - ).rejects.toThrow('Old password and new password should be different') + describe('authenticated', () => { + beforeEach(async () => { + await factory.create('User', { id: 'u3' }) + req = { headers: { authorization: `Bearer ${userBearerToken}` } } }) - }) + describe('old password === new password', () => { + beforeEach(() => { + variables = { ...variables, oldPassword: '1234', newPassword: '1234' } + }) - describe('incorrect old password', () => { - it('responds with "Old password isn\'t valid"', async () => { - await expect( - client.request( - mutation({ - oldPassword: 'notOldPassword', - newPassword: '12345', - }), - ), - ).rejects.toThrow('Old password is not correct') + it('responds with "Old password and new password should be different"', async () => { + await respondsWith({ + errors: [{ message: 'Old password and new password should be different' }], + }) + }) }) - }) - describe('correct password', () => { - it('changes the password if given correct credentials "', async () => { - const response = await client.request( - mutation({ + describe('incorrect old password', () => { + beforeEach(() => { + variables = { + ...variables, + oldPassword: 'notOldPassword', + newPassword: '12345', + } + }) + + it('responds with "Old password isn\'t valid"', async () => { + await respondsWith({ errors: [{ message: 'Old password is not correct' }] }) + }) + }) + + describe('correct password', () => { + beforeEach(() => { + variables = { + ...variables, oldPassword: '1234', newPassword: '12345', - }), - ) - await expect(response).toEqual( - expect.objectContaining({ - changePassword: expect.any(String), - }), - ) + } + }) + + it('changes the password if given correct credentials "', async () => { + await respondsWith({ data: { changePassword: expect.any(String) } }) + }) }) }) }) diff --git a/backend/src/server.js b/backend/src/server.js index f92e77fed..70eae86f1 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -18,20 +18,22 @@ Object.entries(requiredConfigs).map(entry => { const driver = getDriver() const neode = getNeode() +export const context = async ({ req }) => { + const user = await decode(driver, req.headers.authorization) + return { + driver, + neode, + user, + req, + cypherParams: { + currentUserId: user ? user.id : null, + }, + } +} + const createServer = options => { const defaults = { - context: async ({ req }) => { - const user = await decode(driver, req.headers.authorization) - return { - driver, - neode, - user, - req, - cypherParams: { - currentUserId: user ? user.id : null, - }, - } - }, + context, schema: middleware(schema), debug: !!CONFIG.DEBUG, tracing: !!CONFIG.DEBUG,