Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LIFF <> GraphQL server communication #178

Merged
merged 17 commits into from May 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.sample
Expand Up @@ -10,8 +10,13 @@ PORT=5001

# Rollbar error logging
ROLLBAR_ENV=localhost

# Rollbar post_server_item, for chatbot & graphql
ROLLBAR_TOKEN=

# Rollbar post_client_item, for LIFF
ROLLBAR_CLIENT_TOKEN=

# This should match rumors-api's RUMORS_LINE_BOT_SECRET
APP_SECRET=CHANGE_ME

Expand All @@ -33,7 +38,6 @@ USERID_BLACKLIST=

# LIFF URL for reason input
LIFF_URL=line://app/1631030389-xb5mnDdN
LIFF_CORS_ORIGIN=https://cofacts.github.io

# Facebook App ID for share dialog
FACEBOOK_APP_ID=719656818195367
Expand Down
2 changes: 1 addition & 1 deletion .eslintrc.js
Expand Up @@ -6,7 +6,7 @@ module.exports = {
'plugin:import/warnings',
'prettier',
],
env: {node: true, es6: true, jest: true, browser: true},
env: {node: true, es6: true, jest: true},
plugins: [
'prettier',
],
Expand Down
28 changes: 25 additions & 3 deletions README.md
Expand Up @@ -75,14 +75,36 @@ We recommend using [ngrok configuration file](https://ngrok.com/docs#config) to

We are using LIFF to collect user's reason when submitting article & negative feedbacks.

It is accessible under `/liff` of dev server (http://localhost:5001) or production chatbot server.
If you don't need to develop LIFF, you can directly use `LIFF_URL` provided in `.env.sample`.

If you want to modify LIFF, you may need to follow these steps:

#### Creating your own LIFF app

To create LIFF apps, please follow instructions under [official document](https://developers.line.biz/en/docs/liff/getting-started/), which involves
- Creating a LINE login channel
- Select `chat_message.write` in scope (for LIFF to send messages)
After acquiring LIFF URL, place it in `.env` as `LIFF_URL`.
- Set `Endpoint URL` to start with your chabbot endpoint, and add `/liff/index.html` as postfix.

#### Developing LIFF

To develop LIFF, after `npm run dev`, it is accessible under `/liff/index.html` of dev server (http://localhost:5001) or production chatbot server.

In development mode, it spins a webpack-dev-server on `localhost:<LIFF_DEV_PORT>` (default to `8080`),
and `/liff` of chatbot server proxies all requests to the webpack-dev-server.

In production, LIFF files are compiled to `/liff` directory and served as static files by the chatbot server.
A tip to develop LIFF in browser is:
1. trigger LIFF in the mobile phone
2. Get LIFF token from dev server proxy log (something like `GET /liff/index.html?p=<page>&token=<jwt> proxy to -> ...`)
3. Visit `https://<your-dev-chatbot.ngrok.io>/liff/index.html?p=<page>&token=<jwt>` in desktop browser for easier development

`liff.init()` would still work in desktop browser, so that the app renders, enabling us to debug web layouts on desktop.
`liff.sendMessages()` would not work, though.

#### How LIFF is deployed on production

To connect with chatbot, some setup on [LINE developer console](https://developers.line.biz/console/) are required.
On production, LIFF files are compiled to `/liff` directory and served as static files by the chatbot server.

### Process image message(using Tesseract-OCR)

Expand Down
1 change: 0 additions & 1 deletion package.json
Expand Up @@ -37,7 +37,6 @@
},
"homepage": "https://github.com/cofacts/rumors-line-bot#readme",
"dependencies": {
"@koa/cors": "^2.2.3",
"@line/bot-sdk": "^6.8.4",
"apollo-server-koa": "^2.11.0",
"googleapis": "^36.0.0",
Expand Down
6 changes: 3 additions & 3 deletions src/graphql/__tests__/context.js
Expand Up @@ -25,8 +25,8 @@ it('Returns user context', async () => {
{
context {
state
issuedAt
data {
sessionId
searchedText
}
}
Expand All @@ -37,8 +37,8 @@ it('Returns user context', async () => {
userId: 'U12345678',
userContext: {
state: 'CHOOSING_ARTICLE',
issuedAt: 1586013070089,
data: {
sessionId: 1586013070089,
searchedText: 'Foo',
},
},
Expand All @@ -50,8 +50,8 @@ it('Returns user context', async () => {
"context": Object {
"data": Object {
"searchedText": "Foo",
"sessionId": "1586013070089",
},
"issuedAt": 1586013070089,
"state": "CHOOSING_ARTICLE",
},
},
Expand Down
95 changes: 78 additions & 17 deletions src/graphql/__tests__/index.js
@@ -1,6 +1,7 @@
jest.mock('src/lib/redisClient');

import { getContext } from '../';
import { sign } from 'src/lib/jwt';
import redis from 'src/lib/redisClient';

beforeEach(() => {
Expand All @@ -14,49 +15,107 @@ describe('getContext', () => {
expect(context).toMatchInlineSnapshot(`
Object {
"userContext": null,
"userId": "",
"userId": null,
}
`);
});

it('generates null context for wrong credentials', async () => {
redis.get.mockImplementationOnce(() => ({
nonce: 'correctpass',
data: {
sessionId: 'correct-session-id',
},
}));

const context = await getContext({
ctx: {
req: {
headers: {
authorization: `basic ${Buffer.from('user1:wrongpass').toString(
'base64'
)}`,
const jwtWithWrongSession = sign({
sessionId: 'wrong-session-id',
sub: 'user1',
exp: Date.now() / 1000 + 10, // future timestamp
});

expect(
await getContext({
ctx: {
req: {
headers: {
authorization: `Bearer ${jwtWithWrongSession}`,
},
},
},
},
})
).toMatchInlineSnapshot(`
Object {
"userContext": null,
"userId": "user1",
}
`);

const jwtWithExpiredSession = sign({
sessionId: 'correct-session-id',
sub: 'user1',
exp: Date.now() / 1000 - 10, // past timestamp
});

expect(context).toMatchInlineSnapshot(`
// Expired JWTs will fail verification, thus its userId is not resolved either
expect(
await getContext({
ctx: {
req: {
headers: {
authorization: `Bearer ${jwtWithExpiredSession}`,
},
},
},
})
).toMatchInlineSnapshot(`
Object {
"userContext": null,
"userId": "user1",
"userId": null,
}
`);

const jwtWithNoSub = sign({
sessionId: 'correct-session-id',
exp: Date.now() / 1000 + 10, // future timestamp
});

expect(
await getContext({
ctx: {
req: {
headers: {
authorization: `Bearer ${jwtWithNoSub}`,
},
},
},
})
).toMatchInlineSnapshot(`
Object {
"userContext": null,
"userId": null,
}
`);
});

it('reads userContext for logged-in requests', async () => {
redis.get.mockImplementationOnce(() => ({
nonce: 'correctpass',
data: {
sessionId: 'correct-session-id',
},
foo: 'bar',
}));

const jwt = sign({
sessionId: 'correct-session-id',
sub: 'user1',
exp: Date.now() / 1000 + 10, // future timestamp
});

const context = await getContext({
ctx: {
req: {
headers: {
authorization: `basic ${Buffer.from('user1:correctpass').toString(
'base64'
)}`,
authorization: `Bearer ${jwt}`,
},
},
},
Expand All @@ -65,8 +124,10 @@ describe('getContext', () => {
expect(context).toMatchInlineSnapshot(`
Object {
"userContext": Object {
"data": Object {
"sessionId": "correct-session-id",
},
"foo": "bar",
"nonce": "correctpass",
},
"userId": "user1",
}
Expand Down
41 changes: 26 additions & 15 deletions src/graphql/index.js
Expand Up @@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { ApolloServer, makeExecutableSchema } from 'apollo-server-koa';
import redis from 'src/lib/redisClient';
import { verify, read } from 'src/lib/jwt';

export const schema = makeExecutableSchema({
typeDefs: fs.readFileSync(path.join(__dirname, `./typeDefs.graphql`), {
Expand All @@ -25,29 +26,39 @@ export const schema = makeExecutableSchema({
}, {}),
});

// Empty context for non-auth public APIs
const EMPTY_CONTEXT = {
userId: null,
userContext: null,
};

/**
* @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;
}
const jwt = (req.headers.authorization || '').replace(/^Bearer /, '');
if (!jwt || !verify(jwt)) {
return EMPTY_CONTEXT;
}

const parsed = read(jwt);

if (!parsed || !parsed.sub) {
return EMPTY_CONTEXT;
}

const context = await redis.get(parsed.sub);

return {
userId,
userContext,
userId: parsed.sub,
userContext:
context &&
context.data &&
parsed.sessionId &&
context.data.sessionId === parsed.sessionId
? context
: null,
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/graphql/typeDefs.graphql
Expand Up @@ -145,11 +145,11 @@ enum MessagingAPIInsightStatus {

type UserContext {
state: String
issuedAt: Float
data: StateData
}

type StateData {
sessionId: String
searchedText: String
selectedArticleId: ID
selectedArticleText: String
Expand Down
22 changes: 5 additions & 17 deletions src/index.js
@@ -1,6 +1,5 @@
import Koa from 'koa';
import Router from 'koa-router';
import cors from '@koa/cors';
import serve from 'koa-static-server';
import path from 'path';

Expand All @@ -26,26 +25,15 @@ router.get('/', ctx => {
ctx.body = JSON.stringify({ version });
});

// TODO: legacy route, remove this
router.get(
'/context/:userId',
cors({
origin: process.env.LIFF_CORS_ORIGIN,
}),
async ctx => {
const { state, issuedAt } = (await redis.get(ctx.params.userId)) || {};

ctx.body = {
state,
issuedAt,
};
}
);
router.use('/callback', webhookRouter.routes(), webhookRouter.allowedMethods());

if (process.env.NODE_ENV === 'production') {
app.use(
serve({ rootDir: path.join(__dirname, '../liff'), rootPath: '/liff' })
serve({
rootDir: path.join(__dirname, '../liff'),
rootPath: '/liff',
maxage: 31536000 * 1000, // https://stackoverflow.com/a/7071880/1582110
})
);
} else {
app.use(
Expand Down
15 changes: 15 additions & 0 deletions src/liff/.eslintrc.js
@@ -0,0 +1,15 @@
module.exports = {
env: {
browser: true,
node: false,
jest: false,
},
globals: {
// global scripts include in index.html
rollbar: 'readonly',
liff: 'readonly',

// Define plugin
LIFF_ID: 'readonly',
}
};