فهرست منبع

Merge pull request #18653 from overleaf/ii-invite-token-create-hmac

[web] Add HMAC tokens for project invitations

GitOrigin-RevId: 02fa01e24790c9a87f57ff9346f5346658d4dd46
ilkin-overleaf 2 سال پیش
والد
کامیت
b34be6bea4

+ 3 - 0
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,

+ 11 - 0
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,
+}

+ 1 - 0
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,

+ 24 - 0
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])
+}

+ 80 - 0
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)
+    })
+}

+ 2 - 0
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,

+ 12 - 0
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)

+ 21 - 0
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'
+    )
+  })
+})