From 080040ad2857b3c1d410465f4ccb5a755f48caf7 Mon Sep 17 00:00:00 2001 From: MrOrz Date: Sat, 4 Apr 2020 22:56:19 +0800 Subject: [PATCH 1/7] GraphQL authentication & context population --- src/graphql/__tests__/index.js | 72 ++++++++++++++++++++++++++++++++++ src/graphql/index.js | 39 +++++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/graphql/__tests__/index.js diff --git a/src/graphql/__tests__/index.js b/src/graphql/__tests__/index.js new file mode 100644 index 00000000..835858f9 --- /dev/null +++ b/src/graphql/__tests__/index.js @@ -0,0 +1,72 @@ +jest.mock('src/lib/redisClient'); + +import { getContext } from '../'; +import redis from 'src/lib/redisClient'; + +beforeEach(() => { + redis.get.mockClear(); +}); + +describe('getContext', () => { + it('generates null context for anonymous requests', async () => { + const context = await getContext({ ctx: { req: { headers: {} } } }); + + expect(context).toMatchInlineSnapshot(` + Object { + "userContext": null, + } + `); + }); + + it('generates null context for wrong credentials', async () => { + redis.get.mockImplementationOnce(() => ({ + nonce: 'correctpass', + })); + + const context = await getContext({ + ctx: { + req: { + headers: { + authorization: `basic ${Buffer.from('user1:wrongpass').toString( + 'base64' + )}`, + }, + }, + }, + }); + + expect(context).toMatchInlineSnapshot(` + Object { + "userContext": null, + } + `); + }); + + it('reads userContext for logged-in requests', async () => { + redis.get.mockImplementationOnce(() => ({ + nonce: 'correctpass', + foo: 'bar', + })); + + const context = await getContext({ + ctx: { + req: { + headers: { + authorization: `basic ${Buffer.from('user1:correctpass').toString( + 'base64' + )}`, + }, + }, + }, + }); + + expect(context).toMatchInlineSnapshot(` + Object { + "userContext": Object { + "foo": "bar", + "nonce": "correctpass", + }, + } + `); + }); +}); diff --git a/src/graphql/index.js b/src/graphql/index.js index 7a0f4e35..2a7c17ca 100644 --- a/src/graphql/index.js +++ b/src/graphql/index.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { ApolloServer, makeExecutableSchema } from 'apollo-server-koa'; +import redis from 'src/lib/redisClient'; export const schema = makeExecutableSchema({ typeDefs: fs.readFileSync(path.join(__dirname, `./typeDefs.graphql`), { @@ -14,8 +15,44 @@ export const schema = makeExecutableSchema({ ] = require(`./resolvers/${fileName}`).default; return resolvers; }, {}), + schemaDirectives: fs + .readdirSync(path.join(__dirname, 'directives')) + .reduce((directives, fileName) => { + directives[ + fileName.replace(/\.js$/, '') + ] = require(`./directives/${fileName}`).default; + return directives; + }, {}), }); -const server = new ApolloServer({ schema }); +/** + * @param {{ctx: Koa.Context}} + * @returns {object} + */ +export async function getContext({ ctx: { req } }) { + const [userId, nonce] = Buffer.from( + (req.headers.authorization || '').replace(/^basic /, ''), + 'base64' + ) + .toString() + .split(':'); + + let userContext = null; + if (userId && nonce) { + const context = await redis.get(userId); + if (context && context.nonce === nonce) { + userContext = context; + } + } + + return { + userContext, + }; +} + +const server = new ApolloServer({ + schema, + context: getContext, +}); export default server.getMiddleware(); From 30c8a3e52d0715a56eb6eace1484d834eb29cfe4 Mon Sep 17 00:00:00 2001 From: MrOrz Date: Sat, 4 Apr 2020 23:21:33 +0800 Subject: [PATCH 2/7] Implement authentication, @auth directive and context field --- package.json | 1 + src/graphql/__tests__/context.js | 59 ++++++++++++++++++++++++++++++++ src/graphql/directives/auth.js | 23 +++++++++++++ src/graphql/resolvers/Query.js | 4 +++ src/graphql/typeDefs.graphql | 18 ++++++++++ 5 files changed, 105 insertions(+) create mode 100644 src/graphql/__tests__/context.js create mode 100644 src/graphql/directives/auth.js diff --git a/package.json b/package.json index 3ac3e33a..2dd4eefc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "googleapis": "^36.0.0", "grapheme-splitter": "^1.0.4", "graphql": "^15.0.0", + "graphql-tools": "^4.0.7", "koa": "^2.5.0", "koa-router": "^7.4.0", "koa-static": "^5.0.0", diff --git a/src/graphql/__tests__/context.js b/src/graphql/__tests__/context.js new file mode 100644 index 00000000..24400926 --- /dev/null +++ b/src/graphql/__tests__/context.js @@ -0,0 +1,59 @@ +import { gql } from "../testUtils"; + +it("context rejects anonymous users", async () => { + const result = await gql` + { + context { + state + } + } + `(); + expect(result).toMatchInlineSnapshot(` + Object { + "data": Object { + "context": null, + }, + "errors": Array [ + [GraphQLError: Invalid authentication header], + ], + } + `); +}); + +it("Returns user context", async () => { + const result = await gql` + { + context { + state + issuedAt + data { + searchedText + } + } + } + `( + {}, + { + userContext: { + state: "CHOOSING_ARTICLE", + issuedAt: 1586013070089, + data: { + searchedText: "Foo" + } + } + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "data": Object { + "context": Object { + "data": Object { + "searchedText": "Foo", + }, + "issuedAt": 1586013070089, + "state": "CHOOSING_ARTICLE", + }, + }, + } + `); +}); diff --git a/src/graphql/directives/auth.js b/src/graphql/directives/auth.js new file mode 100644 index 00000000..53b0281d --- /dev/null +++ b/src/graphql/directives/auth.js @@ -0,0 +1,23 @@ +import { AuthenticationError } from 'apollo-server-koa'; +import { SchemaDirectiveVisitor } from 'graphql-tools'; + +/** + * When field with @auth is accessed, make sure the user is logged in (i.e. `userContext` in context). + */ +class AuthDirective extends SchemaDirectiveVisitor { + visitFieldDefinition(field) { + const { resolve } = field; + + field.resolve = (...args) => { + const [, , context] = args; + + if (!context.userContext) { + throw new AuthenticationError('Invalid authentication header'); + } + + return resolve(...args); + }; + } +} + +export default AuthDirective; diff --git a/src/graphql/resolvers/Query.js b/src/graphql/resolvers/Query.js index 16e687f8..f2d436bf 100644 --- a/src/graphql/resolvers/Query.js +++ b/src/graphql/resolvers/Query.js @@ -3,4 +3,8 @@ export default { // Resolvers in next level return {}; }, + + context(root, args, context) { + return context.userContext; + }, }; diff --git a/src/graphql/typeDefs.graphql b/src/graphql/typeDefs.graphql index 1336dd6e..ac2c709e 100644 --- a/src/graphql/typeDefs.graphql +++ b/src/graphql/typeDefs.graphql @@ -1,5 +1,10 @@ +directive @auth on FIELD_DEFINITION + type Query { insights: MessagingAPIInsight + + # Current user's chatbot context + context: UserContext @auth } type MessagingAPIInsight { @@ -123,3 +128,16 @@ enum MessagingAPIInsightStatus { UNREADY OUT_OF_SERVICE } + +type UserContext { + state: String + issuedAt: Float + data: StateData +} + +type StateData { + searchedText: String + selectedArticleId: ID + selectedArticleText: String + selectedReplyId: ID +} From da8ccf6e33848f919e4459fbe91d3b7391052851 Mon Sep 17 00:00:00 2001 From: MrOrz Date: Sat, 4 Apr 2020 23:43:41 +0800 Subject: [PATCH 3/7] Redundent jest.mock() as manual mocks for node modules will always load. Ref: https://jestjs.io/docs/en/manual-mocks#mocking-user-modules --- src/graphql/__tests__/insights.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/graphql/__tests__/insights.js b/src/graphql/__tests__/insights.js index d0bddff6..8f726982 100644 --- a/src/graphql/__tests__/insights.js +++ b/src/graphql/__tests__/insights.js @@ -1,4 +1,3 @@ -jest.mock('@line/bot-sdk'); import { mockedGetNumberOfMessageDeliveries, mockedGetNumberOfFollowers, From f24191c880cf28a4fb879d64a1f440ea76071f14 Mon Sep 17 00:00:00 2001 From: MrOrz Date: Sat, 4 Apr 2020 23:44:30 +0800 Subject: [PATCH 4/7] Add "manual" (auto) mock for redisClient so that all redisClient deps are mocked (such as in graphql/__tests__/insights) --- src/lib/__mocks__/redisClient.js | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/lib/__mocks__/redisClient.js diff --git a/src/lib/__mocks__/redisClient.js b/src/lib/__mocks__/redisClient.js new file mode 100644 index 00000000..1c9a9631 --- /dev/null +++ b/src/lib/__mocks__/redisClient.js @@ -0,0 +1,2 @@ +// Automatically mocks all existence of redisClient in unit tests +export default jest.genMockFromModule('../redisClient').default; From 8dfa0c12afe6b0f6c8b49b798e4474c955450a89 Mon Sep 17 00:00:00 2001 From: MrOrz Date: Sat, 4 Apr 2020 23:46:54 +0800 Subject: [PATCH 5/7] Prettier fix --- src/graphql/__tests__/context.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/graphql/__tests__/context.js b/src/graphql/__tests__/context.js index 24400926..60d12c32 100644 --- a/src/graphql/__tests__/context.js +++ b/src/graphql/__tests__/context.js @@ -1,6 +1,6 @@ -import { gql } from "../testUtils"; +import { gql } from '../testUtils'; -it("context rejects anonymous users", async () => { +it('context rejects anonymous users', async () => { const result = await gql` { context { @@ -20,7 +20,7 @@ it("context rejects anonymous users", async () => { `); }); -it("Returns user context", async () => { +it('Returns user context', async () => { const result = await gql` { context { @@ -35,12 +35,12 @@ it("Returns user context", async () => { {}, { userContext: { - state: "CHOOSING_ARTICLE", + state: 'CHOOSING_ARTICLE', issuedAt: 1586013070089, data: { - searchedText: "Foo" - } - } + searchedText: 'Foo', + }, + }, } ); expect(result).toMatchInlineSnapshot(` From 5295edb20990e761d51773539fab7903d37fc657 Mon Sep 17 00:00:00 2001 From: MrOrz Date: Sun, 5 Apr 2020 00:23:10 +0800 Subject: [PATCH 6/7] Avoid calling the real redisClient in unit tests Whenever there will be a dependency to redisClient, call jest.mock(). --- src/graphql/testUtils.js | 3 +++ src/lib/__mocks__/redisClient.js | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/graphql/testUtils.js b/src/graphql/testUtils.js index b24bbe14..e60b50d3 100644 --- a/src/graphql/testUtils.js +++ b/src/graphql/testUtils.js @@ -1,3 +1,6 @@ +// ./index.js contains imports to redisClient, which should be mocked in unit tests. +jest.mock('src/lib/redisClient'); + import { graphql } from 'graphql'; import { schema } from './'; diff --git a/src/lib/__mocks__/redisClient.js b/src/lib/__mocks__/redisClient.js index 1c9a9631..5592ca54 100644 --- a/src/lib/__mocks__/redisClient.js +++ b/src/lib/__mocks__/redisClient.js @@ -1,2 +1,3 @@ -// Automatically mocks all existence of redisClient in unit tests -export default jest.genMockFromModule('../redisClient').default; +export default { + get: jest.fn(), +}; From 97b491de65d9a67c9c92bbdb4ac4118ff8f8359f Mon Sep 17 00:00:00 2001 From: MrOrz Date: Sun, 5 Apr 2020 00:51:31 +0800 Subject: [PATCH 7/7] Add userId in GraphQL context after authentication --- src/graphql/__tests__/context.js | 1 + src/graphql/__tests__/index.js | 3 +++ src/graphql/directives/auth.js | 2 +- src/graphql/index.js | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/graphql/__tests__/context.js b/src/graphql/__tests__/context.js index 60d12c32..6b56ad84 100644 --- a/src/graphql/__tests__/context.js +++ b/src/graphql/__tests__/context.js @@ -34,6 +34,7 @@ it('Returns user context', async () => { `( {}, { + userId: 'U12345678', userContext: { state: 'CHOOSING_ARTICLE', issuedAt: 1586013070089, diff --git a/src/graphql/__tests__/index.js b/src/graphql/__tests__/index.js index 835858f9..b470499f 100644 --- a/src/graphql/__tests__/index.js +++ b/src/graphql/__tests__/index.js @@ -14,6 +14,7 @@ describe('getContext', () => { expect(context).toMatchInlineSnapshot(` Object { "userContext": null, + "userId": "", } `); }); @@ -38,6 +39,7 @@ describe('getContext', () => { expect(context).toMatchInlineSnapshot(` Object { "userContext": null, + "userId": "user1", } `); }); @@ -66,6 +68,7 @@ describe('getContext', () => { "foo": "bar", "nonce": "correctpass", }, + "userId": "user1", } `); }); diff --git a/src/graphql/directives/auth.js b/src/graphql/directives/auth.js index 53b0281d..9da28efa 100644 --- a/src/graphql/directives/auth.js +++ b/src/graphql/directives/auth.js @@ -11,7 +11,7 @@ class AuthDirective extends SchemaDirectiveVisitor { field.resolve = (...args) => { const [, , context] = args; - if (!context.userContext) { + if (!context.userId || !context.userContext) { throw new AuthenticationError('Invalid authentication header'); } diff --git a/src/graphql/index.js b/src/graphql/index.js index 2a7c17ca..fc2460a8 100644 --- a/src/graphql/index.js +++ b/src/graphql/index.js @@ -46,6 +46,7 @@ export async function getContext({ ctx: { req } }) { } return { + userId, userContext, }; }