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

[web] Add HMAC tokens for project invitations

GitOrigin-RevId: 02fa01e24790c9a87f57ff9346f5346658d4dd46
This commit is contained in:
ilkin-overleaf 2024-06-14 12:31:59 +03:00 committed by Copybot
parent bb2a3b091d
commit b34be6bea4
8 changed files with 154 additions and 0 deletions

View file

@ -3,6 +3,7 @@ const { ProjectInvite } = require('../../models/ProjectInvite')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const CollaboratorsEmailHandler = require('./CollaboratorsEmailHandler') const CollaboratorsEmailHandler = require('./CollaboratorsEmailHandler')
const CollaboratorsHandler = require('./CollaboratorsHandler') const CollaboratorsHandler = require('./CollaboratorsHandler')
const CollaboratorsInviteHelper = require('./CollaboratorsInviteHelper')
const UserGetter = require('../User/UserGetter') const UserGetter = require('../User/UserGetter')
const ProjectGetter = require('../Project/ProjectGetter') const ProjectGetter = require('../Project/ProjectGetter')
const Crypto = require('crypto') const Crypto = require('crypto')
@ -83,9 +84,11 @@ const CollaboratorsInviteHandler = {
) )
const buffer = await randomBytes(24) const buffer = await randomBytes(24)
const token = buffer.toString('hex') const token = buffer.toString('hex')
const tokenHmac = CollaboratorsInviteHelper.hashInviteToken(token)
let invite = new ProjectInvite({ let invite = new ProjectInvite({
email, email,
token, token,
tokenHmac,
sendingUserId: sendingUser._id, sendingUserId: sendingUser._id,
projectId, projectId,
privileges, privileges,

View file

@ -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,
}

View file

@ -15,6 +15,7 @@ const ProjectInviteSchema = new Schema(
{ {
email: String, email: String,
token: String, token: String,
tokenHmac: String,
sendingUserId: ObjectId, sendingUserId: ObjectId,
projectId: ObjectId, projectId: ObjectId,
privileges: String, privileges: String,

View file

@ -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])
}

View file

@ -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)
})
}

View file

@ -13,6 +13,7 @@ describe('CollaboratorsInviteController', function () {
beforeEach(function () { beforeEach(function () {
this.projectId = 'project-id-123' this.projectId = 'project-id-123'
this.token = 'some-opaque-token' this.token = 'some-opaque-token'
this.tokenHmac = 'some-hmac-token'
this.targetEmail = 'user@example.com' this.targetEmail = 'user@example.com'
this.privileges = 'readAndWrite' this.privileges = 'readAndWrite'
this.currentUser = { this.currentUser = {
@ -22,6 +23,7 @@ describe('CollaboratorsInviteController', function () {
this.invite = { this.invite = {
_id: new ObjectId(), _id: new ObjectId(),
token: this.token, token: this.token,
tokenHmac: this.tokenHmac,
sendingUserId: this.currentUser._id, sendingUserId: this.currentUser._id,
projectId: this.projectId, projectId: this.projectId,
email: this.targetEmail, email: this.targetEmail,

View file

@ -41,6 +41,9 @@ describe('CollaboratorsInviteHandler', function () {
this.UserGetter = { promises: { getUser: sinon.stub() } } this.UserGetter = { promises: { getUser: sinon.stub() } }
this.ProjectGetter = { promises: {} } this.ProjectGetter = { promises: {} }
this.NotificationsBuilder = { promises: {} } this.NotificationsBuilder = { promises: {} }
this.CollaboratorsInviteHelper = {
hashInviteToken: sinon.stub().returns('abcd'),
}
this.CollaboratorsInviteHandler = SandboxedModule.require(MODULE_PATH, { this.CollaboratorsInviteHandler = SandboxedModule.require(MODULE_PATH, {
requires: { requires: {
@ -51,6 +54,7 @@ describe('CollaboratorsInviteHandler', function () {
'../User/UserGetter': this.UserGetter, '../User/UserGetter': this.UserGetter,
'../Project/ProjectGetter': this.ProjectGetter, '../Project/ProjectGetter': this.ProjectGetter,
'../Notifications/NotificationsBuilder': this.NotificationsBuilder, '../Notifications/NotificationsBuilder': this.NotificationsBuilder,
'./CollaboratorsInviteHelper': this.CollaboratorsInviteHelper,
crypto: this.Crypto, crypto: this.Crypto,
}, },
}) })
@ -69,11 +73,13 @@ describe('CollaboratorsInviteHandler', function () {
} }
this.inviteId = new ObjectId() this.inviteId = new ObjectId()
this.token = 'hnhteaosuhtaeosuahs' this.token = 'hnhteaosuhtaeosuahs'
this.tokenHmac = 'jkhajkefhaekjfhkfg'
this.privileges = 'readAndWrite' this.privileges = 'readAndWrite'
this.fakeInvite = { this.fakeInvite = {
_id: this.inviteId, _id: this.inviteId,
email: this.email, email: this.email,
token: this.token, token: this.token,
tokenHmac: this.tokenHmac,
sendingUserId: this.sendingUserId, sendingUserId: this.sendingUserId,
projectId: this.projectId, projectId: this.projectId,
privileges: this.privileges, privileges: this.privileges,
@ -186,6 +192,7 @@ describe('CollaboratorsInviteHandler', function () {
'_id', '_id',
'email', 'email',
'token', 'token',
'tokenHmac',
'sendingUserId', 'sendingUserId',
'projectId', 'projectId',
'privileges', 'privileges',
@ -197,6 +204,11 @@ describe('CollaboratorsInviteHandler', function () {
this.Crypto.randomBytes.callCount.should.equal(1) 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 () { it('should have called ProjectInvite.save', async function () {
await this.call() await this.call()
this.ProjectInvite.prototype.save.callCount.should.equal(1) this.ProjectInvite.prototype.save.callCount.should.equal(1)

View file

@ -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'
)
})
})