diff --git a/services/web/app/src/Features/Email/EmailBuilder.js b/services/web/app/src/Features/Email/EmailBuilder.js index ad0bdc3eda..46d014a8e1 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.js +++ b/services/web/app/src/Features/Email/EmailBuilder.js @@ -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) diff --git a/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js b/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js index 18fdf33b08..6c5c369195 100644 --- a/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js +++ b/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js @@ -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'], }) diff --git a/services/web/app/src/Features/User/UserEmailsController.js b/services/web/app/src/Features/User/UserEmailsController.js index 523f4b6ebe..cfb692798d 100644 --- a/services/web/app/src/Features/User/UserEmailsController.js +++ b/services/web/app/src/Features/User/UserEmailsController.js @@ -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) diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 1da4608b03..c2e149aa03 100644 --- a/services/web/app/src/router.js +++ b/services/web/app/src/router.js @@ -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( diff --git a/services/web/test/acceptance/src/AddSecondaryEmailTests.js b/services/web/test/acceptance/src/AddSecondaryEmailTests.js new file mode 100644 index 0000000000..526d69e724 --- /dev/null +++ b/services/web/test/acceptance/src/AddSecondaryEmailTests.js @@ -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) + }) +}) diff --git a/services/web/test/unit/src/User/UserEmailsControllerTests.js b/services/web/test/unit/src/User/UserEmailsControllerTests.js index cf81d6b4ff..14fd99b841 100644 --- a/services/web/test/unit/src/User/UserEmailsControllerTests.js +++ b/services/web/test/unit/src/User/UserEmailsControllerTests.js @@ -17,7 +17,7 @@ describe('UserEmailsController', function () { this.user = { _id: 'mock-user-id', email: 'example@overleaf.com', - emails: {}, + emails: [], } this.UserGetter = { @@ -25,6 +25,7 @@ describe('UserEmailsController', function () { getUserFullEmails: sinon.stub(), getUserByAnyEmail: sinon.stub(), promises: { + ensureUniqueEmailAddress: sinon.stub().resolves(), getUser: sinon.stub().resolves(this.user), }, } @@ -46,6 +47,7 @@ describe('UserEmailsController', function () { updateV1AndSetDefaultEmailAddress: sinon.stub(), promises: { addEmailAddress: sinon.stub().resolves(), + confirmEmail: sinon.stub().resolves(), }, } this.EmailHelper = { parseEmail: sinon.stub() } @@ -59,9 +61,23 @@ describe('UserEmailsController', function () { } this.UserAuditLogHandler = { 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, { requires: { + '../Authentication/AuthenticationController': + this.AuthenticationController, '../Authentication/SessionManager': this.SessionManager, '../../infrastructure/Features': this.Features, './UserSessionsManager': this.UserSessionsManager, @@ -84,6 +100,7 @@ describe('UserEmailsController', function () { '../Errors/HttpErrorHandler': this.HttpErrorHandler, '../Analytics/AnalyticsManager': this.AnalyticsManager, './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 () { beforeEach(function () { this.email = 'email_to_remove@bar.com' @@ -541,6 +748,7 @@ describe('UserEmailsController', function () { expect(this.res.sendStatus.lastCall.args[0]).to.equal(422) done() }) + describe('email on another user account', function () { beforeEach(function () { this.UserGetter.getUserByAnyEmail.yields(undefined, {