From b34be6bea4b936f1e7d6954690b7bc4a7c85de36 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Fri, 14 Jun 2024 12:31:59 +0300 Subject: [PATCH] Merge pull request #18653 from overleaf/ii-invite-token-create-hmac [web] Add HMAC tokens for project invitations GitOrigin-RevId: 02fa01e24790c9a87f57ff9346f5346658d4dd46 --- .../CollaboratorsInviteHandler.js | 3 + .../CollaboratorsInviteHelper.js | 11 +++ services/web/app/src/models/ProjectInvite.js | 1 + ...08_add_token_hmac_project_invite_tokens.js | 24 ++++++ .../backfill_project_invites_token_hmac.js | 80 +++++++++++++++++++ .../CollaboratorsInviteControllerTests.js | 2 + .../CollaboratorsInviteHandlerTests.js | 12 +++ .../CollaboratorsInviteHelperTests.js | 21 +++++ 8 files changed, 154 insertions(+) create mode 100644 services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.js create mode 100644 services/web/migrations/20240524135408_add_token_hmac_project_invite_tokens.js create mode 100644 services/web/scripts/backfill_project_invites_token_hmac.js create mode 100644 services/web/test/unit/src/Collaborators/CollaboratorsInviteHelperTests.js diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.js b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.js index 65f160a4f5..85ad9d9d52 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.js @@ -3,6 +3,7 @@ const { ProjectInvite } = require('../../models/ProjectInvite') const logger = require('@overleaf/logger') const CollaboratorsEmailHandler = require('./CollaboratorsEmailHandler') const CollaboratorsHandler = require('./CollaboratorsHandler') +const CollaboratorsInviteHelper = require('./CollaboratorsInviteHelper') const UserGetter = require('../User/UserGetter') const ProjectGetter = require('../Project/ProjectGetter') const Crypto = require('crypto') @@ -83,9 +84,11 @@ const CollaboratorsInviteHandler = { ) const buffer = await randomBytes(24) const token = buffer.toString('hex') + const tokenHmac = CollaboratorsInviteHelper.hashInviteToken(token) let invite = new ProjectInvite({ email, token, + tokenHmac, sendingUserId: sendingUser._id, projectId, privileges, diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.js b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.js new file mode 100644 index 0000000000..c03471dbdf --- /dev/null +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.js @@ -0,0 +1,11 @@ +const Crypto = require('crypto') + +function hashInviteToken(token) { + return Crypto.createHmac('sha256', 'overleaf-token-invite') + .update(token) + .digest('hex') +} + +module.exports = { + hashInviteToken, +} diff --git a/services/web/app/src/models/ProjectInvite.js b/services/web/app/src/models/ProjectInvite.js index 33edd60cec..c7c0d44a79 100644 --- a/services/web/app/src/models/ProjectInvite.js +++ b/services/web/app/src/models/ProjectInvite.js @@ -15,6 +15,7 @@ const ProjectInviteSchema = new Schema( { email: String, token: String, + tokenHmac: String, sendingUserId: ObjectId, projectId: ObjectId, privileges: String, diff --git a/services/web/migrations/20240524135408_add_token_hmac_project_invite_tokens.js b/services/web/migrations/20240524135408_add_token_hmac_project_invite_tokens.js new file mode 100644 index 0000000000..99396cf8a3 --- /dev/null +++ b/services/web/migrations/20240524135408_add_token_hmac_project_invite_tokens.js @@ -0,0 +1,24 @@ +/* eslint-disable no-unused-vars */ + +const Helpers = require('./lib/helpers') +const runScript = require('../scripts/backfill_project_invites_token_hmac') + +exports.tags = ['server-ce', 'server-pro', 'saas'] + +const index = { + key: { + tokenHmac: 1, + }, + name: 'tokenHmac_1', +} + +exports.migrate = async client => { + const { db } = client + await Helpers.addIndexesToCollection(db.projectInvites, [index]) + await runScript(false) +} + +exports.rollback = async client => { + const { db } = client + await Helpers.dropIndexesFromCollection(db.projectInvites, [index]) +} diff --git a/services/web/scripts/backfill_project_invites_token_hmac.js b/services/web/scripts/backfill_project_invites_token_hmac.js new file mode 100644 index 0000000000..e5a662afcf --- /dev/null +++ b/services/web/scripts/backfill_project_invites_token_hmac.js @@ -0,0 +1,80 @@ +const { db, waitForDb } = require('../app/src/infrastructure/mongodb') +const { batchedUpdate } = require('./helpers/batchedUpdate') +const minimist = require('minimist') +const CollaboratorsInviteHelper = require('../app/src/Features/Collaborators/CollaboratorsInviteHelper') + +const argv = minimist(process.argv.slice(2), { + boolean: ['dry-run', 'help'], + default: { + 'dry-run': true, + }, +}) + +const DRY_RUN = argv['dry-run'] + +async function addTokenHmacField(DRY_RUN) { + const query = { tokenHmac: { $exists: false } } + + await batchedUpdate( + 'projectInvites', + query, + async invites => { + for (const invite of invites) { + console.log( + `=> Missing "tokenHmac" token in invitation: ${invite._id.toString()}` + ) + + if (DRY_RUN) { + console.log( + `=> DRY RUN - would add "tokenHmac" token to invitation ${invite._id.toString()}` + ) + continue + } + + const tokenHmac = CollaboratorsInviteHelper.hashInviteToken( + invite.token + ) + + await db.projectInvites.updateOne( + { _id: invite._id }, + { $set: { tokenHmac } } + ) + + console.log( + `=> Added "tokenHmac" token to invitation ${invite._id.toString()}` + ) + } + }, + { token: 1 } + ) +} + +async function main(DRY_RUN) { + await waitForDb() + await addTokenHmacField(DRY_RUN) +} + +module.exports = main + +if (require.main === module) { + if (argv.help || argv._.length > 1) { + console.error(`Usage: node scripts/backfill_project_invites_token_hmac.js + Adds a "tokenHmac" field (which is a hashed version of the token) to each project invite record. + + Options: + --dry-run finds invitations without HMAC token but does not do any updates + `) + + process.exit(1) + } + + main(DRY_RUN) + .then(() => { + console.error('Done') + process.exit(0) + }) + .catch(err => { + console.error(err) + process.exit(1) + }) +} diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.js index 4108e8ecf5..a14c722a23 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.js @@ -13,6 +13,7 @@ describe('CollaboratorsInviteController', function () { beforeEach(function () { this.projectId = 'project-id-123' this.token = 'some-opaque-token' + this.tokenHmac = 'some-hmac-token' this.targetEmail = 'user@example.com' this.privileges = 'readAndWrite' this.currentUser = { @@ -22,6 +23,7 @@ describe('CollaboratorsInviteController', function () { this.invite = { _id: new ObjectId(), token: this.token, + tokenHmac: this.tokenHmac, sendingUserId: this.currentUser._id, projectId: this.projectId, email: this.targetEmail, diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.js index f337f9cc79..f16e726e1f 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.js @@ -41,6 +41,9 @@ describe('CollaboratorsInviteHandler', function () { this.UserGetter = { promises: { getUser: sinon.stub() } } this.ProjectGetter = { promises: {} } this.NotificationsBuilder = { promises: {} } + this.CollaboratorsInviteHelper = { + hashInviteToken: sinon.stub().returns('abcd'), + } this.CollaboratorsInviteHandler = SandboxedModule.require(MODULE_PATH, { requires: { @@ -51,6 +54,7 @@ describe('CollaboratorsInviteHandler', function () { '../User/UserGetter': this.UserGetter, '../Project/ProjectGetter': this.ProjectGetter, '../Notifications/NotificationsBuilder': this.NotificationsBuilder, + './CollaboratorsInviteHelper': this.CollaboratorsInviteHelper, crypto: this.Crypto, }, }) @@ -69,11 +73,13 @@ describe('CollaboratorsInviteHandler', function () { } this.inviteId = new ObjectId() this.token = 'hnhteaosuhtaeosuahs' + this.tokenHmac = 'jkhajkefhaekjfhkfg' this.privileges = 'readAndWrite' this.fakeInvite = { _id: this.inviteId, email: this.email, token: this.token, + tokenHmac: this.tokenHmac, sendingUserId: this.sendingUserId, projectId: this.projectId, privileges: this.privileges, @@ -186,6 +192,7 @@ describe('CollaboratorsInviteHandler', function () { '_id', 'email', 'token', + 'tokenHmac', 'sendingUserId', 'projectId', 'privileges', @@ -197,6 +204,11 @@ describe('CollaboratorsInviteHandler', function () { this.Crypto.randomBytes.callCount.should.equal(1) }) + it('should have generated a HMAC token', async function () { + await this.call() + this.CollaboratorsInviteHelper.hashInviteToken.callCount.should.equal(1) + }) + it('should have called ProjectInvite.save', async function () { await this.call() this.ProjectInvite.prototype.save.callCount.should.equal(1) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHelperTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHelperTests.js new file mode 100644 index 0000000000..fdd45a073e --- /dev/null +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHelperTests.js @@ -0,0 +1,21 @@ +const sinon = require('sinon') +const { expect } = require('chai') +const path = require('path') +const CollaboratorsInviteHelper = require( + path.join( + __dirname, + '/../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper' + ) +) +const Crypto = require('crypto') + +describe('CollaboratorsInviteHelper', function () { + it('should generate a HMAC token', function () { + const CryptoCreateHmac = sinon.spy(Crypto, 'createHmac') + const tokenHmac = CollaboratorsInviteHelper.hashInviteToken('abc') + CryptoCreateHmac.callCount.should.equal(1) + expect(tokenHmac).to.eq( + '3f76e274d83ffba85149f6850c095ce8481454d7446ca4e25beee01e08beb383' + ) + }) +})