mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-28 21:23:17 -05:00
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:
parent
38c3c2338f
commit
c2448ff3d2
6 changed files with 727 additions and 6 deletions
|
@ -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.',
|
||||||
]
|
]
|
||||||
|
|
|
@ -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'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
192
services/web/test/acceptance/src/AddSecondaryEmailTests.js
Normal file
192
services/web/test/acceptance/src/AddSecondaryEmailTests.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
|
@ -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, {
|
||||||
|
|
Loading…
Reference in a new issue