[web] Add audit logs for clear_sessions_set_must_reconfirm script, "must-reset-password-set" and "must-reset-password-unset" (#21776)

* Promisify clear_sessions_set_must_reconfirm.mjs

* Add test on PasswordResetTests.mjs

* Add `must-reset-password-unset` audit log

* Add `must-reset-password-set` audit log

* Add test ClearSessionsSetMustReconfirmTests.mjs

* Fixup bad copy-paste in test: `must-reset-password-set` -> `must-reset-password-unset`

* Check `must_reconfirm` before calling `removeReconfirmFlag`

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Fix unit test

* Use `promiseMapWithLimit`

* Add `{ script: true }` to AuditLog. Also use `undefined` instead of `null` for consistency

---------

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: 522026c82196d263c196503d899b8c57b05b31dd
This commit is contained in:
Antoine Clausse 2024-11-14 15:55:40 +01:00 committed by Copybot
parent da004d367b
commit b0419a86f2
8 changed files with 275 additions and 37 deletions

View file

@ -44,7 +44,7 @@ async function setNewUserPassword(req, res, next) {
password, password,
auditLog auditLog
) )
const { found, reset, userId } = result const { found, reset, userId, mustReconfirm } = result
if (!found) { if (!found) {
return res.status(404).json({ return res.status(404).json({
message: { message: {
@ -58,7 +58,9 @@ async function setNewUserPassword(req, res, next) {
}) })
} }
await UserSessionsManager.promises.removeSessionsFromRedis({ _id: userId }) await UserSessionsManager.promises.removeSessionsFromRedis({ _id: userId })
await UserUpdater.promises.removeReconfirmFlag(userId) if (mustReconfirm) {
await UserUpdater.promises.removeReconfirmFlag(userId, auditLog)
}
if (!req.session.doLoginAfterPasswordReset) { if (!req.session.doLoginAfterPasswordReset) {
return res.sendStatus(200) return res.sendStatus(200)
} }

View file

@ -71,6 +71,7 @@ async function getUserForPasswordResetToken(token) {
_id: 1, _id: 1,
'overleaf.id': 1, 'overleaf.id': 1,
email: 1, email: 1,
must_reconfirm: 1,
}) })
await assertUserPermissions(user, ['change-password']) await assertUserPermissions(user, ['change-password'])
@ -117,7 +118,12 @@ async function setNewUserPassword(token, password, auditLog) {
await PasswordResetHandler.promises.expirePasswordResetToken(token) await PasswordResetHandler.promises.expirePasswordResetToken(token)
return { found: true, reset, userId: user._id } return {
found: true,
reset,
userId: user._id,
mustReconfirm: user.must_reconfirm,
}
} }
const PasswordResetHandler = { const PasswordResetHandler = {

View file

@ -5,6 +5,7 @@ const { callbackify } = require('util')
function _canHaveNoIpAddressId(operation) { function _canHaveNoIpAddressId(operation) {
if (operation === 'join-group-subscription') return true if (operation === 'join-group-subscription') return true
if (operation === 'leave-group-subscription') return true if (operation === 'leave-group-subscription') return true
if (operation === 'must-reset-password-set') return true
return false return false
} }
@ -17,6 +18,8 @@ function _canHaveNoInitiatorId(operation, info) {
if (operation === 'remove-email' && info.script) return true if (operation === 'remove-email' && info.script) return true
if (operation === 'join-group-subscription') return true if (operation === 'join-group-subscription') return true
if (operation === 'leave-group-subscription') return true if (operation === 'leave-group-subscription') return true
if (operation === 'must-reset-password-set') return true
if (operation === 'must-reset-password-unset') return true
} }
/** /**

View file

@ -494,7 +494,18 @@ async function changeEmailAddress(userId, newEmail, auditLog) {
await removeEmailAddress(userId, oldEmail, auditLog) await removeEmailAddress(userId, oldEmail, auditLog)
} }
async function removeReconfirmFlag(userId) { /**
* @param {string} userId
* @param {{initiatorId: string, ip: string}} auditLog
* @returns {Promise<void>}
*/
async function removeReconfirmFlag(userId, auditLog) {
await UserAuditLogHandler.promises.addEntry(
userId.toString(),
'must-reset-password-unset',
auditLog.initiatorId,
auditLog.ip
)
await updateUser(userId.toString(), { $set: { must_reconfirm: false } }) await updateUser(userId.toString(), { $set: { must_reconfirm: false } })
} }

View file

