Merge pull request #17947 from overleaf/dp-secondary-email-confirmation-code

Add endpoints for secondary email confirmation by code

GitOrigin-RevId: c2829672fd9aeca457f76958d4922b9c95086f26
This commit is contained in:
David
2024-04-26 08:04:00 +00:00
committed by Copybot
parent 38c3c2338f
commit c2448ff3d2
6 changed files with 727 additions and 6 deletions
@@ -257,10 +257,12 @@ templates.confirmCode = NoCTAEmailTemplate({
return 'Confirm your email address'
},
message(opts, isPlainText) {
const msg = [
`Welcome to Overleaf! We're so glad you joined us.`,
'Use this 6-digit confirmation code to finish your setup.',
]
const msg = opts.isSecondary
? ['Use this 6-digit code to confirm your email address.']
: [
`Welcome to Overleaf! We're so glad you joined us.`,
'Use this 6-digit confirmation code to finish your setup.',
]
if (isPlainText && opts.confirmCode) {
msg.push(opts.confirmCode)
@@ -43,7 +43,7 @@ function sendConfirmationEmail(userId, email, emailTemplate, callback) {
)
}
async function sendConfirmationCode(email) {
async function sendConfirmationCode(email, isSecondary) {
if (!EmailHelper.parseEmail(email)) {
throw new Error('invalid email')
}
@@ -55,6 +55,7 @@ async function sendConfirmationCode(email) {
await EmailHandler.promises.sendEmail('confirmCode', {
to: email,
confirmCode,
isSecondary,
category: ['ConfirmEmail'],
})
@@ -1,3 +1,4 @@
const AuthenticationController = require('../Authentication/AuthenticationController')
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const SessionManager = require('../Authentication/SessionManager')
@@ -15,9 +16,34 @@ 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 tsscmp = require('tsscmp')
const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
const sendSecondaryConfirmCodeRateLimiter = new RateLimiter(
'send-secondary-confirmation-code',
{
points: 1,
duration: 60,
}
)
const checkSecondaryConfirmCodeRateLimiter = new RateLimiter(
'check-secondary-confirmation-code-per-email',
{
points: 10,
duration: 60,
}
)
const resendSecondaryConfirmCodeRateLimiter = new RateLimiter(
'resend-secondary-confirmation-code',
{
points: 1,
duration: 60,
}
)
async function _sendSecurityAlertEmail(user, email) {
const emailOptions = {
to: user.email,
@@ -30,6 +56,10 @@ async function _sendSecurityAlertEmail(user, email) {
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)
@@ -127,6 +157,263 @@ function sendReconfirmation(req, res, next) {
})
}
/**
* 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)
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 sendSecondaryConfirmCodeRateLimiter.consume(email, 1, {
method: 'email',
})
await UserAuditLogHandler.promises.addEntry(
userId,
'request-add-email-code',
userId,
req.ip,
{
newSecondaryEmail: email,
}
)
const { confirmCode, confirmCodeExpiresTimestamp } =
await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
email,
true
)
req.session.pendingSecondaryEmail = {
email,
confirmCode,
confirmCodeExpiresTimestamp,
}
return res.json({
redir: '/user/emails/confirm-secondary',
})
} 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',
},
})
}
}
async function checkSecondaryEmailConfirmationCode(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,
})
if (!req.session.pendingSecondaryEmail) {
logger.err(
{},
'error checking confirmation code. missing pendingSecondaryEmail'
)
return res.status(500).json({
message: {
key: 'error_performing_request',
},
})
}
try {
await checkSecondaryConfirmCodeRateLimiter.consume(
req.session.pendingSecondaryEmail.email,
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 (
req.session.pendingSecondaryEmail.confirmCodeExpiresTimestamp < Date.now()
) {
return res.status(403).json({
message: { key: 'expired_confirmation_code' },
})
}
if (!tsscmp(req.session.pendingSecondaryEmail.confirmCode, code)) {
return res.status(403).json({
message: { key: 'invalid_confirmation_code' },
})
}
try {
await UserAuditLogHandler.promises.addEntry(
userId,
'add-email-via-code',
userId,
req.ip,
{
newSecondaryEmail: req.session.pendingSecondaryEmail.email,
}
)
await UserUpdater.promises.addEmailAddress(
userId,
req.session.pendingSecondaryEmail.email,
{},
{
initiatorId: user._id,
ipAddress: req.ip,
}
)
await UserUpdater.promises.confirmEmail(
userId,
req.session.pendingSecondaryEmail.email,
{}
)
delete req.session.pendingSecondaryEmail
AnalyticsManager.recordEventForUser(user._id, 'email-verified', {
provider: 'email',
verification_type: 'token',
isPrimary: false,
})
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',
},
})
}
}
async function resendSecondaryEmailConfirmationCode(req, res) {
if (!req.session.pendingSecondaryEmail) {
logger.err(
{},
'error resending confirmation code. missing pendingSecondaryEmail'
)
return res.status(500).json({
message: {
key: 'error_performing_request',
},
})
}
const email = req.session.pendingSecondaryEmail.email
try {
await resendSecondaryConfirmCodeRateLimiter.consume(email, 1, {
method: 'email',
})
} catch (err) {
if (err?.remainingPoints === 0) {
return res.status(429).json({})
} else {
throw err
}
}
try {
const userId = SessionManager.getLoggedInUserId(req.session)
await UserAuditLogHandler.promises.addEntry(
userId,
'resend-add-email-code',
userId,
req.ip,
{
newSecondaryEmail: email,
}
)
const { confirmCode, confirmCodeExpiresTimestamp } =
await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
email,
true
)
req.session.pendingSecondaryEmail.confirmCode = confirmCode
req.session.pendingSecondaryEmail.confirmCodeExpiresTimestamp =
confirmCodeExpiresTimestamp
return res.status(200).json({
message: { key: 'we_sent_new_code' },
})
} catch (err) {
logger.err({ err, email }, 'failed to send confirmation code')
return res.status(500).json({
key: 'error_performing_request',
})
}
}
async function primaryEmailCheckPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const user = await UserGetter.promises.getUser(userId, {
@@ -175,6 +462,13 @@ const UserEmailsController = {
},
add: expressify(add),
addWithConfirmationCode: expressify(addWithConfirmationCode),
checkSecondaryEmailConfirmationCode: expressify(
checkSecondaryEmailConfirmationCode
),
resendSecondaryEmailConfirmationCode: expressify(
resendSecondaryEmailConfirmationCode
),
remove(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
+24
View File
@@ -366,6 +366,30 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail),
UserEmailsController.endorse
)
webRouter.post(
'/user/emails/secondary',
AuthenticationController.requireLogin(),
PermissionsController.requirePermission('add-secondary-email'),
RateLimiterMiddleware.rateLimit(rateLimiters.addEmail),
UserEmailsController.addWithConfirmationCode
)
webRouter.post(
'/user/emails/confirm-secondary',
AuthenticationController.requireLogin(),
PermissionsController.requirePermission('add-secondary-email'),
RateLimiterMiddleware.rateLimit(rateLimiters.checkEmailConfirmationCode),
UserEmailsController.checkSecondaryEmailConfirmationCode
)
webRouter.post(
'/user/emails/resend-secondary-confirmation',
AuthenticationController.requireLogin(),
PermissionsController.requirePermission('add-secondary-email'),
RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmationCode),
UserEmailsController.resendSecondaryEmailConfirmationCode
)
}
webRouter.get(