Merge pull request #187 from Human-Connection/194_stop_decoding_jwt_on_frontend

Implement currentUser query
This commit is contained in:
Grzegorz Leoniec 2019-02-28 14:33:12 +01:00 committed by GitHub
commit 086345cd35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 554 additions and 466 deletions

View File

@ -59,8 +59,6 @@
"neo4j-graphql-js": "~2.3.1", "neo4j-graphql-js": "~2.3.1",
"node-fetch": "~2.3.0", "node-fetch": "~2.3.0",
"npm-run-all": "~4.1.5", "npm-run-all": "~4.1.5",
"passport": "~0.4.0",
"passport-jwt": "~4.0.0",
"sanitize-html": "~1.20.0", "sanitize-html": "~1.20.0",
"slug": "~1.0.0", "slug": "~1.0.0",
"trunc-html": "~1.1.2", "trunc-html": "~1.1.2",

View File

@ -1,182 +1,22 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import bcrypt from 'bcryptjs' import userManagement from './resolvers/user_management.js'
import generateJwt from './jwt/generateToken' import statistics from './resolvers/statistics.js'
import uuid from 'uuid/v4' import reports from './resolvers/reports.js'
import { fixUrl } from './middleware/fixImageUrlsMiddleware' import posts from './resolvers/posts.js'
import { AuthenticationError } from 'apollo-server'
import { neo4jgraphql } from 'neo4j-graphql-js'
export const typeDefs = export const typeDefs =
fs.readFileSync(process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql')) fs.readFileSync(process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql'))
.toString('utf-8') .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 = { export const resolvers = {
Query: { Query: {
isLoggedIn: (parent, args, { driver, user }) => { ...statistics.Query,
return Boolean(user && user.id) ...userManagement.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)
})
}
// usersBySubstring: neo4jgraphql
}, },
Mutation: { Mutation: {
signup: async (parent, { email, password }, { req }) => { ...userManagement.Mutation,
// if (data[email]) { ...reports.Mutation,
// throw new Error('Another User with same email exists.') ...posts.Mutation
// }
// 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
}
} }
} }

View File

@ -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 }
})
})
})
})
})

View File

@ -7,12 +7,10 @@ export const host = 'http://127.0.0.1:4123'
export async function login ({ email, password }) { export async function login ({ email, password }) {
const mutation = ` const mutation = `
mutation { mutation {
login(email:"${email}", password:"${password}"){ login(email:"${email}", password:"${password}")
token
}
}` }`
const response = await request(host, mutation) const response = await request(host, mutation)
return { return {
authorization: `Bearer ${response.login.token}` authorization: `Bearer ${response.login}`
} }
} }

29
src/jwt/decode.js Normal file
View File

@ -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
}
}

View File

@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken'
import ms from 'ms' import ms from 'ms'
// Generate an Access Token for the given User ID // 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, { const token = jwt.sign(user, process.env.JWT_SECRET, {
expiresIn: ms('1d'), expiresIn: ms('1d'),
issuer: process.env.GRAPHQL_URI, issuer: process.env.GRAPHQL_URI,

View File

@ -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)
}
})
}

View File

@ -23,7 +23,8 @@ const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, ctx, info) => {
// Permissions // Permissions
const permissions = shield({ const permissions = shield({
Query: { Query: {
statistics: allow statistics: allow,
currentUser: allow
// fruits: and(isAuthenticated, or(isAdmin, isModerator)), // fruits: and(isAuthenticated, or(isAdmin, isModerator)),
// customers: and(isAuthenticated, isAdmin) // customers: and(isAuthenticated, isAdmin)
}, },

22
src/resolvers/posts.js Normal file
View File

@ -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
}
}
}

View File

@ -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' } ] } ])
})
})
})
})

51
src/resolvers/reports.js Normal file
View File

@ -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
}
}
}

View File

@ -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 }
})
})
})
})
})

View File

@ -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)
})
}
}
}

View File

@ -0,0 +1,51 @@
import encode from '../jwt/encode'
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: 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 }) => {
// 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
return encode(currentUser)
} else throw new AuthenticationError('Incorrect email address or password.')
})
}
}
}

