diff --git a/services/web/app/src/Features/Security/OneTimeTokenHandler.js b/services/web/app/src/Features/Security/OneTimeTokenHandler.js index 69e5dd8861..e9112ae529 100644 --- a/services/web/app/src/Features/Security/OneTimeTokenHandler.js +++ b/services/web/app/src/Features/Security/OneTimeTokenHandler.js @@ -112,6 +112,25 @@ const OneTimeTokenHandler = { } ) }, + + expireAllTokensForUser(userId, use, callback) { + const now = new Date() + db.tokens.updateMany( + { + use, + 'data.user_id': userId.toString(), + usedAt: { $exists: false }, + }, + { + $set: { + usedAt: now, + }, + }, + error => { + callback(error) + } + ) + }, } OneTimeTokenHandler.promises = promisifyAll(OneTimeTokenHandler) diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index bb47ac7b00..f2a6070f50 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -22,6 +22,7 @@ const { acceptsJson, } = require('../../infrastructure/RequestContentTypeDetection') const Modules = require('../../infrastructure/Modules') +const OneTimeTokenHandler = require('../Security/OneTimeTokenHandler') async function _sendSecurityAlertClearedSessions(user) { const emailOptions = { @@ -131,6 +132,11 @@ async function changePassword(req, res, next) { req.sessionID, ]) + await OneTimeTokenHandler.promises.expireAllTokensForUser( + userId.toString(), + 'password' + ) + return res.json({ message: { type: 'success', diff --git a/services/web/migrations/20231113173237_tokens_user_id_index.js b/services/web/migrations/20231113173237_tokens_user_id_index.js new file mode 100644 index 0000000000..c80b524380 --- /dev/null +++ b/services/web/migrations/20231113173237_tokens_user_id_index.js @@ -0,0 +1,22 @@ +/* eslint-disable no-unused-vars */ + +const Helpers = require('./lib/helpers') + +exports.tags = ['server-ce', 'server-pro', 'saas'] + +const index = { + key: { + 'data.user_id': 1, + }, + name: 'data.user_id_1', +} + +exports.migrate = async client => { + const { db } = client + await Helpers.addIndexesToCollection(db.tokens, [index]) +} + +exports.rollback = async client => { + const { db } = client + await Helpers.dropIndexesFromCollection(db.tokens, [index]) +} diff --git a/services/web/test/unit/src/User/UserControllerTests.js b/services/web/test/unit/src/User/UserControllerTests.js index cae14467fe..d6b4f86333 100644 --- a/services/web/test/unit/src/User/UserControllerTests.js +++ b/services/web/test/unit/src/User/UserControllerTests.js @@ -129,6 +129,10 @@ describe('UserController', function () { promises: { sendEmail: sinon.stub().resolves() }, } + this.OneTimeTokenHandler = { + promises: { expireAllTokensForUser: sinon.stub().resolves() }, + } + this.UserController = SandboxedModule.require(modulePath, { requires: { '../Helpers/UrlHelper': this.UrlHelper, @@ -149,6 +153,7 @@ describe('UserController', function () { '@overleaf/settings': this.settings, '@overleaf/o-error': OError, '../Email/EmailHandler': this.EmailHandler, + '../Security/OneTimeTokenHandler': this.OneTimeTokenHandler, '../../infrastructure/RequestContentTypeDetection': this.RequestContentTypeDetection, }, @@ -739,6 +744,17 @@ describe('UserController', function () { }) this.UserController.changePassword(this.req, this.res) }) + + it('should expire password reset tokens', function (done) { + this.res.json.callsFake(() => { + this.OneTimeTokenHandler.promises.expireAllTokensForUser.should.have.been.calledWith( + this.user._id, + 'password' + ) + done() + }) + this.UserController.changePassword(this.req, this.res) + }) }) describe('errors', function () {