From df4d93983e36336d6c58edb9fca32f27efb19dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Maniaci?= Date: Fri, 15 Apr 2022 15:15:14 +0200 Subject: [PATCH 1/4] git: ajout d'un .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e69de29..b512c09 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file From 9482afab97a2fdefc063383977c42b2cd397401c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Maniaci?= Date: Fri, 15 Apr 2022 15:15:33 +0200 Subject: [PATCH 2/4] docker: ajout d'un dockerignore --- .dockerignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3fb8b13 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +.github +node_modules \ No newline at end of file From 2061580d099b1683d7284703cc8c3181f2657109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Maniaci?= Date: Wed, 20 Apr 2022 09:08:06 +0200 Subject: [PATCH 3/4] docker: tidy up - don't install every package globally; - don't reinstall them through Docker-Compose; - remove obsolete service; - remove ignored env variable. --- Dockerfile | 15 ++++++--------- docker-compose.yml | 7 +------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index c5130c6..895c7af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,13 @@ FROM node:lts -WORKDIR /app +RUN npm i -g nodemon typescript -RUN chown node:node /app +USER node -COPY . . +WORKDIR /app -RUN npm install -g nodemon -RUN npm install typescript -g -RUN npm install -g +COPY --chown=node . . -USER node +RUN npm i -EXPOSE 8100 -RUN npm run dev \ No newline at end of file +CMD npm run migrate && npm run dev diff --git a/docker-compose.yml b/docker-compose.yml index e11bf72..00001ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,17 +9,12 @@ services: - "5432:5432" web: build: . - command: bash -c "echo 'first npm install can be a bit long'; npm install; npm run migrate; npm run dev" depends_on: - db - - maildev - env_file: - - .env environment: DATABASE_URL: postgres://bbbanalytics:bbbanalytics@db:5432/bbbanalytics - SECURE: "false" HOSTNAME: localhost - JWT_SECURITY_SALT: "SecretThatShouldChangedInProduction" + JWT_SECURITY_SALT: SecretThatShouldChangedInProduction ports: - "8100:8100" - "9229:9229" From 46e7b7aa9c77e7f50df8704f96036136c809275c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Maniaci?= Date: Wed, 20 Apr 2022 13:39:58 +0200 Subject: [PATCH 4/4] jwt: allow multiple issuers to post We identify our callers with the `tag` query parameter: /v1/post_events?tag=dinum To allow multiple callers to post to our app without relying on a single secret (the current JWT_SECURITY_SALT), introduce a new JWT_TAGS_AND_SALTS which contains issuer:salt pairs in a comma-separated string. On an incoming request, find the matching issuer and sign against their secret, throwing an error if the issuer isn't found. Note: if the JWT_TAGS_AND_SALTS isn't set, fallback to the previous implementation with the single JWT_SECURITY_SALT secret). --- docker-compose.yml | 1 + src/config.ts | 1 + src/controllers/indexController.ts | 4 +- src/index.ts | 31 +++++- tests/test-event.js | 173 ++++++++++++++++++----------- 5 files changed, 144 insertions(+), 66 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 00001ba..a74412f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: DATABASE_URL: postgres://bbbanalytics:bbbanalytics@db:5432/bbbanalytics HOSTNAME: localhost JWT_SECURITY_SALT: SecretThatShouldChangedInProduction + JWT_TAGS_AND_SALTS: dinum:SecretThatShouldChangedInProduction ports: - "8100:8100" - "9229:9229" diff --git a/src/config.ts b/src/config.ts index 28fcaa2..82ba2f7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ module.exports = { secret: process.env.JWT_SECURITY_SALT, + tagsAndSalts: process.env.JWT_TAGS_AND_SALTS, host: process.env.HOSTNAME, port: process.env.PORT || 8100, }; diff --git a/src/controllers/indexController.ts b/src/controllers/indexController.ts index 99dbe6c..09aba5e 100644 --- a/src/controllers/indexController.ts +++ b/src/controllers/indexController.ts @@ -76,7 +76,7 @@ module.exports.postEvents = async function (req, res) { catch (err) { return res.status(500).send(`${err}`); } - return res.json({ + return res.status(200).json({ status: 'ok' - }, 200) + }); }; diff --git a/src/index.ts b/src/index.ts index 98c46bf..3b94ac5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,12 +14,41 @@ const app = express(); app.use(expressSanitizer()); app.use(bodyParser.urlencoded({ extended: false })); +const getSecretForTag = function(req, payload, done){ + const { tag } = req.query; + + // fallback to the default JWT_SECURITY_SALT if the new variable isn't set up + if (!config.tagsAndSalts) { + done(null, config.secret); + return; + } + + const issuer = config + .tagsAndSalts + .split(",") + .map(i => i.split(":")) + .find(([t]) => t === tag); + + if (!issuer) throw new Error(`No matching issuer was found for tag '${tag}'.`) + + const [, secret] = issuer; + + done(null, secret); +} + app.use( expressJWT({ - secret: config.secret, + secret: getSecretForTag, algorithms: ['HS512'], typ: "JWT", }), + function(err, req, res, next) { + if (err) { + res.status(403).send('No matching issuer was found'); + } else { + next(); + } + }, ); app.use(express.json()); diff --git a/tests/test-event.js b/tests/test-event.js index 5dbf20a..94a89d8 100644 --- a/tests/test-event.js +++ b/tests/test-event.js @@ -5,75 +5,122 @@ const config = require('../src/config'); const jwt = require('jsonwebtoken'); const apiResponse = { - "version": "1.0", - "meeting_id": "meeting-persistent-3--c308049", - "internal_meeting_id": "3e677e88dl7698", - "data": { - "metadata": { - "analytics_callback_url": "https://webhook.site/f519038c-b956-4fa3-9b5c-148e8df09b47", - "is_breakout": "false", - "meeting_name": "Arawa3" + version: '1.0', + meeting_id: 'meeting-persistent-c308049', + internal_meeting_id: '3e677e88dl7698', + data: { + metadata: { + analytics_callback_url: + 'https://webhook.site/f519038c-b956-4fa3-9b5c-148e8df09b47', + is_breakout: 'false', + meeting_name: 'Arawa3', }, - "duration": 20, - "start": "2021-06-27 18:52:12 +0200", - "finish": "2021-06-27 18:52:32 +0200", - "attendees": [ + duration: 20, + start: '2021-06-27 18:52:12 +0200', + finish: '2021-06-27 18:52:32 +0200', + attendees: [ { - "ext_user_id": "w_hll5o9o", - "name": "Lucas Pla", - "moderator": true, - "joins": [ - "2021-06-23 18:52:21 +0200" - ], - "leaves": [ - "2021-06-23 18:52:32 +0200" - ], - "duration": 11, - "recent_talking_time": "", - "engagement": { - "chats": 0, - "talks": 0, - "raisehand": 0, - "emojis": 0, - "poll_votes": 0, - "talk_time": 0 - } - } + ext_user_id: 'w_hll5o9o', + name: 'Lucas Pla', + moderator: true, + joins: ['2021-06-23 18:52:21 +0200'], + leaves: ['2021-06-23 18:52:32 +0200'], + duration: 11, + recent_talking_time: '', + engagement: { + chats: 0, + talks: 0, + raisehand: 0, + emojis: 0, + poll_votes: 0, + talk_time: 0, + }, + }, ], - "files": [ - "default.pdf" - ], - "polls": [], -}} + files: ['default.pdf'], + polls: [], + }, +}; + +const signForSecret = function (secret) { + return jwt.sign( + { + exp: Math.floor(Date.now() / 1000) + 60 * 60, + }, + secret, + { + algorithm: 'HS512', + header: { + typ: 'JWT', + }, + } + ); +}; + +const sendResponseForTag = async function (tag, secret) { + return await chai + .request(app) + .post(`/v1/post_events?tag=${tag}`) + .set('content-type', 'application/json') + .set('Authorization', 'Bearer ' + signForSecret(secret)) + .set('user-agent', 'BigBlueButton Analytics Callback') + .send(apiResponse); +}; describe('Meetings', async () => { + beforeEach(async () => { + config.tagsAndSalts = 'dinum:somefancysecret'; + + await knex.raw('truncate table meetings cascade'); + }); + + describe('POST /v1/post_events', async () => { + describe("when the JWT_TAGS_AND_SALTS variable isn't set", () => { + beforeEach(() => { + config.tagsAndSalts = null; + }); + + it('defaults to JWT_SECURITY_SALT', async () => { + const res = await sendResponseForTag('some org', config.secret); + + res.should.have.status(200); + }); + }); + + describe('when the issuer is unknown', () => { + it('throws an error', async () => { + const res = await sendResponseForTag('some org', 'somefancysecret'); + + res.should.have.status(403); + }); + }); + + describe('when the issuer is known but the secret is wrong', () => { + it('throws an error', async () => { + const res = await sendResponseForTag('dinum', 'some other secret'); + + res.should.have.status(403); + }); + }); + + describe('when the issuer is known and the secret is correct', () => { + it('should create a new entry in meetings', async () => { + const res = await sendResponseForTag('dinum', 'somefancysecret'); + + res.should.have.status(200); + + const stats = await knex('meetings') + .orderBy('created_at', 'desc') + .first(); - describe('POST /v1/post_events unauthenticated', async () => { - - it('should create a new entry in meetings', async () => { - let res - try { - const token = jwt.sign({ - exp: Math.floor(Date.now() / 1000) + (60 * 60), - }, config.secret, { algorithm: 'HS512', header: { - typ: 'JWT' - }}) - res = await chai.request(app) - .post('/v1/post_events?tag=dinum') - .set('content-type', 'application/json') - .set("Authorization", "Bearer " + token) - .set('user-agent', 'BigBlueButton Analytics Callback') - .send(apiResponse) - } catch (e) { - console.log(e) - } - res.should.have.status(200); - const stats = await knex('meetings').orderBy('created_at', 'desc').first() - stats.duration.should.be.equal(apiResponse.data.duration) - stats.moderator_count.should.be.equal(1) - stats.internal_meeting_id.should.be.equal(apiResponse.internal_meeting_id) - stats.tag.should.be.equal('dinum') - stats.weekday.should.be.equal(0) + stats.duration.should.be.equal(apiResponse.data.duration); + stats.moderator_count.should.be.equal(1); + stats.internal_meeting_id.should.be.equal( + apiResponse.internal_meeting_id + ); + stats.tag.should.be.equal('dinum'); + stats.weekday.should.be.equal(0); }); + }); }); });