From 69bf53e05e2529d7cef21bd816da1c39fc73ce59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Tue, 26 Feb 2019 16:28:22 +0100 Subject: [PATCH 1/3] Impplement currentUser query * remove dead code (passport-jwt) * refactor resolves to have a separate folder * currentUser and login have the same response --- package.json | 2 - src/graphql-schema.js | 178 ++--------------------- src/graphql-schema.spec.js | 184 ------------------------ src/jwt/decode.js | 29 ++++ src/jwt/{generateToken.js => encode.js} | 2 +- src/jwt/strategy.js | 42 ------ src/middleware/permissionsMiddleware.js | 3 +- src/resolvers/posts.js | 22 +++ src/resolvers/posts.spec.js | 61 ++++++++ src/resolvers/reports.js | 51 +++++++ src/resolvers/reports.spec.js | 68 +++++++++ src/resolvers/statistics.js | 67 +++++++++ src/resolvers/user_management.js | 52 +++++++ src/resolvers/user_management.spec.js | 183 +++++++++++++++++++++++ src/schema.graphql | 4 +- src/server.js | 27 +--- yarn.lock | 28 +--- 17 files changed, 555 insertions(+), 448 deletions(-) delete mode 100644 src/graphql-schema.spec.js create mode 100644 src/jwt/decode.js rename src/jwt/{generateToken.js => encode.js} (90%) delete mode 100644 src/jwt/strategy.js create mode 100644 src/resolvers/posts.js create mode 100644 src/resolvers/posts.spec.js create mode 100644 src/resolvers/reports.js create mode 100644 src/resolvers/reports.spec.js create mode 100644 src/resolvers/statistics.js create mode 100644 src/resolvers/user_management.js create mode 100644 src/resolvers/user_management.spec.js diff --git a/package.json b/package.json index 39c9c14c6..e313b45d5 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,6 @@ "neo4j-graphql-js": "~2.3.1", "node-fetch": "~2.3.0", "npm-run-all": "~4.1.5", - "passport": "~0.4.0", - "passport-jwt": "~4.0.0", "sanitize-html": "~1.20.0", "slug": "~1.0.0", "trunc-html": "~1.1.2", diff --git a/src/graphql-schema.js b/src/graphql-schema.js index ce84dde36..6d10183c9 100644 --- a/src/graphql-schema.js +++ b/src/graphql-schema.js @@ -1,182 +1,22 @@ import fs from 'fs' import path from 'path' -import bcrypt from 'bcryptjs' -import generateJwt from './jwt/generateToken' -import uuid from 'uuid/v4' -import { fixUrl } from './middleware/fixImageUrlsMiddleware' -import { AuthenticationError } from 'apollo-server' -import { neo4jgraphql } from 'neo4j-graphql-js' +import userManagement from './resolvers/user_management.js' +import statistics from './resolvers/statistics.js' +import reports from './resolvers/reports.js' +import posts from './resolvers/posts.js' export const typeDefs = fs.readFileSync(process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql')) .toString('utf-8') -export const query = (cypher, session) => { - return new Promise((resolve, reject) => { - let data = [] - session - .run(cypher) - .subscribe({ - onNext: function (record) { - let item = {} - record.keys.forEach(key => { - item[key] = record.get(key) - }) - data.push(item) - }, - onCompleted: function () { - session.close() - resolve(data) - }, - onError: function (error) { - reject(error) - } - }) - }) -} -const queryOne = (cypher, session) => { - return new Promise((resolve, reject) => { - query(cypher, session) - .then(res => { - resolve(res.length ? res.pop() : {}) - }) - .catch(err => { - reject(err) - }) - }) -} - export const resolvers = { Query: { - isLoggedIn: (parent, args, { driver, user }) => { - return Boolean(user && user.id) - }, - statistics: async (parent, args, { driver, user }) => { - return new Promise(async (resolve) => { - const session = driver.session() - const queries = { - countUsers: 'MATCH (r:User) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countUsers', - countPosts: 'MATCH (r:Post) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countPosts', - countComments: 'MATCH (r:Comment) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countComments', - countNotifications: 'MATCH (r:Notification) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countNotifications', - countOrganizations: 'MATCH (r:Organization) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countOrganizations', - countProjects: 'MATCH (r:Project) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countProjects', - countInvites: 'MATCH (r:Invite) WHERE r.wasUsed <> true OR NOT exists(r.wasUsed) RETURN COUNT(r) AS countInvites', - countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows', - countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts' - } - let data = { - countUsers: (await queryOne(queries.countUsers, session)).countUsers.low, - countPosts: (await queryOne(queries.countPosts, session)).countPosts.low, - countComments: (await queryOne(queries.countComments, session)).countComments.low, - countNotifications: (await queryOne(queries.countNotifications, session)).countNotifications.low, - countOrganizations: (await queryOne(queries.countOrganizations, session)).countOrganizations.low, - countProjects: (await queryOne(queries.countProjects, session)).countProjects.low, - countInvites: (await queryOne(queries.countInvites, session)).countInvites.low, - countFollows: (await queryOne(queries.countFollows, session)).countFollows.low, - countShouts: (await queryOne(queries.countShouts, session)).countShouts.low - } - resolve(data) - }) - } - // usersBySubstring: neo4jgraphql + ...statistics.Query, + ...userManagement.Query }, Mutation: { - signup: async (parent, { email, password }, { req }) => { - // if (data[email]) { - // throw new Error('Another User with same email exists.') - // } - // data[email] = { - // password: await bcrypt.hashSync(password, 10), - // } - - return true - }, - login: async (parent, { email, password }, { driver, req, user }) => { - // if (user && user.id) { - // throw new Error('Already logged in.') - // } - const session = driver.session() - return session.run( - 'MATCH (user:User {email: $userEmail}) ' + - 'RETURN user {.id, .slug, .name, .avatar, .locationName, .about, .email, .password, .role} as user LIMIT 1', { - userEmail: email - }) - .then(async (result) => { - session.close() - const [currentUser] = await result.records.map(function (record) { - return record.get('user') - }) - - if (currentUser && await bcrypt.compareSync(password, currentUser.password)) { - delete currentUser.password - currentUser.avatar = fixUrl(currentUser.avatar) - return Object.assign(currentUser, { - token: generateJwt(currentUser) - }) - } else throw new AuthenticationError('Incorrect email address or password.') - }) - }, - report: async (parent, { resource, description }, { driver, req, user }, resolveInfo) => { - const contextId = uuid() - const session = driver.session() - const data = { - id: contextId, - type: resource.type, - createdAt: (new Date()).toISOString(), - description: resource.description - } - await session.run( - 'CREATE (r:Report $report) ' + - 'RETURN r.id, r.type, r.description', { - report: data - } - ) - let contentType - - switch (resource.type) { - case 'post': - case 'contribution': - contentType = 'Post' - break - case 'comment': - contentType = 'Comment' - break - case 'user': - contentType = 'User' - break - } - - await session.run( - `MATCH (author:User {id: $userId}), (context:${contentType} {id: $resourceId}), (report:Report {id: $contextId}) ` + - 'MERGE (report)<-[:REPORTED]-(author) ' + - 'MERGE (context)<-[:REPORTED]-(report) ' + - 'RETURN context', { - resourceId: resource.id, - userId: user.id, - contextId: contextId - } - ) - session.close() - - // TODO: output Report compatible object - return data - }, - CreatePost: async (object, params, ctx, resolveInfo) => { - const result = await neo4jgraphql(object, params, ctx, resolveInfo, false) - - const session = ctx.driver.session() - await session.run( - 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + - 'MERGE (post)<-[:WROTE]-(author) ' + - 'RETURN author', { - userId: ctx.user.id, - postId: result.id - }) - session.close() - - return result - } - + ...userManagement.Mutation, + ...reports.Mutation, + ...posts.Mutation } } diff --git a/src/graphql-schema.spec.js b/src/graphql-schema.spec.js deleted file mode 100644 index 7aa47835f..000000000 --- a/src/graphql-schema.spec.js +++ /dev/null @@ -1,184 +0,0 @@ -import Factory from './seed/factories' -import { GraphQLClient, request } from 'graphql-request' -import jwt from 'jsonwebtoken' -import { host, login } from './jest/helpers' - -const factory = Factory() - -beforeEach(async () => { - await factory.create('User', { - email: 'test@example.org', - password: '1234' - }) -}) - -afterEach(async () => { - await factory.cleanDatabase() -}) - -describe('isLoggedIn', () => { - describe('unauthenticated', () => { - it('returns false', async () => { - const query = '{ isLoggedIn }' - await expect(request(host, query)).resolves.toEqual({ isLoggedIn: false }) - }) - }) -}) - -describe('login', () => { - const mutation = (params) => { - const { email, password } = params - return ` - mutation { - login(email:"${email}", password:"${password}"){ - token - } - }` - } - - 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 - jwt.verify(token, process.env.JWT_SECRET, (err, data) => { - expect(data.email).toEqual('test@example.org') - expect(err).toBeNull() - }) - }) - }) - - describe('with a valid email but incorrect password', () => { - 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.') - }) - }) - - describe('with a non-existing email', () => { - 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.') - }) - }) - }) -}) - -describe('CreatePost', () => { - describe('unauthenticated', () => { - let client - it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(`mutation { - CreatePost( - title: "I am a post", - content: "Some content" - ) { slug } - }`)).rejects.toThrow('Not Authorised') - }) - - describe('authenticated', () => { - let headers - let response - beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) - response = await client.request(`mutation { - CreatePost( - title: "A title", - content: "Some content" - ) { title, content } - }`, { headers }) - }) - - it('creates a post', () => { - expect(response).toEqual({ CreatePost: { title: 'A title', content: 'Some content' } }) - }) - - it('assigns the authenticated user as author', async () => { - const { User } = await client.request(`{ - User(email:"test@example.org") { - contributions { - title - } - } - }`, { headers }) - expect(User).toEqual([ { contributions: [ { title: 'A title' } ] } ]) - }) - }) - }) -}) - -describe('report', () => { - beforeEach(async () => { - await factory.create('User', { - email: 'test@example.org', - password: '1234' - }) - await factory.create('User', { - id: 'u2', - name: 'abusive-user', - role: 'user', - email: 'abusive-user@example.org' - }) - }) - - afterEach(async () => { - await factory.cleanDatabase() - }) - - describe('unauthenticated', () => { - let client - it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect( - client.request(`mutation { - report( - description: "I don't like this user", - resource: { - id: "u2", - type: user - } - ) { id, createdAt } - }`) - ).rejects.toThrow('Not Authorised') - }) - - describe('authenticated', () => { - let headers - let response - beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) - response = await client.request(`mutation { - report( - description: "I don't like this user", - resource: { - id: "u2", - type: user - } - ) { id, createdAt } - }`, - { headers } - ) - }) - it('creates a report', () => { - let { id, createdAt } = response.report - expect(response).toEqual({ - report: { id, createdAt } - }) - }) - }) - }) -}) diff --git a/src/jwt/decode.js b/src/jwt/decode.js new file mode 100644 index 000000000..0ab1e4529 --- /dev/null +++ b/src/jwt/decode.js @@ -0,0 +1,29 @@ +import jwt from 'jsonwebtoken' + +export default async (driver, authorizationHeader) => { + if (!authorizationHeader) return null + const token = authorizationHeader.replace('Bearer ', '') + let id = null + try { + const decoded = await jwt.verify(token, process.env.JWT_SECRET) + id = decoded.sub + } catch { + return null + } + const session = driver.session() + const query = ` + MATCH (user:User {id: {id} }) + RETURN user {.id, .slug, .name, .avatar, .email, .role} as user + LIMIT 1 + ` + const result = await session.run(query, { id }) + session.close() + const [currentUser] = await result.records.map((record) => { + return record.get('user') + }) + if (!currentUser) return null + return { + token, + ...currentUser + } +} diff --git a/src/jwt/generateToken.js b/src/jwt/encode.js similarity index 90% rename from src/jwt/generateToken.js rename to src/jwt/encode.js index 7cbc70330..62e85616b 100644 --- a/src/jwt/generateToken.js +++ b/src/jwt/encode.js @@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken' import ms from 'ms' // Generate an Access Token for the given User ID -export default function generateJwt (user) { +export default function encode (user) { const token = jwt.sign(user, process.env.JWT_SECRET, { expiresIn: ms('1d'), issuer: process.env.GRAPHQL_URI, diff --git a/src/jwt/strategy.js b/src/jwt/strategy.js deleted file mode 100644 index 5b1ea1231..000000000 --- a/src/jwt/strategy.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Strategy } from 'passport-jwt' -import { fixUrl } from '../middleware/fixImageUrlsMiddleware' - -const cookieExtractor = (req) => { - var token = null - if (req && req.cookies) { - token = req.cookies['jwt'] - } - return token -} - -export default (driver) => { - const options = { - jwtFromRequest: cookieExtractor, - secretOrKey: process.env.JWT_SECRET, - issuer: process.env.GRAPHQL_URI, - audience: process.env.CLIENT_URI - } - - return new Strategy(options, - async (JWTPayload, next) => { - const session = driver.session() - const result = await session.run( - 'MATCH (user:User {id: $userId}) ' + - 'RETURN user {.id, .slug, .name, .avatar, .email, .role} as user LIMIT 1', - { - userId: JWTPayload.id - } - ) - session.close() - const [currentUser] = await result.records.map((record) => { - return record.get('user') - }) - - if (currentUser) { - currentUser.avatar = fixUrl(currentUser.avatar) - return next(null, currentUser) - } else { - return next(null, false) - } - }) -} diff --git a/src/middleware/permissionsMiddleware.js b/src/middleware/permissionsMiddleware.js index 1a3f04ceb..0c6723b4b 100644 --- a/src/middleware/permissionsMiddleware.js +++ b/src/middleware/permissionsMiddleware.js @@ -23,7 +23,8 @@ const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, ctx, info) => { // Permissions const permissions = shield({ Query: { - statistics: allow + statistics: allow, + currentUser: allow // fruits: and(isAuthenticated, or(isAdmin, isModerator)), // customers: and(isAuthenticated, isAdmin) }, diff --git a/src/resolvers/posts.js b/src/resolvers/posts.js new file mode 100644 index 000000000..6a8a0c25f --- /dev/null +++ b/src/resolvers/posts.js @@ -0,0 +1,22 @@ +import { neo4jgraphql } from 'neo4j-graphql-js' + +export default { + Mutation: { + CreatePost: async (object, params, ctx, resolveInfo) => { + const result = await neo4jgraphql(object, params, ctx, resolveInfo, false) + + const session = ctx.driver.session() + await session.run( + 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + + 'MERGE (post)<-[:WROTE]-(author) ' + + 'RETURN author', { + userId: ctx.user.id, + postId: result.id + }) + session.close() + + return result + } + + } +} diff --git a/src/resolvers/posts.spec.js b/src/resolvers/posts.spec.js new file mode 100644 index 000000000..a6c1d7e3e --- /dev/null +++ b/src/resolvers/posts.spec.js @@ -0,0 +1,61 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +beforeEach(async () => { + await factory.create('User', { + email: 'test@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('CreatePost', () => { + describe('unauthenticated', () => { + let client + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(`mutation { + CreatePost( + title: "I am a post", + content: "Some content" + ) { slug } + }`)).rejects.toThrow('Not Authorised') + }) + + describe('authenticated', () => { + let headers + let response + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + response = await client.request(`mutation { + CreatePost( + title: "A title", + content: "Some content" + ) { title, content } + }`, { headers }) + }) + + it('creates a post', () => { + expect(response).toEqual({ CreatePost: { title: 'A title', content: 'Some content' } }) + }) + + it('assigns the authenticated user as author', async () => { + const { User } = await client.request(`{ + User(email:"test@example.org") { + contributions { + title + } + } + }`, { headers }) + expect(User).toEqual([ { contributions: [ { title: 'A title' } ] } ]) + }) + }) + }) +}) diff --git a/src/resolvers/reports.js b/src/resolvers/reports.js new file mode 100644 index 000000000..c471d7b7a --- /dev/null +++ b/src/resolvers/reports.js @@ -0,0 +1,51 @@ +import uuid from 'uuid/v4' + +export default { + Mutation: { + report: async (parent, { resource, description }, { driver, req, user }, resolveInfo) => { + const contextId = uuid() + const session = driver.session() + const data = { + id: contextId, + type: resource.type, + createdAt: (new Date()).toISOString(), + description: resource.description + } + await session.run( + 'CREATE (r:Report $report) ' + + 'RETURN r.id, r.type, r.description', { + report: data + } + ) + let contentType + + switch (resource.type) { + case 'post': + case 'contribution': + contentType = 'Post' + break + case 'comment': + contentType = 'Comment' + break + case 'user': + contentType = 'User' + break + } + + await session.run( + `MATCH (author:User {id: $userId}), (context:${contentType} {id: $resourceId}), (report:Report {id: $contextId}) ` + + 'MERGE (report)<-[:REPORTED]-(author) ' + + 'MERGE (context)<-[:REPORTED]-(report) ' + + 'RETURN context', { + resourceId: resource.id, + userId: user.id, + contextId: contextId + } + ) + session.close() + + // TODO: output Report compatible object + return data + } + } +} diff --git a/src/resolvers/reports.spec.js b/src/resolvers/reports.spec.js new file mode 100644 index 000000000..253cdadcc --- /dev/null +++ b/src/resolvers/reports.spec.js @@ -0,0 +1,68 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +describe('report', () => { + beforeEach(async () => { + await factory.create('User', { + email: 'test@example.org', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + name: 'abusive-user', + role: 'user', + email: 'abusive-user@example.org' + }) + }) + + afterEach(async () => { + await factory.cleanDatabase() + }) + + describe('unauthenticated', () => { + let client + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(`mutation { + report( + description: "I don't like this user", + resource: { + id: "u2", + type: user + } + ) { id, createdAt } + }`) + ).rejects.toThrow('Not Authorised') + }) + + describe('authenticated', () => { + let headers + let response + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + response = await client.request(`mutation { + report( + description: "I don't like this user", + resource: { + id: "u2", + type: user + } + ) { id, createdAt } + }`, + { headers } + ) + }) + it('creates a report', () => { + let { id, createdAt } = response.report + expect(response).toEqual({ + report: { id, createdAt } + }) + }) + }) + }) +}) diff --git a/src/resolvers/statistics.js b/src/resolvers/statistics.js new file mode 100644 index 000000000..17c4be956 --- /dev/null +++ b/src/resolvers/statistics.js @@ -0,0 +1,67 @@ +export const query = (cypher, session) => { + return new Promise((resolve, reject) => { + let data = [] + session + .run(cypher) + .subscribe({ + onNext: function (record) { + let item = {} + record.keys.forEach(key => { + item[key] = record.get(key) + }) + data.push(item) + }, + onCompleted: function () { + session.close() + resolve(data) + }, + onError: function (error) { + reject(error) + } + }) + }) +} +const queryOne = (cypher, session) => { + return new Promise((resolve, reject) => { + query(cypher, session) + .then(res => { + resolve(res.length ? res.pop() : {}) + }) + .catch(err => { + reject(err) + }) + }) +} + +export default { + Query: { + statistics: async (parent, args, { driver, user }) => { + return new Promise(async (resolve) => { + const session = driver.session() + const queries = { + countUsers: 'MATCH (r:User) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countUsers', + countPosts: 'MATCH (r:Post) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countPosts', + countComments: 'MATCH (r:Comment) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countComments', + countNotifications: 'MATCH (r:Notification) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countNotifications', + countOrganizations: 'MATCH (r:Organization) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countOrganizations', + countProjects: 'MATCH (r:Project) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countProjects', + countInvites: 'MATCH (r:Invite) WHERE r.wasUsed <> true OR NOT exists(r.wasUsed) RETURN COUNT(r) AS countInvites', + countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows', + countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts' + } + let data = { + countUsers: (await queryOne(queries.countUsers, session)).countUsers.low, + countPosts: (await queryOne(queries.countPosts, session)).countPosts.low, + countComments: (await queryOne(queries.countComments, session)).countComments.low, + countNotifications: (await queryOne(queries.countNotifications, session)).countNotifications.low, + countOrganizations: (await queryOne(queries.countOrganizations, session)).countOrganizations.low, + countProjects: (await queryOne(queries.countProjects, session)).countProjects.low, + countInvites: (await queryOne(queries.countInvites, session)).countInvites.low, + countFollows: (await queryOne(queries.countFollows, session)).countFollows.low, + countShouts: (await queryOne(queries.countShouts, session)).countShouts.low + } + resolve(data) + }) + } + } +} diff --git a/src/resolvers/user_management.js b/src/resolvers/user_management.js new file mode 100644 index 000000000..b0c9d3df5 --- /dev/null +++ b/src/resolvers/user_management.js @@ -0,0 +1,52 @@ +import encode from '../jwt/encode' +import { fixUrl } from '../middleware/fixImageUrlsMiddleware' +import bcrypt from 'bcryptjs' +import { AuthenticationError } from 'apollo-server' + +export default { + Query: { + isLoggedIn: (parent, args, { driver, user }) => { + return Boolean(user && user.id) + }, + currentUser: (parent, args, { user }) => { + return user + } + }, + Mutation: { + signup: async (parent, { email, password }, { req }) => { + // if (data[email]) { + // throw new Error('Another User with same email exists.') + // } + // data[email] = { + // password: await bcrypt.hashSync(password, 10), + // } + + return true + }, + login: async (parent, { email, password }, { driver, req, user }) => { + // if (user && user.id) { + // throw new Error('Already logged in.') + // } + const session = driver.session() + return session.run( + 'MATCH (user:User {email: $userEmail}) ' + + 'RETURN user {.id, .slug, .name, .avatar, .email, .password, .role} as user LIMIT 1', { + userEmail: email + }) + .then(async (result) => { + session.close() + const [currentUser] = await result.records.map(function (record) { + return record.get('user') + }) + + if (currentUser && await bcrypt.compareSync(password, currentUser.password)) { + delete currentUser.password + currentUser.avatar = fixUrl(currentUser.avatar) + return Object.assign(currentUser, { + token: encode(currentUser) + }) + } else throw new AuthenticationError('Incorrect email address or password.') + }) + } + } +} diff --git a/src/resolvers/user_management.spec.js b/src/resolvers/user_management.spec.js new file mode 100644 index 000000000..cb12efb2d --- /dev/null +++ b/src/resolvers/user_management.spec.js @@ -0,0 +1,183 @@ +import Factory from '../seed/factories' +import { GraphQLClient, request } from 'graphql-request' +import jwt from 'jsonwebtoken' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +// 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' } + +beforeEach(async () => { + await factory.create('User', { + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/seyedhossein1/128.jpg', + id: 'acb2d923-f3af-479e-9f00-61b12e864666', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user', + email: 'test@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('isLoggedIn', () => { + const query = '{ isLoggedIn }' + describe('unauthenticated', () => { + it('returns false', async () => { + await expect(request(host, query)).resolves.toEqual({ 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('with valid JWT Bearer token', () => { + const client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) + + it('returns false', async () => { + await expect(client.request(query)).resolves.toEqual({ isLoggedIn: false }) + }) + + describe('and a corresponding user in the database', () => { + 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('currentUser', () => { + const query = `{ + currentUser { + id + slug + name + avatar + email + role + token + } + }` + + describe('unauthenticated', () => { + it('returns null', async () => { + const expected = { currentUser: null } + await expect(request(host, query)).resolves.toEqual(expected) + }) + }) + + 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('and corresponding user in the database', () => { + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('returns the whole user object', async () => { + const expected = { + currentUser: { + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/seyedhossein1/128.jpg', + email: 'test@example.org', + id: 'acb2d923-f3af-479e-9f00-61b12e864666', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user', + token: headers.authorization.replace('Bearer ', '') + } + } + await expect(client.request(query)).resolves.toEqual(expected) + }) + }) + }) +}) + +describe('login', () => { + const mutation = (params) => { + const { email, password } = params + return ` + mutation { + login(email:"${email}", password:"${password}"){ + token + } + }` + } + + 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 + jwt.verify(token, process.env.JWT_SECRET, (err, data) => { + expect(data.email).toEqual('test@example.org') + expect(err).toBeNull() + }) + }) + }) + + describe('with a valid email but incorrect password', () => { + 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.') + }) + }) + + describe('with a non-existing email', () => { + 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.') + }) + }) + }) +}) diff --git a/src/schema.graphql b/src/schema.graphql index 55f23d5ca..4413e1deb 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -1,5 +1,6 @@ type Query { isLoggedIn: Boolean! + currentUser: LoggedInUser statistics: Statistics! } type Mutation { @@ -7,6 +8,7 @@ type Mutation { signup(email: String!, password: String!): Boolean! report(resource: Resource!, description: String): Report } + type LoggedInUser { id: ID! slug: String! @@ -14,8 +16,6 @@ type LoggedInUser { avatar:String! email: String! role: String! - locationName: String - about: String token: String! } diff --git a/src/server.js b/src/server.js index 5867e6952..2fc3af871 100644 --- a/src/server.js +++ b/src/server.js @@ -8,10 +8,7 @@ import middleware from './middleware' import applyDirectives from './bootstrap/directives' import applyScalars from './bootstrap/scalars' import { getDriver } from './bootstrap/neo4j' - -import passport from 'passport' -import jwtStrategy from './jwt/strategy' -import jwt from 'jsonwebtoken' +import decode from './jwt/decode' dotenv.config() // check env and warn @@ -42,20 +39,14 @@ schema = applyScalars(applyDirectives(schema)) const createServer = (options) => { const defaults = { - context: async (req) => { - const payload = { + context: async ({ request }) => { + const authorizationHeader = request.headers.authorization || '' + const user = await decode(driver, authorizationHeader) + return { driver, - user: null, - req: req.request + user, + req: request } - try { - const token = payload.req.headers.authorization.replace('Bearer ', '') - payload.user = await jwt.verify(token, process.env.JWT_SECRET) - } catch (err) { - // nothing - } - - return payload }, schema: schema, debug: debug, @@ -65,11 +56,7 @@ const createServer = (options) => { } const server = new GraphQLServer(Object.assign({}, defaults, options)) - passport.use('jwt', jwtStrategy(driver)) - server.express.use(passport.initialize()) server.express.use(express.static('public')) - - server.express.post('/graphql', passport.authenticate(['jwt'], { session: false })) return server } diff --git a/yarn.lock b/yarn.lock index 2fe610fee..5ec9c94a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4348,7 +4348,7 @@ jsonify@~0.0.0: resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= -jsonwebtoken@^8.2.0, jsonwebtoken@~8.5.0: +jsonwebtoken@~8.5.0: version "8.5.0" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#ebd0ca2a69797816e1c5af65b6c759787252947e" integrity sha512-IqEycp0znWHNA11TpYi77bVgyBO/pGESDh7Ajhas+u0ttkGkKYIIAjniL4Bw5+oVejVF+SYkaI7XKfwCCyeTuA== @@ -5348,27 +5348,6 @@ pascalcase@^0.1.1: resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= -passport-jwt@~4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.0.tgz#7f0be7ba942e28b9f5d22c2ebbb8ce96ef7cf065" - integrity sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg== - dependencies: - jsonwebtoken "^8.2.0" - passport-strategy "^1.0.0" - -passport-strategy@1.x.x, passport-strategy@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" - integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ= - -passport@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.0.tgz#c5095691347bd5ad3b5e180238c3914d16f05811" - integrity sha1-xQlWkTR71a07XhgCOMORTRbwWBE= - dependencies: - passport-strategy "1.x.x" - pause "0.0.1" - path-dirname@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" @@ -5423,11 +5402,6 @@ pathval@^1.1.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= -pause@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" - integrity sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10= - performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" From 5fd44230ddc5660757185a78b572979da0d5e6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 27 Feb 2019 10:42:55 +0100 Subject: [PATCH 2/3] Get rid of LoggedInUser graphql type This was causing a lot of headache on the frontend. Now, there a single source of truth. If you query `currentUser` you can overwrite your local copy of the user and update the UI. --- src/jest/helpers.js | 6 ++---- src/resolvers/user_management.js | 15 +++++++-------- src/resolvers/user_management.spec.js | 10 +++------- src/schema.graphql | 14 ++------------ src/seed/factories/index.js | 6 ++---- 5 files changed, 16 insertions(+), 35 deletions(-) diff --git a/src/jest/helpers.js b/src/jest/helpers.js index ff6a535e2..0d358ed40 100644 --- a/src/jest/helpers.js +++ b/src/jest/helpers.js @@ -7,12 +7,10 @@ export const host = 'http://127.0.0.1:4123' export async function login ({ email, password }) { const mutation = ` mutation { - login(email:"${email}", password:"${password}"){ - token - } + login(email:"${email}", password:"${password}") }` const response = await request(host, mutation) return { - authorization: `Bearer ${response.login.token}` + authorization: `Bearer ${response.login}` } } diff --git a/src/resolvers/user_management.js b/src/resolvers/user_management.js index b0c9d3df5..552f6201a 100644 --- a/src/resolvers/user_management.js +++ b/src/resolvers/user_management.js @@ -1,16 +1,18 @@ import encode from '../jwt/encode' -import { fixUrl } from '../middleware/fixImageUrlsMiddleware' import bcrypt from 'bcryptjs' import { AuthenticationError } from 'apollo-server' +import { neo4jgraphql } from 'neo4j-graphql-js' export default { Query: { isLoggedIn: (parent, args, { driver, user }) => { return Boolean(user && user.id) }, - currentUser: (parent, args, { user }) => { - return user - } + currentUser: async (object, params, ctx, resolveInfo) => { + const { user} = ctx + if(!user) return null + return neo4jgraphql(object, {id: user.id}, ctx, resolveInfo, false) + }, }, Mutation: { signup: async (parent, { email, password }, { req }) => { @@ -41,10 +43,7 @@ export default { if (currentUser && await bcrypt.compareSync(password, currentUser.password)) { delete currentUser.password - currentUser.avatar = fixUrl(currentUser.avatar) - return Object.assign(currentUser, { - token: encode(currentUser) - }) + return encode(currentUser) } else throw new AuthenticationError('Incorrect email address or password.') }) } diff --git a/src/resolvers/user_management.spec.js b/src/resolvers/user_management.spec.js index cb12efb2d..a3bf6fdd0 100644 --- a/src/resolvers/user_management.spec.js +++ b/src/resolvers/user_management.spec.js @@ -82,7 +82,6 @@ describe('currentUser', () => { avatar email role - token } }` @@ -122,8 +121,7 @@ describe('currentUser', () => { id: 'acb2d923-f3af-479e-9f00-61b12e864666', name: 'Matilde Hermiston', slug: 'matilde-hermiston', - role: 'user', - token: headers.authorization.replace('Bearer ', '') + role: 'user' } } await expect(client.request(query)).resolves.toEqual(expected) @@ -137,9 +135,7 @@ describe('login', () => { const { email, password } = params return ` mutation { - login(email:"${email}", password:"${password}"){ - token - } + login(email:"${email}", password:"${password}") }` } @@ -150,7 +146,7 @@ describe('login', () => { email: 'test@example.org', password: '1234' })) - const { token } = data.login + const token = data.login jwt.verify(token, process.env.JWT_SECRET, (err, data) => { expect(data.email).toEqual('test@example.org') expect(err).toBeNull() diff --git a/src/schema.graphql b/src/schema.graphql index 4413e1deb..1f9bcb477 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -1,24 +1,14 @@ type Query { isLoggedIn: Boolean! - currentUser: LoggedInUser + currentUser: User statistics: Statistics! } type Mutation { - login(email: String!, password: String!): LoggedInUser + login(email: String!, password: String!): String! signup(email: String!, password: String!): Boolean! report(resource: Resource!, description: String): Report } -type LoggedInUser { - id: ID! - slug: String! - name: String! - avatar:String! - email: String! - role: String! - token: String! -} - type Statistics { countUsers: Int! countPosts: Int! diff --git a/src/seed/factories/index.js b/src/seed/factories/index.js index d9bbd700c..ed35d2c3b 100644 --- a/src/seed/factories/index.js +++ b/src/seed/factories/index.js @@ -15,13 +15,11 @@ export const seedServerHost = 'http://127.0.0.1:4001' const authenticatedHeaders = async ({ email, password }, host) => { const mutation = ` mutation { - login(email:"${email}", password:"${password}"){ - token - } + login(email:"${email}", password:"${password}") }` const response = await request(host, mutation) return { - authorization: `Bearer ${response.login.token}` + authorization: `Bearer ${response.login}` } } const factories = { From f9b60fa0b0b7bbec42eab38c22e39d58695c066d Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Wed, 27 Feb 2019 08:10:20 -0300 Subject: [PATCH 3/3] Fix lint --- src/resolvers/user_management.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/resolvers/user_management.js b/src/resolvers/user_management.js index 552f6201a..ec4ae7ce2 100644 --- a/src/resolvers/user_management.js +++ b/src/resolvers/user_management.js @@ -9,10 +9,10 @@ export default { return Boolean(user && user.id) }, currentUser: async (object, params, ctx, resolveInfo) => { - const { user} = ctx - if(!user) return null - return neo4jgraphql(object, {id: user.id}, ctx, resolveInfo, false) - }, + const { user } = ctx + if (!user) return null + return neo4jgraphql(object, { id: user.id }, ctx, resolveInfo, false) + } }, Mutation: { signup: async (parent, { email, password }, { req }) => {