@ -1,8 +1,9 @@
import fs from 'node:fs' import fs from 'node:fs'
import { ObjectId } from '../app/src/infrastructure/mongodb.js' import { ObjectId } from '../app/src/infrastructure/mongodb.js'
import async from 'async'
import UserUpdater from '../app/src/Features/User/UserUpdater.js' import UserUpdater from '../app/src/Features/User/UserUpdater.js'
import UserSessionsManager from '../app/src/Features/User/UserSessionsManager.js' import UserSessionsManager from '../app/src/Features/User/UserSessionsManager.js'
import UserAuditLogHandler from '../app/src/Features/User/UserAuditLogHandler.js'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
const ASYNC_LIMIT = 10 const ASYNC_LIMIT = 10
@ -30,37 +31,45 @@ function _validateUserIdList(userIds) {
}) })
} }
function _handleUser(userId, callback) { async function _handleUser(userId) {
UserUpdater.updateUser(userId, { $set: { must_reconfirm: true } }, error => { try {
if (error) { await UserUpdater.promises.updateUser(userId, {
console.log(`Failed to set must_reconfirm ${userId}`, error) $set: { must_reconfirm: true },
processLogger.failedSet.push(userId) })
return callback() } catch (error) {
} else { console.log(`Failed to set must_reconfirm ${userId}`, error)
UserSessionsManager.removeSessionsFromRedis( processLogger.failedSet.push(userId)
{ _id: userId }, return
null, }
error => {
if (error) { try {
console.log(`Failed to clear sessions for ${userId}`, error) await UserAuditLogHandler.promises.addEntry(
processLogger.failedClear.push(userId) userId,
} else { 'must-reset-password-set',
processLogger.success.push(userId) undefined,
} undefined,
return callback() { script: true }
} )
) } catch (error) {
} console.log(`Failed to create audit log for ${userId}`, error)
}) // don't block the process if audit log fails
}
try {
await UserSessionsManager.promises.removeSessionsFromRedis(
{ _id: userId },
null
)
} catch (error) {
console.log(`Failed to clear sessions for ${userId}`, error)
processLogger.failedClear.push(userId)
return
}
processLogger.success.push(userId)
} }
async function _loopUsers(userIds) { async function _loopUsers(userIds) {
await new Promise((resolve, reject) => { return promiseMapWithLimit(ASYNC_LIMIT, userIds, _handleUser)
async.eachLimit(userIds, ASYNC_LIMIT, _handleUser, error => {
if (error) return reject(error)
resolve()
})
})
} }
const fileName = process.argv[2] const fileName = process.argv[2]

View file

@ -0,0 +1,155 @@
import { exec } from 'node:child_process'
import { promisify } from 'util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { ObjectId, db } from '../../../app/src/infrastructure/mongodb.js'
import fs from 'fs/promises'
import UserHelper from './helpers/User.js'
import UserGetter from '../../../app/src/Features/User/UserGetter.js'
const User = UserHelper.promises
const TEST_FILE_PATH = '/tmp/test-users.txt'
describe('ClearSessionsSetMustReconfirm', function () {
let user1, user2, user3, user4, usersMustReconfirm, usersMustNotReconfirm
beforeEach('create test users', async function () {
user1 = new User()
user2 = new User() // not in the file
user3 = new User() // not in the file
user4 = new User()
await user1.login()
await user2.login()
await user3.login()
await user4.login()
usersMustReconfirm = [user1, user4]
usersMustNotReconfirm = [user2, user3]
})
beforeEach('create test file', async function () {
await fs.writeFile(
TEST_FILE_PATH,
usersMustReconfirm.map(user => user._id.toString()).join('\n')
)
})
afterEach('cleanup test file', async function () {
try {
await fs.unlink(TEST_FILE_PATH)
} catch (err) {
// Ignore error if file doesn't exist
}
})
async function runScript(filePath = TEST_FILE_PATH) {
let result
try {
result = await promisify(exec)(
['VERBOSE_LOGGING=true']
.concat(['node', 'scripts/clear_sessions_set_must_reconfirm.mjs'])
.concat([filePath])
.join(' ')
)
} catch (error) {
logger.error({ error }, 'script failed')
throw error
}
const { stdout: stdOut } = result
expect(stdOut).to.include('DONE.')
return result
}
describe('processing users', function () {
it('should process all users successfully', async function () {
const { stdout } = await runScript()
expect(stdout).to.include(`${usersMustReconfirm.length} successful`)
expect(stdout).to.include('0 failed to clear sessions')
expect(stdout).to.include('0 failed to set must_reconfirm')
for (const user of usersMustReconfirm) {
const updatedUser = await UserGetter.promises.getUser({
_id: user._id,
})
expect(updatedUser.must_reconfirm).to.be.true
}
for (const user of usersMustNotReconfirm) {
const updatedUser = await UserGetter.promises.getUser({
_id: user._id,
})
expect(updatedUser.must_reconfirm).to.be.false
}
})
it('should handle invalid user IDs in file', async function () {
await fs.writeFile(
TEST_FILE_PATH,
[
'invalid-id',
...usersMustReconfirm.map(user => user._id.toString()).join('\n'),
].join('\n')
)
try {
await runScript()
expect.fail('Should have thrown error')
} catch (error) {
expect(error.message).to.include('user ID not valid')
}
})
it('should process large number of users with concurrency limit', async function () {
const manyUserIds = Array.from({ length: 15 }, () =>
new ObjectId().toString()
)
await fs.writeFile(TEST_FILE_PATH, manyUserIds.join('\n'))
const { stdout } = await runScript()
expect(stdout).to.include('15 successful')
})
})
describe('error handling', function () {
beforeEach('ensure test file exists', async function () {
await fs.writeFile(
TEST_FILE_PATH,
usersMustReconfirm.map(user => user._id.toString()).join('\n')
)
})
it('should report failed user updates', async function () {
await db.users.updateOne(
{ _id: user1._id },
{ $set: { must_reconfirm: null } }
)
const { stdout } = await runScript()
expect(stdout).to.include('failed to set must_reconfirm')
})
it('should handle missing input file', async function () {
try {
await runScript(['/non/existent/file'])
expect.fail('Should have thrown error')
} catch (error) {
expect(error.message).to.include('ENOENT')
}
})
})
describe('audit logging', function () {
it('should create audit log entries for processed users', async function () {
await runScript()
for (const user of usersMustReconfirm) {
const auditLogEntry = await user.getAuditLog()
expect(auditLogEntry).to.exist
expect(auditLogEntry[0].operation).to.equal('login')
expect(auditLogEntry[1].operation).to.equal('must-reset-password-set')
expect(auditLogEntry[1].initiatorId).to.be.undefined
expect(auditLogEntry[1].ipAddress).to.be.undefined
expect(auditLogEntry[1].info).to.deep.equal({ script: true })
}
for (const user of usersMustNotReconfirm) {
const auditLogEntry = await user.getAuditLog()
expect(auditLogEntry).to.exist
expect(auditLogEntry[0].operation).to.equal('login')
expect(auditLogEntry[1]).to.be.undefined
}
})
})
})

