mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
[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:
parent
da004d367b
commit
b0419a86f2
8 changed files with 275 additions and 37 deletions
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in a new issue