Files
overleaf-cep/services/web/app/src/Features/User/UserEmailsController.js
Tim Down a77f218a77 Merge pull request #25805 from overleaf/td-bs5-rename-auth-pages-feature-flag
Change auth pages feature flag

GitOrigin-RevId: 091b2cde7cc4f91e2ce7533d610db773fc622bb5
2025-05-23 08:05:14 +00:00

830 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: [
`<span style="display:inline-block;padding: 0 20px;width:100%;">Added: <br/><b>${email}</b></span>`,
],
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<void>}
*/
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<void>} 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 cant 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