diff --git a/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js b/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js
index b800e7db30..18fdf33b08 100644
--- a/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js
+++ b/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js
@@ -7,6 +7,7 @@ const UserUpdater = require('./UserUpdater')
const UserGetter = require('./UserGetter')
const { callbackify, promisify } = require('util')
const crypto = require('crypto')
+const SessionManager = require('../Authentication/SessionManager')
// Reject email confirmation tokens after 90 days
const TOKEN_EXPIRY_IN_S = 90 * 24 * 60 * 60
@@ -85,58 +86,51 @@ async function sendReconfirmationEmail(userId, email) {
await EmailHandler.promises.sendEmail('reconfirmEmail', emailOptions)
}
+async function confirmEmailFromToken(req, token) {
+ const { data } = await OneTimeTokenHandler.promises.peekValueFromToken(
+ TOKEN_USE,
+ token
+ )
+ if (!data) {
+ throw new Errors.NotFoundError('no token found')
+ }
+
+ const loggedInUserId = SessionManager.getLoggedInUserId(req.session)
+ // user_id may be stored as an ObjectId or string
+ const userId = data.user_id?.toString()
+ const email = data.email
+ if (!userId || email !== EmailHelper.parseEmail(email)) {
+ throw new Errors.NotFoundError('invalid data')
+ }
+ if (loggedInUserId !== userId) {
+ throw new Errors.ForbiddenError('logged in user does not match token user')
+ }
+ const user = await UserGetter.promises.getUser(userId, { emails: 1 })
+ if (!user) {
+ throw new Errors.NotFoundError('user not found')
+ }
+ const emailExists = user.emails.some(emailData => emailData.email === email)
+ if (!emailExists) {
+ throw new Errors.NotFoundError('email missing for user')
+ }
+
+ await OneTimeTokenHandler.promises.expireToken(TOKEN_USE, token)
+ await UserUpdater.promises.confirmEmail(userId, email)
+
+ return { userId, email }
+}
+
const UserEmailsConfirmationHandler = {
sendConfirmationEmail,
sendReconfirmationEmail: callbackify(sendReconfirmationEmail),
- confirmEmailFromToken(token, callback) {
- OneTimeTokenHandler.getValueFromTokenAndExpire(
- TOKEN_USE,
- token,
- function (error, data) {
- if (error) {
- return callback(error)
- }
- if (!data) {
- return callback(new Errors.NotFoundError('no token found'))
- }
- const userId = data.user_id
- const email = data.email
-
- if (!userId || email !== EmailHelper.parseEmail(email)) {
- return callback(new Errors.NotFoundError('invalid data'))
- }
- UserGetter.getUser(userId, { emails: 1 }, function (error, user) {
- if (error) {
- return callback(error)
- }
- if (!user) {
- return callback(new Errors.NotFoundError('user not found'))
- }
- const emailExists = user.emails.some(
- emailData => emailData.email === email
- )
- if (!emailExists) {
- return callback(new Errors.NotFoundError('email missing for user'))
- }
- UserUpdater.confirmEmail(userId, email, function (error) {
- if (error) {
- return callback(error)
- }
- callback(null, { userId, email })
- })
- })
- }
- )
- },
+ confirmEmailFromToken: callbackify(confirmEmailFromToken),
}
UserEmailsConfirmationHandler.promises = {
sendConfirmationEmail: promisify(sendConfirmationEmail),
- confirmEmailFromToken: promisify(
- UserEmailsConfirmationHandler.confirmEmailFromToken
- ),
+ confirmEmailFromToken,
sendConfirmationCode,
}
diff --git a/services/web/app/src/Features/User/UserEmailsController.js b/services/web/app/src/Features/User/UserEmailsController.js
index 297e0f86ca..523f4b6ebe 100644
--- a/services/web/app/src/Features/User/UserEmailsController.js
+++ b/services/web/app/src/Features/User/UserEmailsController.js
@@ -156,6 +156,13 @@ async function primaryEmailCheck(req, res) {
AsyncFormHelper.redirect(req, res, '/project')
}
+async function showConfirm(req, res, next) {
+ res.render('user/confirm_email', {
+ token: req.query.token,
+ title: 'confirm_email',
+ })
+}
+
const UserEmailsController = {
list(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -254,12 +261,7 @@ const UserEmailsController = {
primaryEmailCheck: expressify(primaryEmailCheck),
- showConfirm(req, res, next) {
- res.render('user/confirm_email', {
- token: req.query.token,
- title: 'confirm_email',
- })
- },
+ showConfirm: expressify(showConfirm),
confirm(req, res, next) {
const { token } = req.body
@@ -269,10 +271,18 @@ const UserEmailsController = {
})
}
UserEmailsConfirmationHandler.confirmEmailFromToken(
+ req,
token,
function (error, userData) {
if (error) {
- if (error instanceof Errors.NotFoundError) {
+ if (error instanceof Errors.ForbiddenError) {
+ res.status(403).json({
+ message: {
+ key: 'confirm-email-wrong-user',
+ text: `We can’t 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'),
})
diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js
index ca59aa73ee..8704a5c169 100644
--- a/services/web/app/src/router.js
+++ b/services/web/app/src/router.js
@@ -303,9 +303,14 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
UserController.promises.ensureAffiliationMiddleware,
UserEmailsController.list
)
- webRouter.get('/user/emails/confirm', UserEmailsController.showConfirm)
+ webRouter.get(
+ '/user/emails/confirm',
+ AuthenticationController.requireLogin(),
+ UserEmailsController.showConfirm
+ )
webRouter.post(
'/user/emails/confirm',
+ AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiters.confirmEmail),
UserEmailsController.confirm
)
diff --git a/services/web/app/views/user/confirm_email.pug b/services/web/app/views/user/confirm_email.pug
index 9527785b26..d45b5b8dba 100644
--- a/services/web/app/views/user/confirm_email.pug
+++ b/services/web/app/views/user/confirm_email.pug
@@ -4,10 +4,17 @@ block content
main.content.content-alt#main-content
.container
.row
- .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
+ .col-md-8.col-md-offset-2.col-lg-6.col-lg-offset-3
.card
- .page-header
+ .page-header(data-ol-hide-on-error-message="confirm-email-wrong-user")
h1 #{translate("confirm_email")}
+ form(
+ method="POST"
+ action="/logout"
+ id="logoutForm"
+ )
+ input(type="hidden", name="_csrf", value=csrfToken)
+ input(type="hidden", name="redirect", value=currentUrlWithQueryParams)
form(
data-ol-async-form,
data-ol-auto-submit,
@@ -21,11 +28,20 @@ block content
div(data-ol-not-sent)
+formMessages()
+ div(data-ol-custom-form-message="confirm-email-wrong-user" hidden)
+ h1.h3 #{translate("we_cant_confirm_this_email")}
+ p !{translate("to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account")}
+ p !{translate("you_are_currently_logged_in_as", {email: getUserEmail()})}
+ .actions
+ button.btn-primary.btn.btn-block(
+ form="logoutForm"
+ ) #{translate('log_in_with_a_different_account')}
.actions
button.btn-primary.btn.btn-block(
type='submit',
data-ol-disabled-inflight
+ data-ol-hide-on-error-message="confirm-email-wrong-user"
)
span(data-ol-inflight="idle")
| #{translate('confirm')}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index ebb86054d7..834f6fba59 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1029,6 +1029,7 @@
"log_in_first_to_proceed": "You will need to log in first to proceed.",
"log_in_now": "Log in now",
"log_in_with": "Log in with __provider__",
+ "log_in_with_a_different_account": "Log in with a different account",
"log_in_with_email": "Log in with __email__",
"log_in_with_existing_institution_email": "Please log in with your existing __appName__ account in order to get your __appName__ and __institutionName__ institutional accounts linked.",
"log_in_with_primary_email_address": "This will be the email address to use if you log in with an email address and password. Important __appName__ notifications will be sent to this email address.",
@@ -1913,6 +1914,7 @@
"to_add_email_accounts_need_to_be_linked_2": "To add this email, your <0>__appName__0> and <0>__institutionName__0> accounts will need to be linked.",
"to_add_more_collaborators": "To add more collaborators or turn on link sharing, please ask the project owner",
"to_change_access_permissions": "To change access permissions, please ask the project owner",
+ "to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account": "To confirm an email address, you must be logged in with the Overleaf account that requested the new secondary email.",
"to_confirm_transfer_enter_email_address": "To accept the invitation, enter the email address linked to your account.",
"to_confirm_unlink_all_users_enter_email": "To confirm you want to unlink all users, enter your email address:",
"to_fix_this_you_can": "To fix this, you can:",
@@ -2121,6 +2123,7 @@
"visual_editor": "Visual Editor",
"visual_editor_is_only_available_for_tex_files": "Visual Editor is only available for TeX files",
"want_change_to_apply_before_plan_end": "If you wish this change to apply before the end of your current billing period, please contact us.",
+ "we_cant_confirm_this_email": "We can’t confirm this email",
"we_cant_find_any_sections_or_subsections_in_this_file": "We can’t find any sections or subsections in this file",
"we_do_not_share_personal_information": "See our <0>Privacy Notice0> for details of how we treat your personal data",
"we_logged_you_in": "We have logged you in.",
@@ -2179,6 +2182,7 @@
"you_are_a_manager_of_commons_at_institution_x": "You are a <0>manager0> of the Overleaf Commons subscription at <0>__institutionName__0>",
"you_are_a_manager_of_publisher_x": "You are a <0>manager0> of <0>__publisherName__0>",
"you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are a <1>manager1> of the <0>__planName__0> group subscription <1>__groupName__1> administered by <1>__adminEmail__1>",
+ "you_are_currently_logged_in_as": "You are currently logged in as __email__.",
"you_are_on_a_paid_plan_contact_support_to_find_out_more": "You’re on an __appName__ Paid plan. <0>Contact support0> to find out more.",
"you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "You are on our <0>__planName__0> plan as a <1>confirmed member1> of <1>__institutionName__1>",
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__0> plan as a <1>member1> of the group subscription <1>__groupName__1> administered by <1>__adminEmail__1>",
diff --git a/services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js b/services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js
index 585a3cb149..f01a1c3250 100644
--- a/services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js
+++ b/services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js
@@ -24,20 +24,6 @@ const EmailHelper = require('../../../../app/src/Features/Helpers/EmailHelper')
describe('UserEmailsConfirmationHandler', function () {
beforeEach(function () {
- this.UserEmailsConfirmationHandler = SandboxedModule.require(modulePath, {
- requires: {
- '@overleaf/settings': (this.settings = {
- siteUrl: 'https://emails.example.com',
- }),
- '../Security/OneTimeTokenHandler': (this.OneTimeTokenHandler = {}),
- './UserUpdater': (this.UserUpdater = {}),
- './UserGetter': (this.UserGetter = {
- getUser: sinon.stub().yields(null, this.mockUser),
- }),
- '../Email/EmailHandler': (this.EmailHandler = {}),
- '../Helpers/EmailHelper': EmailHelper,
- },
- })
this.mockUser = {
_id: 'mock-user-id',
email: 'mock@example.com',
@@ -45,6 +31,31 @@ describe('UserEmailsConfirmationHandler', function () {
}
this.user_id = this.mockUser._id
this.email = this.mockUser.email
+ this.req = {}
+ this.UserEmailsConfirmationHandler = SandboxedModule.require(modulePath, {
+ requires: {
+ '@overleaf/settings': (this.settings = {
+ siteUrl: 'https://emails.example.com',
+ }),
+ '../Security/OneTimeTokenHandler': (this.OneTimeTokenHandler = {
+ promises: {},
+ }),
+ './UserUpdater': (this.UserUpdater = {
+ promises: {},
+ }),
+ './UserGetter': (this.UserGetter = {
+ getUser: sinon.stub().yields(null, this.mockUser),
+ promises: {
+ getUser: sinon.stub().resolves(this.mockUser),
+ },
+ }),
+ '../Email/EmailHandler': (this.EmailHandler = {}),
+ '../Helpers/EmailHelper': EmailHelper,
+ '../Authentication/SessionManager': (this.SessionManager = {
+ getLoggedInUserId: sinon.stub().returns(this.mockUser._id),
+ }),
+ },
+ })
return (this.callback = sinon.stub())
})
@@ -127,122 +138,139 @@ describe('UserEmailsConfirmationHandler', function () {
describe('confirmEmailFromToken', function () {
beforeEach(function () {
- this.OneTimeTokenHandler.getValueFromTokenAndExpire = sinon
+ this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
- .yields(null, { user_id: this.user_id, email: this.email })
- return (this.UserUpdater.confirmEmail = sinon.stub().yields())
+ .resolves({ data: { user_id: this.user_id, email: this.email } })
+ this.OneTimeTokenHandler.promises.expireToken = sinon.stub().resolves()
+ this.UserUpdater.promises.confirmEmail = sinon.stub().resolves()
})
describe('successfully', function () {
- beforeEach(function () {
- return this.UserEmailsConfirmationHandler.confirmEmailFromToken(
- (this.token = 'mock-token'),
- this.callback
+ beforeEach(async function () {
+ await this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
+ this.req,
+ (this.token = 'mock-token')
)
})
- it('should call getValueFromTokenAndExpire', function () {
- return this.OneTimeTokenHandler.getValueFromTokenAndExpire
+ it('should call peekValueFromToken', function () {
+ return this.OneTimeTokenHandler.promises.peekValueFromToken
+ .calledWith('email_confirmation', this.token)
+ .should.equal(true)
+ })
+
+ it('should call expireToken', function () {
+ return this.OneTimeTokenHandler.promises.expireToken
.calledWith('email_confirmation', this.token)
.should.equal(true)
})
it('should confirm the email of the user_id', function () {
- return this.UserUpdater.confirmEmail
+ return this.UserUpdater.promises.confirmEmail
.calledWith(this.user_id, this.email)
.should.equal(true)
})
-
- it('should call the callback', function () {
- return this.callback.called.should.equal(true)
- })
})
describe('with an expired token', function () {
beforeEach(function () {
- this.OneTimeTokenHandler.getValueFromTokenAndExpire = sinon
+ this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
- .yields(null, null)
- return this.UserEmailsConfirmationHandler.confirmEmailFromToken(
- (this.token = 'mock-token'),
- this.callback
- )
+ .rejects(new Errors.NotFoundError('no token found'))
})
- it('should call the callback with a NotFoundError', function () {
- return this.callback
- .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
- .should.equal(true)
+ it('should reject with a NotFoundError', async function () {
+ await expect(
+ this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
+ this.req,
+ (this.token = 'mock-token')
+ )
+ ).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no user_id in the token', function () {
beforeEach(function () {
- this.OneTimeTokenHandler.getValueFromTokenAndExpire = sinon
+ this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
- .yields(null, { email: this.email })
- return this.UserEmailsConfirmationHandler.confirmEmailFromToken(
- (this.token = 'mock-token'),
- this.callback
- )
+ .resolves({ data: { email: this.email } })
})
- it('should call the callback with a NotFoundError', function () {
- return this.callback
- .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
- .should.equal(true)
+ it('should reject with a NotFoundError', async function () {
+ await expect(
+ this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
+ this.req,
+ (this.token = 'mock-token')
+ )
+ ).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no email in the token', function () {
beforeEach(function () {
- this.OneTimeTokenHandler.getValueFromTokenAndExpire = sinon
+ this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
- .yields(null, { user_id: this.user_id })
- return this.UserEmailsConfirmationHandler.confirmEmailFromToken(
- (this.token = 'mock-token'),
- this.callback
- )
+ .resolves({ data: { user_id: this.user_id } })
})
- it('should call the callback with a NotFoundError', function () {
- return this.callback
- .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
- .should.equal(true)
+ it('should reject with a NotFoundError', async function () {
+ await expect(
+ this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
+ this.req,
+ (this.token = 'mock-token')
+ )
+ ).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no user found', function () {
beforeEach(function () {
- this.UserGetter.getUser.yields(null, null)
- return this.UserEmailsConfirmationHandler.confirmEmailFromToken(
- (this.token = 'mock-token'),
- this.callback
- )
+ this.UserGetter.promises.getUser.resolves(null)
})
- it('should call the callback with a NotFoundError', function () {
- return this.callback
- .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
- .should.equal(true)
+ it('should reject with a NotFoundError', async function () {
+ await expect(
+ this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
+ this.req,
+ (this.token = 'mock-token')
+ )
+ ).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with secondary email missing on user', function () {
beforeEach(function () {
- this.OneTimeTokenHandler.getValueFromTokenAndExpire = sinon
+ this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
- .yields(null, { user_id: this.user_id, email: 'deleted@email.com' })
- return this.UserEmailsConfirmationHandler.confirmEmailFromToken(
- (this.token = 'mock-token'),
- this.callback
- )
+ .resolves({
+ data: { user_id: this.user_id, email: 'deleted@email.com' },
+ })
})
- it('should call the callback with a NotFoundError', function () {
- return this.callback
- .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
- .should.equal(true)
+ it('should reject with a NotFoundError', async function () {
+ await expect(
+ this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
+ this.req,
+ (this.token = 'mock-token')
+ )
+ ).to.be.rejectedWith(Errors.NotFoundError)
+ })
+ })
+
+ describe('when the logged in user is not the token user', function () {
+ beforeEach(function () {
+ this.SessionManager.getLoggedInUserId = sinon
+ .stub()
+ .returns('other-user-id')
+ })
+
+ it('should reject with a ForbiddenError', async function () {
+ await expect(
+ this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
+ this.req,
+ (this.token = 'mock-token')
+ )
+ ).to.be.rejectedWith(Errors.ForbiddenError)
})
})
})
diff --git a/services/web/test/unit/src/User/UserEmailsControllerTests.js b/services/web/test/unit/src/User/UserEmailsControllerTests.js
index b16f91498a..cf81d6b4ff 100644
--- a/services/web/test/unit/src/User/UserEmailsControllerTests.js
+++ b/services/web/test/unit/src/User/UserEmailsControllerTests.js
@@ -440,7 +440,7 @@ describe('UserEmailsController', function () {
it('should confirm the email from the token', function () {
this.UserEmailsConfirmationHandler.confirmEmailFromToken
- .calledWith(this.token)
+ .calledWith(this.req, this.token)
.should.equal(true)
})