View file

@ -450,4 +450,53 @@ describe('PasswordReset', function () {
expect(response.status).to.equal(400) expect(response.status).to.equal(400)
}) })
}) })
describe('reconfirm flag', function () {
const getReconfirmAuditLogEntry = async function (email) {
const userHelper = await UserHelper.getUser({ email })
const auditLog = userHelper.getAuditLogWithoutNoise()
return auditLog.find(
entry => entry.operation === 'must-reset-password-unset'
)
}
it('should add audit log entry when flag changes from true to false', async function () {
// Set must_reconfirm to true
await db.users.updateOne(
{ _id: user._id },
{ $set: { must_reconfirm: true } }
)
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'a-password',
}),
})
expect(response.status).to.equal(200)
const reconfirmEntry = await getReconfirmAuditLogEntry(email)
expect(reconfirmEntry).to.exist
expect(reconfirmEntry.ipAddress).to.equal('127.0.0.1')
expect(reconfirmEntry.timestamp).to.exist
})
it('should not add audit log entry when flag was already false', async function () {
await db.users.updateOne(
{ _id: user._id },
{ $set: { must_reconfirm: false } }
)
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'a-password',
}),
})
expect(response.status).to.equal(200)
const reconfirmEntry = await getReconfirmAuditLogEntry(email)
expect(reconfirmEntry).to.not.exist
})
})
}) })

View file

@ -35,9 +35,12 @@ describe('PasswordResetController', function () {
generateAndEmailResetToken: sinon.stub(), generateAndEmailResetToken: sinon.stub(),
promises: { promises: {
generateAndEmailResetToken: sinon.stub(), generateAndEmailResetToken: sinon.stub(),
setNewUserPassword: sinon setNewUserPassword: sinon.stub().resolves({
.stub() found: true,
.resolves({ found: true, reset: true, userID: this.user_id }), reset: true,
userID: this.user_id,
mustReconfirm: true,
}),
getUserForPasswordResetToken: sinon getUserForPasswordResetToken: sinon
.stub() .stub()
.withArgs(this.token) .withArgs(this.token)
@ -271,7 +274,7 @@ describe('PasswordResetController', function () {
this.PasswordResetController.setNewUserPassword(this.req, this.res) this.PasswordResetController.setNewUserPassword(this.req, this.res)
}) })
it('should call removeReconfirmFlag', function (done) { it('should call removeReconfirmFlag if user.must_reconfirm', function (done) {
this.res.sendStatus = code => { this.res.sendStatus = code => {
this.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1) this.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1)
done() done()