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..6b56ad84 --- /dev/null +++ b/src/graphql/__tests__/context.js @@ -0,0 +1,60 @@ +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 + } + } + } + `( + {}, + { + userId: 'U12345678', + 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/__tests__/index.js b/src/graphql/__tests__/index.js new file mode 100644 index 00000000..b470499f --- /dev/null +++ b/src/graphql/__tests__/index.js @@ -0,0 +1,75 @@ +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, + "userId": "", + } + `); + }); + + 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, + "userId": "user1", + } + `); + }); + + 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", + }, + "userId": "user1", + } + `); + }); +}); 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, diff --git a/src/graphql/directives/auth.js b/src/graphql/directives/auth.js new file mode 100644 index 00000000..9da28efa --- /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.userId || !context.userContext) { + throw new AuthenticationError('Invalid authentication header'); + } + + return resolve(...args); + }; + } +} + +export default AuthDirective; diff --git a/src/graphql/index.js b/src/graphql/index.js index 7a0f4e35..fc2460a8 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,45 @@ 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 { + userId, + userContext, + }; +} + +const server = new ApolloServer({ + schema, + context: getContext, +}); export default server.getMiddleware(); 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/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/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 +} diff --git a/src/lib/__mocks__/redisClient.js b/src/lib/__mocks__/redisClient.js new file mode 100644 index 00000000..5592ca54 --- /dev/null +++ b/src/lib/__mocks__/redisClient.js @@ -0,0 +1,3 @@ +export default { + get: jest.fn(), +};