mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
571 lines
18 KiB
JavaScript
571 lines
18 KiB
JavaScript
import Path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import _ from 'lodash'
|
|
import crypto from 'crypto'
|
|
import Settings from '@overleaf/settings'
|
|
import Metrics from '@overleaf/metrics'
|
|
import logger from '@overleaf/logger'
|
|
import { User } from '../../../../app/src/models/User.js'
|
|
import { DeletedUser } from '../../../../app/src/models/DeletedUser.js'
|
|
import { DeletedProject } from '../../../../app/src/models/DeletedProject.js'
|
|
import { expressify, promiseMapWithLimit } from '@overleaf/promise-utils'
|
|
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.js'
|
|
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs'
|
|
import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.js'
|
|
import OneTimeTokenHandler from '../../../../app/src/Features/Security/OneTimeTokenHandler.js'
|
|
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
|
|
import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js'
|
|
import UserDeleter from '../../../../app/src/Features/User/UserDeleter.mjs'
|
|
import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs'
|
|
import OwnershipTransferHandler from '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'
|
|
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.js'
|
|
import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs'
|
|
import Errors, { OError } from '../../../../app/src/Features/Errors/Errors.js'
|
|
import { db } from '../../../../app/src/infrastructure/mongodb.js'
|
|
|
|
const __dirname = Path.dirname(fileURLToPath(import.meta.url))
|
|
|
|
const externalAuth = process.env.EXTERNAL_AUTH ? process.env.EXTERNAL_AUTH.split(' ') : []
|
|
const availableAuthMethods = ['local', ...externalAuth]
|
|
|
|
const userIsAdminUpdatedOnLogin = Object.fromEntries(
|
|
availableAuthMethods.map(m => [
|
|
m,
|
|
Boolean(Settings[m]?.attAdmin) && Boolean(Settings[m]?.valAdmin)
|
|
])
|
|
)
|
|
const userDetailsUpdatedOnLogin = Object.fromEntries(
|
|
availableAuthMethods.map(m => [
|
|
m,
|
|
Boolean(Settings[m]?.updateUserDetailsOnLogin)
|
|
])
|
|
)
|
|
|
|
async function _sendActivationEmail(idString) {
|
|
const user = await User.findById(idString, { email: 1, _id: 0 }).lean()
|
|
if (!user) {
|
|
throw new OError('User does not exist, cannot send activation email', { userId: idString })
|
|
}
|
|
const ONE_WEEK = 7 * 24 * 60 * 60 // seconds
|
|
const token = await OneTimeTokenHandler.promises.getNewToken(
|
|
'password',
|
|
{ user_id: idString, email: user.email },
|
|
{ expiresIn: ONE_WEEK }
|
|
)
|
|
const setNewPasswordUrl = `${Settings.siteUrl}/user/activate?token=${token}&user_id=${idString}`
|
|
await EmailHandler.promises.sendEmail('registered', { to: user.email, setNewPasswordUrl })
|
|
.catch(error => {
|
|
throw new OError('Failed to send activation email', { error: error.message, email: user.email })
|
|
})
|
|
return setNewPasswordUrl
|
|
}
|
|
|
|
function cleanupSession(req) {
|
|
// cleanup redirects at the end of the redirect chain
|
|
delete req.session.postCheckoutRedirect
|
|
delete req.session.postLoginRedirect
|
|
delete req.session.postOnboardingRedirect
|
|
}
|
|
|
|
async function manageUsersPage(req, res, next) {
|
|
cleanupSession(req)
|
|
|
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
|
|
|
const usersBlobPending = _getUsers().catch(err => {
|
|
logger.err({ err }, 'users listing in background failed')
|
|
return undefined
|
|
})
|
|
|
|
const prefetchedUsersBlob = await usersBlobPending
|
|
|
|
Metrics.inc('user-list-prefetch-users', 1, {
|
|
status: prefetchedUsersBlob ? 'success' : 'error',
|
|
})
|
|
|
|
res.render(Path.resolve(__dirname, '../views/manage-users-react'), {
|
|
title: 'Manage Users',
|
|
prefetchedUsersBlob,
|
|
availableAuthMethods,
|
|
userDetailsUpdatedOnLogin,
|
|
userIsAdminUpdatedOnLogin,
|
|
})
|
|
}
|
|
|
|
async function registerNewUser(req, res, next) {
|
|
const { email, isExternal, isAdmin } = req.body
|
|
if (email == null || email === '') {
|
|
return HttpErrorHandler.unprocessableEntity(req, res, 'Email address is empty')
|
|
}
|
|
delete req.body.isExternal
|
|
req.body.password = crypto.randomBytes(32).toString('hex')
|
|
|
|
let user
|
|
try {
|
|
user = await UserRegistrationHandler.promises.registerNewUser(req.body)
|
|
} catch (err) {
|
|
if (err.message == 'EmailAlreadyRegistered') {
|
|
return HttpErrorHandler.conflict(req, res, 'email_already_registered')
|
|
}
|
|
if (err.message === 'InvalidEmailError') {
|
|
return HttpErrorHandler.unprocessableEntity(req, res, 'email_address_is_invalid')
|
|
}
|
|
if (err.message === 'InvalidPasswordError') {
|
|
return HttpErrorHandler.unprocessableEntity(req, res, 'try_again')
|
|
}
|
|
OError.tag(err, 'error user registration', {
|
|
email,
|
|
})
|
|
throw err
|
|
}
|
|
|
|
try {
|
|
const reversedHostname = user.email
|
|
.split('@')[1]
|
|
.split('')
|
|
.reverse()
|
|
.join('')
|
|
const update = {
|
|
$set: { isAdmin, emails: [{ email, reversedHostname, confirmedAt: Date.now() }] },
|
|
}
|
|
if (isExternal) {
|
|
update.$unset = { hashedPassword: "" }
|
|
} else {
|
|
await _sendActivationEmail(user._id.toString())
|
|
}
|
|
await User.updateOne({ _id: user._id }, update).exec()
|
|
} catch (err) {
|
|
OError.tag(err, 'error finishing user registration', {
|
|
email: user.email,
|
|
})
|
|
throw err
|
|
}
|
|
|
|
const authMethods = isExternal ? [] : ['local']
|
|
const { id, first_name, last_name, signUpDate } = user
|
|
const newUser = { id, email, firstName: first_name, lastName: last_name, isAdmin, signUpDate, inactive: true, deleted: false, authMethods }
|
|
res.json({ user: newUser })
|
|
}
|
|
|
|
async function sendActivationEmail(req, res, next) {
|
|
const { userId } = req.params
|
|
try {
|
|
await _sendActivationEmail(userId)
|
|
} catch (err) {
|
|
logger.warn({ err })
|
|
return HttpErrorHandler.unprocessableEntity(req, res, 'Error sending activation email')
|
|
}
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async function getUsersJson(req, res) {
|
|
const { filters, page, sort } = req.body
|
|
const usersPage = await _getUsers(filters, sort, page)
|
|
res.json(usersPage)
|
|
}
|
|
|
|
|
|
async function activateAccountPage(req, res, next) {
|
|
// An 'activation' is actually just a password reset on an account that
|
|
// was set with a random password originally.
|
|
if (req.query.user_id == null || req.query.token == null) {
|
|
return ErrorController.notFound(req, res)
|
|
}
|
|
|
|
if (typeof req.query.user_id !== 'string') {
|
|
return ErrorController.forbidden(req, res)
|
|
}
|
|
|
|
const user = await UserGetter.promises.getUser(req.query.user_id, {
|
|
email: 1,
|
|
})
|
|
|
|
if (!user) {
|
|
return ErrorController.notFound(req, res)
|
|
}
|
|
|
|
req.session.doLoginAfterPasswordReset = true
|
|
|
|
res.render(Path.resolve(__dirname, '../views/activate'), {
|
|
title: 'activate_account',
|
|
email: user.email,
|
|
token: req.query.token,
|
|
})
|
|
}
|
|
|
|
async function _getUsers(
|
|
filters = {},
|
|
sort = { by: 'name', order: 'asc' },
|
|
page = { size: 20 }
|
|
) {
|
|
const projection = {
|
|
_id: 1,
|
|
email: 1,
|
|
first_name: 1,
|
|
last_name: 1,
|
|
lastActive: 1,
|
|
lastLoggedIn: 1,
|
|
signUpDate: 1,
|
|
loginCount: 1,
|
|
isAdmin: 1,
|
|
hashedPassword: 1,
|
|
samlIdentifiers: 1,
|
|
thirdPartyIdentifiers: 1,
|
|
suspended: 1,
|
|
}
|
|
const projectionDeleted = {};
|
|
for (const key of Object.keys(projection)) {
|
|
projectionDeleted[key] = `$user.${key}`
|
|
}
|
|
projectionDeleted.deletedAt = '$deleterData.deletedAt'
|
|
|
|
const activeUsers = await UserGetter.promises.getUsers({}, projection)
|
|
const deletedUsers = await DeletedUser.aggregate([
|
|
{ $match: { user: { $type: 'object' } } },
|
|
{ $project: projectionDeleted },
|
|
])
|
|
|
|
const allUsers = [...activeUsers, ...deletedUsers]
|
|
|
|
const formattedUsers = _formatUsers(allUsers)
|
|
const filteredUsers = _applyFilters(formattedUsers, filters)
|
|
const users = _sortAndPaginate(filteredUsers, sort, page)
|
|
|
|
return {
|
|
totalSize: filteredUsers.length,
|
|
users,
|
|
}
|
|
}
|
|
|
|
function _formatUsers(users) {
|
|
const formattedUsers = []
|
|
const yearAgo = new Date()
|
|
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
|
|
|
|
for (const user of users) {
|
|
formattedUsers.push(
|
|
_formatUserInfo(user, yearAgo)
|
|
)
|
|
}
|
|
|
|
return formattedUsers
|
|
}
|
|
|
|
function _applyFilters(users, filters) {
|
|
if (!_hasActiveFilter(filters)) {
|
|
return users
|
|
}
|
|
return users.filter(user => _matchesFilters(user, filters))
|
|
}
|
|
|
|
function _sortAndPaginate(users, sort, page) {
|
|
if (
|
|
(sort.by && !['lastActive', 'signUpDate', 'email', 'name', 'deletedAt'].includes(sort.by)) ||
|
|
(sort.order && !['asc', 'desc'].includes(sort.order))
|
|
) {
|
|
throw new OError('Invalid sorting criteria', { sort })
|
|
}
|
|
|
|
const sortedUsers = _.orderBy(
|
|
users,
|
|
[sort.by || 'signUpDate'],
|
|
[sort.order || 'desc']
|
|
)
|
|
return sortedUsers
|
|
}
|
|
|
|
function _formatUserInfo(user, maxDate) {
|
|
let authMethods = []
|
|
if (availableAuthMethods.includes('local') && user.hashedPassword) authMethods.push('local')
|
|
if (availableAuthMethods.includes('saml') && user.samlIdentifiers.length > 0) authMethods.push('saml')
|
|
if (availableAuthMethods.includes('oidc') && user.thirdPartyIdentifiers.length > 0) authMethods.push('oidc')
|
|
// If none of the above, mark as LDAP
|
|
if (availableAuthMethods.includes('ldap') && authMethods.length === 0 && user.loginCount !== 0) authMethods.push('ldap')
|
|
|
|
// if not all user's authentications methods update a property on login, allow admin to update that property
|
|
const allowUpdateDetails = authMethods.length === 0 || !authMethods.every(m => userDetailsUpdatedOnLogin[m])
|
|
const allowUpdateIsAdmin = authMethods.length === 0 || !authMethods.every(m => userIsAdminUpdatedOnLogin[m])
|
|
|
|
return {
|
|
id: user._id.toString(),
|
|
email: user.email,
|
|
firstName: user.first_name,
|
|
lastName: user.last_name,
|
|
isAdmin: user.isAdmin,
|
|
loginCount: user.loginCount,
|
|
signUpDate: user.signUpDate,
|
|
lastActive: user.lastActive,
|
|
lastLoggedIn: user.lastLoggedIn,
|
|
authMethods,
|
|
allowUpdateDetails,
|
|
allowUpdateIsAdmin,
|
|
...(user.suspended && { suspended: user.suspended }),
|
|
inactive: !user.lastActive || user.lastActive < maxDate,
|
|
...(user.deletedAt && { deletedAt: user.deletedAt }),
|
|
deleted: Boolean(user.deletedAt),
|
|
}
|
|
}
|
|
|
|
function _matchesFilters(user, filters) {
|
|
if (
|
|
filters.search?.length &&
|
|
user.email.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
|
|
user.first_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
|
|
user.last_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
|
|
) { return false }
|
|
// Deleted users only match the 'deleted' filter
|
|
if (user.deleted) return Boolean(filters.deleted)
|
|
if (filters.all) return true
|
|
if (filters.admin) return user.isAdmin
|
|
if (filters.inactive && !user.inactive) return false
|
|
if (filters.suspended && !user.suspended) return false
|
|
for (const method of availableAuthMethods) {
|
|
if (filters[method] && !user.authMethods.includes(method)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
function _hasActiveFilter(filters) {
|
|
return Boolean(
|
|
filters.deleted ||
|
|
filters.all ||
|
|
filters.admin ||
|
|
filters.inactive ||
|
|
filters.suspended ||
|
|
filters.local ||
|
|
filters.saml ||
|
|
filters.oidc ||
|
|
filters.ldap ||
|
|
filters.search?.length
|
|
)
|
|
}
|
|
|
|
async function deleteUser(req, res, next) {
|
|
const deleterUserId = SessionManager.getLoggedInUserId(req.session)
|
|
const { userId } = req.params
|
|
const { sendEmail, toUserId } = req.body
|
|
|
|
logger.debug({ deleterUserId, userId }, 'admin is trying to delete user account')
|
|
|
|
if (toUserId) {
|
|
try {
|
|
await OwnershipTransferHandler.promises.transferAllProjectsToUser({
|
|
fromUserId: userId,
|
|
toUserId,
|
|
ipAddress: '0.0.0.0',
|
|
})
|
|
} catch (err) {
|
|
logger.warn({ userId, toUserId }, err.message)
|
|
const message = 'Failed to transfer projects ownership'
|
|
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
|
}
|
|
}
|
|
|
|
try {
|
|
await UserDeleter.promises.deleteUser(userId, {
|
|
deleterUser: { '_id': deleterUserId },
|
|
ipAddress: req.ip,
|
|
skipEmail: !sendEmail,
|
|
})
|
|
} catch (err) {
|
|
logger.warn({ deleterUser, userId }, err.message)
|
|
if (toUserId) {
|
|
try { // failed to delete user, try to transfer all projects back
|
|
await OwnershipTransferHandler.promises.transferAllProjectsToUser({
|
|
toUserId,
|
|
fromUserId: userId,
|
|
ipAddress: '0.0.0.0',
|
|
})
|
|
} catch (e) {
|
|
logger.warn({ toUserId, userId }, 'Failed to transfer the projects ownership back: ' + e.message)
|
|
}
|
|
}
|
|
const message = 'Something went wrong. Does the account still exist?'
|
|
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
|
}
|
|
|
|
const deletedUser = await DeletedUser.findOne(
|
|
{ 'user._id': userId }, { 'deleterData.deletedAt': 1 }
|
|
).lean()
|
|
|
|
res.json({ deletedAt: deletedUser.deleterData.deletedAt })
|
|
}
|
|
|
|
async function purgeDeletedUser(req, res, next) {
|
|
const deleterUserId = SessionManager.getLoggedInUserId(req.session)
|
|
const userId = req.params.userId
|
|
|
|
logger.debug({ deleterUserId, userId }, 'admin is trying to purge deleted user account')
|
|
try {
|
|
UserDeleter.promises.expireDeletedUser(userId)
|
|
} catch (err) {
|
|
logger.warn({ restorerId, userId }, err.message)
|
|
const message = 'Something went wrong. The user is already deleted?'
|
|
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
|
}
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async function restoreDeletedUser(req, res, next) {
|
|
const restorerId = SessionManager.getLoggedInUserId(req.session)
|
|
const userId = req.params.userId
|
|
|
|
logger.debug({ restorerId, userId }, 'admin is trying to restore deleted user')
|
|
|
|
let userData
|
|
try {
|
|
const deletedEntry = await DeletedUser.findOne( { "user._id": userId }).lean()
|
|
userData = deletedEntry?.user
|
|
if (!userData) {
|
|
const message = 'Something went wrong. The user is purged?'
|
|
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
|
}
|
|
|
|
const exists = await User.findOne({ email: userData.email }, { _id: 1 }).lean()
|
|
if (exists) {
|
|
const message = req.i18n.translate('email_already_registered')
|
|
return HttpErrorHandler.conflict(req, res, message)
|
|
}
|
|
|
|
userData.suspended = false
|
|
await User.create(userData)
|
|
await DeletedUser.deleteOne({ "user._id": userId })
|
|
|
|
} catch (err) {
|
|
const message = req.i18n.translate('generic_something_went_wrong')
|
|
return HttpErrorHandler.legacyInternal(
|
|
req, res, message,
|
|
OError.tag(err, 'problem restoring deleted user', {
|
|
userId,
|
|
})
|
|
)
|
|
}
|
|
|
|
try {
|
|
const projects = await DeletedProject.find({ "project.owner_ref": userId }).exec()
|
|
logger.info(
|
|
{ userId, projectCount: projects.length },
|
|
'found user projects to restore'
|
|
)
|
|
await promiseMapWithLimit(5, projects, project =>
|
|
ProjectDeleter.promises.undeleteProject(project.deleterData.deletedProjectId, { suffix: "" }))
|
|
} catch (err) {
|
|
logger.info({ userId }, err.message)
|
|
}
|
|
|
|
return res.json({
|
|
restoredId: userData._id.toString(),
|
|
email: userData.email,
|
|
})
|
|
}
|
|
|
|
async function updateUser(req, res, next) {
|
|
const userId = req.params.userId
|
|
const actorUserId = SessionManager.getLoggedInUserId(req.session)
|
|
req.logger.addFields({ actorUserId })
|
|
const { body } = req
|
|
|
|
const projection = Object.fromEntries(Object.keys(body).map(k => [k, 1]))
|
|
const user = await User.findById(userId, projection).exec()
|
|
|
|
if (user == null) {
|
|
throw new OError('problem updating user settings', { userId })
|
|
}
|
|
|
|
let emailIsUpdated = false
|
|
const newEmail = body.email?.trim().toLowerCase()
|
|
if (newEmail != null && newEmail !== user.email) { // email is updated
|
|
if (newEmail.indexOf('@') === -1) {
|
|
const message = req.i18n.translate('email_address_is_invalid')
|
|
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
|
}
|
|
const auditLog = { initiatorId: actorUserId, ipAddress: req.ip }
|
|
|
|
try {
|
|
await UserUpdater.promises.changeEmailAddress(userId, newEmail, auditLog)
|
|
emailIsUpdated = true
|
|
} catch (err) {
|
|
if (err instanceof Errors.EmailExistsError) {
|
|
const message = req.i18n.translate('email_already_registered')
|
|
return HttpErrorHandler.conflict(req, res, message)
|
|
} else {
|
|
const message = req.i18n.translate('problem_changing_email_address')
|
|
return HttpErrorHandler.legacyInternal(
|
|
req, res, message,
|
|
OError.tag(err, 'problem changing email address', {
|
|
userId,
|
|
newEmail,
|
|
})
|
|
)
|
|
}
|
|
}
|
|
if (userId == actorUserId) {
|
|
SessionManager.setInSessionUser(req.session, {
|
|
email: newEmail,
|
|
})
|
|
}
|
|
}
|
|
|
|
const update = {}
|
|
for (const [key, value] of Object.entries(body)) {
|
|
if (key === "email") continue
|
|
if (value === user[key]) continue
|
|
update[key] = typeof value === "string" ? value.trim() : value
|
|
}
|
|
Object.assign(user, update)
|
|
try {
|
|
await user.save()
|
|
} catch (err) {
|
|
throw new OError('problem updating user settings', { userId })
|
|
}
|
|
if (userId == actorUserId) {
|
|
const sessionUpdate = {}
|
|
if (update.first_name != null) sessionUpdate.first_name = update.first_name
|
|
if (update.last_name != null) sessionUpdate.last_name = update.last_name
|
|
SessionManager.setInSessionUser(req.session, sessionUpdate)
|
|
}
|
|
if (emailIsUpdated) update["email"] = newEmail
|
|
|
|
return res.json(update)
|
|
}
|
|
|
|
async function _getActivationLink(userId) {
|
|
try {
|
|
const tokenDoc = await db.tokens.findOne({
|
|
use: 'password',
|
|
'data.user_id': userId,
|
|
expiresAt: { $gt: new Date() },
|
|
usedAt: { $exists: false },
|
|
peekCount: { $not: { $gte: OneTimeTokenHandler.MAX_PEEKS } },
|
|
})
|
|
if (!tokenDoc) {
|
|
return null
|
|
}
|
|
return `${Settings.siteUrl}/user/activate?token=${tokenDoc.token}&user_id=${userId}`
|
|
} catch (err) {
|
|
logger.warn({ userId }, 'Failed to get activation link' + err.message)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function getAdditionalUserInfo(req, res, next) {
|
|
const { userId } = req.params
|
|
const activationLink = await _getActivationLink(userId)
|
|
res.json({ activationLink })
|
|
}
|
|
|
|
export default {
|
|
manageUsersPage: expressify(manageUsersPage),
|
|
getUsersJson: expressify(getUsersJson),
|
|
getAdditionalUserInfo: expressify(getAdditionalUserInfo),
|
|
registerNewUser: expressify(registerNewUser),
|
|
activateAccountPage: expressify(activateAccountPage),
|
|
sendActivationEmail: expressify(sendActivationEmail),
|
|
deleteUser: expressify(deleteUser),
|
|
restoreDeletedUser: expressify(restoreDeletedUser),
|
|
purgeDeletedUser: expressify(purgeDeletedUser),
|
|
updateUser: expressify(updateUser),
|
|
}
|