View File

@ -0,0 +1,179 @@
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
}
}`
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'
}
}
await expect(client.request(query)).resolves.toEqual(expected)
})
})
})
})
describe('login', () => {
const mutation = (params) => {
const { email, password } = params
return `
mutation {
login(email:"${email}", password:"${password}")
}`
}
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.')
})
})
})
})

View File

@ -1,23 +1,13 @@
type Query { type Query {
isLoggedIn: Boolean! isLoggedIn: Boolean!
currentUser: User
statistics: Statistics! statistics: Statistics!
} }
type Mutation { type Mutation {
login(email: String!, password: String!): LoggedInUser login(email: String!, password: String!): String!
signup(email: String!, password: String!): Boolean! signup(email: String!, password: String!): Boolean!
report(resource: Resource!, description: String): Report report(resource: Resource!, description: String): Report
} }
type LoggedInUser {
id: ID!
slug: String!
name: String!
avatar:String!
email: String!
role: String!
locationName: String
about: String
token: String!
}
type Statistics { type Statistics {
countUsers: Int! countUsers: Int!

View File

@ -15,13 +15,11 @@ export const seedServerHost = 'http://127.0.0.1:4001'
const authenticatedHeaders = async ({ email, password }, host) => { const authenticatedHeaders = async ({ email, password }, host) => {
const mutation = ` const mutation = `
mutation { mutation {
login(email:"${email}", password:"${password}"){ login(email:"${email}", password:"${password}")
token
}
}` }`
const response = await request(host, mutation) const response = await request(host, mutation)
return { return {
authorization: `Bearer ${response.login.token}` authorization: `Bearer ${response.login}`
} }
} }
const factories = { const factories = {

View File

@ -8,10 +8,7 @@ import middleware from './middleware'
import applyDirectives from './bootstrap/directives' import applyDirectives from './bootstrap/directives'
import applyScalars from './bootstrap/scalars' import applyScalars from './bootstrap/scalars'
import { getDriver } from './bootstrap/neo4j' import { getDriver } from './bootstrap/neo4j'
import decode from './jwt/decode'
import passport from 'passport'
import jwtStrategy from './jwt/strategy'
import jwt from 'jsonwebtoken'
dotenv.config() dotenv.config()
// check env and warn // check env and warn
@ -42,20 +39,14 @@ schema = applyScalars(applyDirectives(schema))
const createServer = (options) => { const createServer = (options) => {
const defaults = { const defaults = {
context: async (req) => { context: async ({ request }) => {
const payload = { const authorizationHeader = request.headers.authorization || ''
const user = await decode(driver, authorizationHeader)
return {
driver, driver,
user: null, user,
req: req.request 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, schema: schema,
debug: debug, debug: debug,
@ -65,11 +56,7 @@ const createServer = (options) => {
} }
const server = new GraphQLServer(Object.assign({}, defaults, 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.use(express.static('public'))
server.express.post('/graphql', passport.authenticate(['jwt'], { session: false }))
return server return server
} }

View File

@ -4412,7 +4412,7 @@ jsonify@~0.0.0:
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=
jsonwebtoken@^8.2.0, jsonwebtoken@~8.5.0: jsonwebtoken@~8.5.0:
version "8.5.0" version "8.5.0"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#ebd0ca2a69797816e1c5af65b6c759787252947e" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#ebd0ca2a69797816e1c5af65b6c759787252947e"
integrity sha512-IqEycp0znWHNA11TpYi77bVgyBO/pGESDh7Ajhas+u0ttkGkKYIIAjniL4Bw5+oVejVF+SYkaI7XKfwCCyeTuA== integrity sha512-IqEycp0znWHNA11TpYi77bVgyBO/pGESDh7Ajhas+u0ttkGkKYIIAjniL4Bw5+oVejVF+SYkaI7XKfwCCyeTuA==
@ -5412,27 +5412,6 @@ pascalcase@^0.1.1:
resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= 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: path-dirname@^1.0.0:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
@ -5487,11 +5466,6 @@ pathval@^1.1.0:
resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= 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: performance-now@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"