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-25 09:41:04 +01:00 committed by Copybot
parent 38c3c2338f
commit c2448ff3d2
6 changed files with 727 additions and 6 deletions

View file

@ -257,7 +257,9 @@ templates.confirmCode = NoCTAEmailTemplate({
return 'Confirm your email address' return 'Confirm your email address'
}, },
message(opts, isPlainText) { message(opts, isPlainText) {
const msg = [ const msg = opts.isSecondary
? ['Use this 6-digit code to confirm your email address.']
: [
`Welcome to Overleaf! We're so glad you joined us.`, `Welcome to Overleaf! We're so glad you joined us.`,
'Use this 6-digit confirmation code to finish your setup.', 'Use this 6-digit confirmation code to finish your setup.',
] ]

View file

@ -43,7 +43,7 @@ function sendConfirmationEmail(userId, email, emailTemplate, callback) {
) )
} }
async function sendConfirmationCode(email) { async function sendConfirmationCode(email, isSecondary) {
if (!EmailHelper.parseEmail(email)) { if (!EmailHelper.parseEmail(email)) {
throw new Error('invalid email') throw new Error('invalid email')
} }
@ -55,6 +55,7 @@ async function sendConfirmationCode(email) {
await EmailHandler.promises.sendEmail('confirmCode', { await EmailHandler.promises.sendEmail('confirmCode', {
to: email, to: email,
confirmCode, confirmCode,
isSecondary,
category: ['ConfirmEmail'], category: ['ConfirmEmail'],
}) })

View file

@ -1,3 +1,4 @@
const AuthenticationController = require('../Authentication/AuthenticationController')
const Settings = require('@overleaf/settings') const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const SessionManager = require('../Authentication/SessionManager') const SessionManager = require('../Authentication/SessionManager')
@ -15,9 +16,34 @@ const AsyncFormHelper = require('../Helpers/AsyncFormHelper')
const AnalyticsManager = require('../Analytics/AnalyticsManager') const AnalyticsManager = require('../Analytics/AnalyticsManager')
const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler') const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
const UserAuditLogHandler = require('./UserAuditLogHandler') const UserAuditLogHandler = require('./UserAuditLogHandler')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const tsscmp = require('tsscmp')
const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10 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) { async function _sendSecurityAlertEmail(user, email) {
const emailOptions = { const emailOptions = {
to: user.email, to: user.email,
@ -30,6 +56,10 @@ async function _sendSecurityAlertEmail(user, email) {
await EmailHandler.promises.sendEmail('securityAlert', emailOptions) 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) { async function add(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email) 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) { async function primaryEmailCheckPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const user = await UserGetter.promises.getUser(userId, { const user = await UserGetter.promises.getUser(userId, {
@ -175,6 +462,13 @@ const UserEmailsController = {
}, },
add: expressify(add), add: expressify(add),
addWithConfirmationCode: expressify(addWithConfirmationCode),
checkSecondaryEmailConfirmationCode: expressify(
checkSecondaryEmailConfirmationCode
),
resendSecondaryEmailConfirmationCode: expressify(
resendSecondaryEmailConfirmationCode
),
remove(req, res, next) { remove(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)

View file

@ -366,6 +366,30 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail), RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail),
UserEmailsController.endorse 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( webRouter.get(

View file

@ -0,0 +1,192 @@
const { expect } = require('chai')
const User = require('./helpers/User').promises
const logger = require('@overleaf/logger')
const sinon = require('sinon')
const { db } = require('../../../app/src/infrastructure/mongodb')
const Features = require('../../../app/src/infrastructure/Features')
describe('Add secondary email address confirmation code email', function () {
let spy
let user, user2, res, confirmCode
const extractConfirmCode = () => {
const emailDebugLog = spy.args.find(
([, msg]) => msg === 'Would send email if enabled.'
)
const emailConfirmSubject = emailDebugLog[0].options.subject
return emailConfirmSubject.match(/\((\d{6})\)/)[1]
}
beforeEach(async function () {
if (!Features.hasFeature('affiliations')) {
this.skip()
}
spy = sinon.spy(logger, 'debug')
user = new User()
await user.register()
await user.login()
spy.resetHistory()
res = await user.doRequest('POST', {
json: {
email: 'secondary@overleaf.com',
},
uri: `/user/emails/secondary`,
})
confirmCode = extractConfirmCode()
})
afterEach(function () {
if (!Features.hasFeature('affiliations')) {
this.skip()
}
spy.restore()
})
it('should redirect to confirm secondary email page', function () {
expect(res.response.statusCode).to.equal(200)
expect(res.body.redir).to.equal('/user/emails/confirm-secondary')
expect(confirmCode.length).to.equal(6)
})
describe('with a valid confirmation code', function () {
beforeEach(async function () {
this.result = await user.doRequest('POST', {
json: {
code: confirmCode,
},
uri: '/user/emails/confirm-secondary',
})
})
it('should redirect to /project', async function () {
expect(this.result.response.statusCode).to.equal(200)
expect(this.result.body.redir).to.equal('/project')
})
it('the new email should be saved in mongo', async function () {
const userInDb = await db.users.findOne(
{ email: user.email },
{ projection: { emails: 1 } }
)
expect(userInDb).to.exist
const newSecondaryEmail = userInDb.emails.find(
email => email.email === 'secondary@overleaf.com'
)
expect(newSecondaryEmail).to.exist
expect(newSecondaryEmail.confirmedAt).to.exist
expect(newSecondaryEmail.reconfirmedAt).to.exist
expect(newSecondaryEmail.reconfirmedAt).to.deep.equal(
newSecondaryEmail.confirmedAt
)
})
})
describe('with an invalid confirmation code', function () {
beforeEach(async function () {
this.result = await user.doRequest('POST', {
json: {
code: '123',
},
uri: '/user/emails/confirm-secondary',
})
})
it('should respond with invalid confirmation code error', async function () {
expect(this.result.response.statusCode).to.equal(403)
expect(this.result.body.message.key).to.equal('invalid_confirmation_code')
})
})
describe('with a duplicate email', async function () {
beforeEach(async function () {
await user.doRequest('POST', {
json: {
code: confirmCode,
},
uri: '/user/emails/confirm-secondary',
})
user2 = new User()
await user2.register()
await user2.login()
})
it('should respond with a email already registered error', async function () {
res = await user2.doRequest('POST', {
json: {
email: 'secondary@overleaf.com',
},
uri: `/user/emails/secondary`,
})
expect(res.response.statusCode).to.equal(409)
expect(res.body.message.text).to.equal('This email is already registered')
})
})
it('should hit rate limit on code check', async function () {
let confirmEmailReq
for (let i = 0; i < 20; i++) {
confirmEmailReq = await user.doRequest('POST', {
json: {
code: '123',
},
uri: '/user/emails/confirm-secondary',
})
}
expect(confirmEmailReq.response.statusCode).to.equal(429)
})
it('should resend confirm code', async function () {
const oldConfirmCode = extractConfirmCode()
spy.resetHistory()
const resendCodeRes = await user.doRequest('POST', {
uri: '/user/emails/resend-secondary-confirmation',
})
const newConfirmCode = extractConfirmCode()
expect(resendCodeRes.response.statusCode).to.equal(200)
expect(JSON.parse(resendCodeRes.body).message.key).to.equal(
'we_sent_new_code'
)
const oldConfirmRes = await user.doRequest('POST', {
json: {
code: oldConfirmCode,
},
uri: '/user/emails/confirm-secondary',
})
expect(oldConfirmRes.response.statusCode).to.equal(403)
expect(oldConfirmRes.body.message.key).to.equal('invalid_confirmation_code')
const newCodeRes = await user.doRequest('POST', {
json: {
code: newConfirmCode,
},
uri: '/user/emails/confirm-secondary',
})
expect(newCodeRes.response.statusCode).to.equal(200)
expect(newCodeRes.body.redir).to.equal('/project')
})
it('should hit rate limit on code resend', async function () {
let resendCodeReq
for (let i = 0; i < 5; i++) {
resendCodeReq = await user.doRequest('POST', {
json: true,
uri: '/user/emails/resend-secondary-confirmation',
})
}
expect(resendCodeReq.response.statusCode).to.equal(429)
})
})

View file

@ -17,7 +17,7 @@ describe('UserEmailsController', function () {
this.user = { this.user = {
_id: 'mock-user-id', _id: 'mock-user-id',
email: 'example@overleaf.com', email: 'example@overleaf.com',
emails: {}, emails: [],
} }
this.UserGetter = { this.UserGetter = {
@ -25,6 +25,7 @@ describe('UserEmailsController', function () {
getUserFullEmails: sinon.stub(), getUserFullEmails: sinon.stub(),
getUserByAnyEmail: sinon.stub(), getUserByAnyEmail: sinon.stub(),
promises: { promises: {
ensureUniqueEmailAddress: sinon.stub().resolves(),
getUser: sinon.stub().resolves(this.user), getUser: sinon.stub().resolves(this.user),
}, },
} }
@ -46,6 +47,7 @@ describe('UserEmailsController', function () {
updateV1AndSetDefaultEmailAddress: sinon.stub(), updateV1AndSetDefaultEmailAddress: sinon.stub(),
promises: { promises: {
addEmailAddress: sinon.stub().resolves(), addEmailAddress: sinon.stub().resolves(),
confirmEmail: sinon.stub().resolves(),
}, },
} }
this.EmailHelper = { parseEmail: sinon.stub() } this.EmailHelper = { parseEmail: sinon.stub() }
@ -59,9 +61,23 @@ describe('UserEmailsController', function () {
} }
this.UserAuditLogHandler = { this.UserAuditLogHandler = {
addEntry: sinon.stub().yields(), addEntry: sinon.stub().yields(),
promises: {
addEntry: sinon.stub().resolves(),
},
}
this.rateLimiter = {
consume: sinon.stub().resolves(),
}
this.RateLimiter = {
RateLimiter: sinon.stub().returns(this.rateLimiter),
}
this.AuthenticationController = {
getRedirectFromSession: sinon.stub().returns(null),
} }
this.UserEmailsController = SandboxedModule.require(modulePath, { this.UserEmailsController = SandboxedModule.require(modulePath, {
requires: { requires: {
'../Authentication/AuthenticationController':
this.AuthenticationController,
'../Authentication/SessionManager': this.SessionManager, '../Authentication/SessionManager': this.SessionManager,
'../../infrastructure/Features': this.Features, '../../infrastructure/Features': this.Features,
'./UserSessionsManager': this.UserSessionsManager, './UserSessionsManager': this.UserSessionsManager,
@ -84,6 +100,7 @@ describe('UserEmailsController', function () {
'../Errors/HttpErrorHandler': this.HttpErrorHandler, '../Errors/HttpErrorHandler': this.HttpErrorHandler,
'../Analytics/AnalyticsManager': this.AnalyticsManager, '../Analytics/AnalyticsManager': this.AnalyticsManager,
'./UserAuditLogHandler': this.UserAuditLogHandler, './UserAuditLogHandler': this.UserAuditLogHandler,
'../../infrastructure/RateLimiter': this.RateLimiter,
}, },
}) })
}) })
@ -267,6 +284,196 @@ describe('UserEmailsController', function () {
}) })
}) })
describe('addWithConfirmationCode', function () {
beforeEach(function () {
this.newEmail = 'new_email@baz.com'
this.req.body = {
email: this.newEmail,
}
this.EmailHelper.parseEmail.returns(this.newEmail)
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
.stub()
.resolves({
confirmCode: '123456',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('sends an email confirmation', function (done) {
this.UserEmailsController.addWithConfirmationCode(this.req, {
json: ({ redir }) => {
redir.should.equal('/user/emails/confirm-secondary')
assertCalledWith(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
this.newEmail,
true
)
done()
},
})
})
it('handles email parse error', function (done) {
this.EmailHelper.parseEmail.returns(null)
this.UserEmailsController.addWithConfirmationCode(this.req, {
sendStatus: code => {
code.should.equal(422)
done()
},
})
})
it('handles when the email already exists', function (done) {
this.UserGetter.promises.ensureUniqueEmailAddress.rejects(
new Errors.EmailExistsError()
)
this.UserEmailsController.addWithConfirmationCode(this.req, {
status: code => {
code.should.equal(409)
return { json: () => done() }
},
})
})
it('should fail to add new emails when the limit has been reached', function (done) {
this.user.emails = []
for (let i = 0; i < 10; i++) {
this.user.emails.push({ email: `example${i}@overleaf.com` })
}
this.UserEmailsController.addWithConfirmationCode(this.req, {
status: code => {
expect(code).to.equal(422)
return {
json: error => {
expect(error.message).to.equal('secondary email limit exceeded')
done()
},
}
},
})
})
})
describe('checkSecondaryEmailConfirmationCode', function () {
beforeEach(function () {
this.newEmail = 'new_email@baz.com'
this.req.session.pendingSecondaryEmail = {
confirmCode: '123456',
email: this.newEmail,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
})
describe('with a valid confirmation code', function () {
beforeEach(function () {
this.req.body = {
code: '123456',
}
})
it('adds the email', function (done) {
this.UserEmailsController.checkSecondaryEmailConfirmationCode(
this.req,
{
json: () => {
assertCalledWith(
this.UserUpdater.promises.addEmailAddress,
this.user._id,
this.newEmail
)
assertCalledWith(
this.UserUpdater.promises.confirmEmail,
this.user._id,
this.newEmail
)
done()
},
}
)
})
it('redirects to /project', function (done) {
this.UserEmailsController.checkSecondaryEmailConfirmationCode(
this.req,
{
json: ({ redir }) => {
redir.should.equal('/project')
done()
},
}
)
})
})
describe('with an invalid confirmation code', function () {
beforeEach(function () {
this.req.body = {
code: '999999',
}
})
it('does not add the email', function (done) {
this.UserEmailsController.checkSecondaryEmailConfirmationCode(
this.req,
{
status: () => {
assertNotCalled(this.UserUpdater.promises.addEmailAddress)
assertNotCalled(this.UserUpdater.promises.confirmEmail)
done()
return { json: this.next }
},
}
)
})
it('responds with a 403', function (done) {
this.UserEmailsController.checkSecondaryEmailConfirmationCode(
this.req,
{
status: code => {
code.should.equal(403)
done()
return { json: this.next }
},
}
)
})
})
})
describe('resendSecondaryEmailConfirmationCode', function () {
beforeEach(function () {
this.newEmail = 'new_email@baz.com'
this.req.session.pendingSecondaryEmail = {
confirmCode: '123456',
email: this.newEmail,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
.stub()
.resolves({
confirmCode: '123456',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('should send the email', function (done) {
this.UserEmailsController.resendSecondaryEmailConfirmationCode(this.req, {
status: code => {
code.should.equal(200)
assertCalledWith(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
this.newEmail,
true
)
done()
return { json: this.next }
},
})
})
})
describe('remove', function () { describe('remove', function () {
beforeEach(function () { beforeEach(function () {
this.email = 'email_to_remove@bar.com' this.email = 'email_to_remove@bar.com'
@ -541,6 +748,7 @@ describe('UserEmailsController', function () {
expect(this.res.sendStatus.lastCall.args[0]).to.equal(422) expect(this.res.sendStatus.lastCall.args[0]).to.equal(422)
done() done()
}) })
describe('email on another user account', function () { describe('email on another user account', function () {
beforeEach(function () { beforeEach(function () {
this.UserGetter.getUserByAnyEmail.yields(undefined, { this.UserGetter.getUserByAnyEmail.yields(undefined, {