const AuthenticationController = require('../Authentication/AuthenticationController')
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const SessionManager = require('../Authentication/SessionManager')
const UserGetter = require('./UserGetter')
const UserUpdater = require('./UserUpdater')
const UserSessionsManager = require('./UserSessionsManager')
const EmailHandler = require('../Email/EmailHandler')
const EmailHelper = require('../Helpers/EmailHelper')
const UserEmailsConfirmationHandler = require('./UserEmailsConfirmationHandler')
const { endorseAffiliation } = require('../Institutions/InstitutionsAPI')
const Errors = require('../Errors/Errors')
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const { expressify } = require('@overleaf/promise-utils')
const AsyncFormHelper = require('../Helpers/AsyncFormHelper')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
const UserAuditLogHandler = require('./UserAuditLogHandler')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const Features = require('../../infrastructure/Features')
const tsscmp = require('tsscmp')
const Modules = require('../../infrastructure/Modules')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
const sendConfirmCodeRateLimiter = new RateLimiter('send-confirmation-code', {
points: 1,
duration: 60,
})
const checkConfirmCodeRateLimiter = new RateLimiter(
'check-confirmation-code-per-email',
{
points: 10,
duration: 60,
}
)
const resendConfirmCodeRateLimiter = new RateLimiter(
'resend-confirmation-code',
{
points: 1,
duration: 60,
}
)
async function _sendSecurityAlertEmail(user, email) {
const emailOptions = {
to: user.email,
actionDescribed: `a secondary email address has been added to your account ${user.email}`,
message: [
`Added:
${email}`,
],
action: 'secondary email address added',
}
await EmailHandler.promises.sendEmail('securityAlert', emailOptions)
}
/**
* This method is for adding a secondary email to be confirmed via an emailed link.
* For code confirmation, see the `addWithConfirmationCode` method in this file.
*/
async function add(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
if (!email) {
return res.sendStatus(422)
}
const user = await UserGetter.promises.getUser(userId, {
email: 1,
'emails.email': 1,
})
if (user.emails.length >= Settings.emailAddressLimit) {
return res.status(422).json({ message: 'secondary email limit exceeded' })
}
const affiliationOptions = {
university: req.body.university,
role: req.body.role,
department: req.body.department,
}
try {
await UserUpdater.promises.addEmailAddress(
userId,
email,
affiliationOptions,
{
initiatorId: user._id,
ipAddress: req.ip,
}
)
} catch (error) {
return UserEmailsController._handleEmailError(error, req, res, next)
}
await _sendSecurityAlertEmail(user, email)
await UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
userId,
email
)
res.sendStatus(204)
}
async function resendConfirmation(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
if (!email) {
return res.sendStatus(422)
}
const user = await UserGetter.promises.getUserByAnyEmail(email, { _id: 1 })
if (!user || user._id.toString() !== userId) {
return res.sendStatus(422)
}
await UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
userId,
email
)
res.sendStatus(200)
}
async function sendReconfirmation(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
if (!email) {
return res.sendStatus(400)
}
const user = await UserGetter.promises.getUserByAnyEmail(email, { _id: 1 })
if (!user || user._id.toString() !== userId) {
return res.sendStatus(422)
}
await UserEmailsConfirmationHandler.promises.sendReconfirmationEmail(
userId,
email
)
res.sendStatus(204)
}
async function sendExistingSecondaryEmailConfirmationCode(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
if (!email) {
return res.sendStatus(400)
}
const user = await UserGetter.promises.getUserByAnyEmail(email, {
_id: 1,
email,
})
if (!user || user._id.toString() !== userId) {
return res.sendStatus(422)
}
await sendCodeAndStoreInSession(req, 'pendingExistingEmail', email)
res.sendStatus(204)
}
/**
* This method is for adding a secondary email to be confirmed via a code.
* For email link confirmation see the `add` method in this file.
*/
async function addWithConfirmationCode(req, res) {
delete req.session.pendingSecondaryEmail
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
const affiliationOptions = {
university: req.body.university,
role: req.body.role,
department: req.body.department,
}
if (!email) {
return res.sendStatus(422)
}
const user = await UserGetter.promises.getUser(userId, {
email: 1,
'emails.email': 1,
})
if (user.emails.length >= Settings.emailAddressLimit) {
return res.status(422).json({ message: 'secondary email limit exceeded' })
}
try {
await UserGetter.promises.ensureUniqueEmailAddress(email)
await sendConfirmCodeRateLimiter.consume(email, 1, {
method: 'email',
})
await UserAuditLogHandler.promises.addEntry(
userId,
'request-add-email-code',
userId,
req.ip,
{
newSecondaryEmail: email,
}
)
await sendCodeAndStoreInSession(
req,
'pendingSecondaryEmail',
email,
affiliationOptions
)
return res.sendStatus(200)
} catch (err) {
if (err.name === 'EmailExistsError') {
return res.status(409).json({
message: {
type: 'error',
text: req.i18n.translate('email_already_registered'),
},
})
}
if (err?.remainingPoints === 0) {
return res.status(429).json({})
}
logger.err({ err }, 'failed to send confirmation code')
delete req.session.pendingSecondaryEmail
return res.status(500).json({
message: {
key: 'error_performing_request',
},
})
}
}
/**
* @param {import('express').Request} req
* @param {string} sessionKey
* @param {string} email
* @param affiliationOptions
* @returns {Promise}
*/
async function sendCodeAndStoreInSession(
req,
sessionKey,
email,
affiliationOptions
) {
const { confirmCode, confirmCodeExpiresTimestamp } =
await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
email,
false
)
req.session[sessionKey] = {
email,
confirmCode,
confirmCodeExpiresTimestamp,
affiliationOptions,
}
}
/**
* @param {string} sessionKey
* @param {(req: import('express').Request, user: any, email: string, affiliationOptions: any) => Promise} beforeConfirmEmail
* @returns {Promise<*>}
*/
const _checkConfirmationCode =
(sessionKey, beforeConfirmEmail) => async (req, res) => {
const userId = SessionManager.getLoggedInUserId(req.session)
const code = req.body.code
const user = await UserGetter.promises.getUser(userId, {
email: 1,
'emails.email': 1,
})
const sessionData = req.session[sessionKey]
if (!sessionData) {
logger.err({}, `error checking confirmation code. missing ${sessionKey}`)
return res.status(422).json({
message: {
key: 'error_performing_request',
},
})
}
const emailToCheck = sessionData.email
try {
await checkConfirmCodeRateLimiter.consume(emailToCheck, 1, {
method: 'email',
})
} catch (err) {
if (err?.remainingPoints === 0) {
return res.sendStatus(429)
} else {
return res.status(500).json({
message: {
key: 'error_performing_request',
},
})
}
}
if (sessionData.confirmCodeExpiresTimestamp < Date.now()) {
return res.status(403).json({
message: { key: 'expired_confirmation_code' },
})
}
if (!tsscmp(sessionData.confirmCode, code)) {
return res.status(403).json({
message: { key: 'invalid_confirmation_code' },
})
}
try {
await beforeConfirmEmail(
req,
user,
emailToCheck,
sessionData.affiliationOptions
)
await UserUpdater.promises.confirmEmail(
userId,
emailToCheck,
sessionData.affiliationOptions
)
delete req.session[sessionKey]
AnalyticsManager.recordEventForUserInBackground(
user._id,
'email-verified',
{
provider: 'email',
verification_type: 'token',
isPrimary: user.email === emailToCheck,
}
)
const redirectUrl =
AuthenticationController.getRedirectFromSession(req) || '/project'
return res.json({
redir: redirectUrl,
})
} catch (error) {
if (error.name === 'EmailExistsError') {
return res.status(409).json({
message: {
type: 'error',
text: req.i18n.translate('email_already_registered'),
},
})
}
logger.err({ error }, 'failed to check confirmation code')
return res.status(500).json({
message: {
key: 'error_performing_request',
},
})
}
}
const checkNewSecondaryEmailConfirmationCode = _checkConfirmationCode(
'pendingSecondaryEmail',
async (req, user, email, affiliationOptions) => {
await UserAuditLogHandler.promises.addEntry(
user._id,
'add-email-via-code',
user._id,
req.ip,
{ newSecondaryEmail: email }
)
await _sendSecurityAlertEmail(user, email)
await UserUpdater.promises.addEmailAddress(
user._id,
email,
affiliationOptions,
{
initiatorId: user._id,
ipAddress: req.ip,
}
)
}
)
const checkExistingEmailConfirmationCode = _checkConfirmationCode(
'pendingExistingEmail',
async (req, user, email) => {
await UserAuditLogHandler.promises.addEntry(
user._id,
'confirm-email-via-code',
user._id,
req.ip,
{ email }
)
}
)
const _resendConfirmationCode =
(sessionKey, operation, auditLogEmailKey) => async (req, res) => {
const sessionData = req.session[sessionKey]
if (!sessionData) {
logger.err({}, `error resending confirmation code. missing ${sessionKey}`)
return res.status(422).json({
message: {
key: 'error_performing_request',
},
})
}
const email = sessionData.email
try {
await resendConfirmCodeRateLimiter.consume(email, 1, { method: 'email' })
} catch (err) {
if (err?.remainingPoints === 0) {
return res.status(429).json({})
} else {
throw err
}
}
const userId = SessionManager.getLoggedInUserId(req.session)
try {
await UserAuditLogHandler.promises.addEntry(
userId,
operation,
userId,
req.ip,
{ [auditLogEmailKey]: email }
)
const { confirmCode, confirmCodeExpiresTimestamp } =
await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
email,
false
)
sessionData.confirmCode = confirmCode
sessionData.confirmCodeExpiresTimestamp = confirmCodeExpiresTimestamp
return res.status(200).json({ message: { key: 'we_sent_new_code' } })
} catch (err) {
logger.err({ err, userId, email }, 'failed to send confirmation code')
return res.status(500).json({ key: 'error_performing_request' })
}
}
const resendNewSecondaryEmailConfirmationCode = _resendConfirmationCode(
'pendingSecondaryEmail',
'resend-add-email-code',
'newSecondaryEmail'
)
const resendExistingSecondaryEmailConfirmationCode = _resendConfirmationCode(
'pendingExistingEmail',
'resend-confirm-email-code',
'email'
)
async function confirmSecondaryEmailPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
if (!req.session.pendingSecondaryEmail) {
const redirectURL =
AuthenticationController.getRedirectFromSession(req) || '/project'
return res.redirect(redirectURL)
}
AnalyticsManager.recordEventForUserInBackground(
userId,
'confirm-secondary-email-page-displayed'
)
res.render('user/confirmSecondaryEmail', {
email: req.session.pendingSecondaryEmail.email,
})
}
async function addSecondaryEmailPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const confirmedEmails =
await UserGetter.promises.getUserConfirmedEmails(userId)
if (confirmedEmails.length >= 2) {
const redirectURL =
AuthenticationController.getRedirectFromSession(req) || '/project'
return res.redirect(redirectURL)
}
AnalyticsManager.recordEventForUserInBackground(
userId,
'add-secondary-email-page-displayed'
)
res.render('user/addSecondaryEmail')
}
async function primaryEmailCheckPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const user = await UserGetter.promises.getUser(userId, {
lastPrimaryEmailCheck: 1,
signUpDate: 1,
email: 1,
emails: 1,
})
if (!UserPrimaryEmailCheckHandler.requiresPrimaryEmailCheck(user)) {
return res.redirect('/project')
}
AnalyticsManager.recordEventForUserInBackground(
userId,
'primary-email-check-page-displayed'
)
const { variant } = await SplitTestHandler.promises.getAssignment(
req,
res,
'bs5-auth-pages'
)
const template =
variant === 'enabled'
? 'user/primaryEmailCheck-bs5'
: 'user/primaryEmailCheck'
res.render(template)
}
async function primaryEmailCheck(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
await UserUpdater.promises.updateUser(userId, {
$set: { lastPrimaryEmailCheck: new Date() },
})
AnalyticsManager.recordEventForUserInBackground(
userId,
'primary-email-check-done'
)
// We want to redirect to prompt a user to add a secondary email if their primary
// is an institutional email and they dont' already have a secondary.
if (Features.hasFeature('saas') && req.capabilitySet.has('add-affiliation')) {
const confirmedEmails =
await UserGetter.promises.getUserConfirmedEmails(userId)
if (confirmedEmails.length < 2) {
const { email: primaryEmail } = SessionManager.getSessionUser(req.session)
const primaryEmailDomain = EmailHelper.getDomain(primaryEmail)
const institution = (
await Modules.promises.hooks.fire(
'getInstitutionViaDomain',
primaryEmailDomain
)
)?.[0]
if (institution) {
return AsyncFormHelper.redirect(req, res, '/user/emails/add-secondary')
}
}
}
AsyncFormHelper.redirect(req, res, '/project')
}
async function showConfirm(req, res, next) {
res.render('user/confirm_email', {
token: req.query.token,
title: 'confirm_email',
})
}
async function remove(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
if (!email) {
return res.sendStatus(422)
}
const auditLog = {
initiatorId: userId,
ipAddress: req.ip,
}
await UserUpdater.promises.removeEmailAddress(userId, email, auditLog)
res.sendStatus(200)
}
async function setDefault(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
if (!email) {
return res.sendStatus(422)
}
const { emails, email: oldDefault } = await UserGetter.promises.getUser(
userId,
{ email: 1, emails: 1 }
)
const primaryEmailData = emails?.find(email => email.email === oldDefault)
const deleteOldEmail =
req.query['delete-unconfirmed-primary'] !== undefined &&
primaryEmailData &&
!primaryEmailData.confirmedAt
const auditLog = {
initiatorId: userId,
ipAddress: req.ip,
}
try {
await UserUpdater.promises.setDefaultEmailAddress(
userId,
email,
false,
auditLog,
true,
deleteOldEmail
)
} catch (err) {
return UserEmailsController._handleEmailError(err, req, res, next)
}
SessionManager.setInSessionUser(req.session, { email })
const user = SessionManager.getSessionUser(req.session)
try {
await UserSessionsManager.promises.removeSessionsFromRedis(
user,
req.sessionID // remove all sessions except the current session
)
} catch (err) {
logger.warn(
{ err },
'failed revoking secondary sessions after changing default email'
)
}
if (
req.query['delete-unconfirmed-primary'] !== undefined &&
primaryEmailData &&
!primaryEmailData.confirmedAt
) {
await UserUpdater.promises.removeEmailAddress(
userId,
primaryEmailData.email,
{
initiatorId: userId,
ipAddress: req.ip,
extraInfo: {
info: 'removed unconfirmed email after setting new primary',
},
}
)
}
res.sendStatus(200)
}
const UserEmailsController = {
list(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
UserGetter.getUserFullEmails(userId, function (error, fullEmails) {
if (error) {
return next(error)
}
res.json(fullEmails)
})
},
add: expressify(add),
addWithConfirmationCode: expressify(addWithConfirmationCode),
checkNewSecondaryEmailConfirmationCode: expressify(
checkNewSecondaryEmailConfirmationCode
),
checkExistingEmailConfirmationCode: expressify(
checkExistingEmailConfirmationCode
),
resendNewSecondaryEmailConfirmationCode: expressify(
resendNewSecondaryEmailConfirmationCode
),
resendExistingSecondaryEmailConfirmationCode: expressify(
resendExistingSecondaryEmailConfirmationCode
),
remove: expressify(remove),
setDefault: expressify(setDefault),
endorse(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
if (!email) {
return res.sendStatus(422)
}
endorseAffiliation(
userId,
email,
req.body.role,
req.body.department,
function (error) {
if (error) {
return next(error)
}
res.sendStatus(204)
}
)
},
resendConfirmation: expressify(resendConfirmation),
sendReconfirmation: expressify(sendReconfirmation),
sendExistingSecondaryEmailConfirmationCode: expressify(
sendExistingSecondaryEmailConfirmationCode
),
addSecondaryEmailPage: expressify(addSecondaryEmailPage),
confirmSecondaryEmailPage: expressify(confirmSecondaryEmailPage),
primaryEmailCheckPage: expressify(primaryEmailCheckPage),
primaryEmailCheck: expressify(primaryEmailCheck),
showConfirm: expressify(showConfirm),
confirm(req, res, next) {
const { token } = req.body
if (!token) {
return res.status(422).json({
message: req.i18n.translate('confirmation_link_broken'),
})
}
UserEmailsConfirmationHandler.confirmEmailFromToken(
req,
token,
function (error, userData) {
if (error) {
if (error instanceof Errors.ForbiddenError) {
res.status(403).json({
message: {
key: 'confirm-email-wrong-user',
text: `We can’t confirm this email. You must be logged in with the Overleaf account that requested the new secondary email.`,
},
})
} else if (error instanceof Errors.NotFoundError) {
res.status(404).json({
message: req.i18n.translate('confirmation_token_invalid'),
})
} else {
next(error)
}
} else {
const { userId, email } = userData
const tokenPrefix = token.substring(0, AUDIT_LOG_TOKEN_PREFIX_LENGTH)
UserAuditLogHandler.addEntry(
userId,
'confirm-email',
userId,
req.ip,
{ token: tokenPrefix, email },
auditLogError => {
if (auditLogError) {
logger.error(
{ error: auditLogError, userId, token: tokenPrefix },
'failed to add audit log entry'
)
}
UserGetter.getUser(
userData.userId,
{ email: 1 },
function (error, user) {
if (error) {
logger.error(
{ error, userId: userData.userId },
'failed to get user'
)
}
const isPrimary = user?.email === userData.email
AnalyticsManager.recordEventForUserInBackground(
userData.userId,
'email-verified',
{
provider: 'email',
verification_type: 'link',
isPrimary,
}
)
res.sendStatus(200)
}
)
}
)
}
}
)
},
_handleEmailError(error, req, res, next) {
if (error instanceof Errors.UnconfirmedEmailError) {
return HttpErrorHandler.conflict(req, res, 'email must be confirmed')
} else if (error instanceof Errors.EmailExistsError) {
const message = req.i18n.translate('email_already_registered')
return HttpErrorHandler.conflict(req, res, message)
} else if (error.message === '422: Email does not belong to university') {
const message = req.i18n.translate('email_does_not_belong_to_university')
return HttpErrorHandler.conflict(req, res, message)
}
next(error)
},
}
module.exports = UserEmailsController