mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[web] Replace token-link email verification with 6-digit code on SSO registration (ORCID) (#33889)
* Replace token-link email with 6-digit code on SSO registration Unverified SSO emails previously received a long-lived token link (90-day TTL) via UserEmailsConfirmationHandler. This replaces that flow with the same 6-digit code verification used for password registration, redirecting through /registration/confirm-email. - SSOManager.registerSSO now always confirms email (caller must verify first); removes sendConfirmationEmail / _finishRegistration - SSOController._signUp sends confirmation code and stores pendingSSORegistration in session when IdP email_verified is false - New SSOConfirmEmailHandler completes registration after code check via completeSSOEmailConfirmation module hook - OnboardingController confirm-email handlers accept pendingSSORegistration alongside pendingUserRegistration confirmEmailFromToken (POST /user/emails/confirm) removal is deferred to a follow-up PR to avoid breaking in-flight 90-day tokens. Closes #28607 * Fix unverified-email edge cases; Add ORCID e2e tests; * Rename `confirmEmail` parameter to `emailVerifiedByIdP` in _signUp function * Remove `sendConfirmationEmail` * Mock getUserByAnyEmail in tests * Extract _finishSSORegistration helper to deduplicate the register → set session flags → allocate referral → finishSaasLogin → finishLogin sequence shared by both the direct and deferred (code-confirmed) paths. * Stop duplicating session data in pendingSSORegistration analyticsId, splitTests, and referal_* are already in the session at confirmation time — no need to copy them into pendingSSORegistration. Re-fetch splitTests fresh on completion instead. * Simplify the code * Remove dead confirmEmail template No callers remain after sendConfirmationEmail was deleted. The token-link flow (confirmEmailFromToken) only validates tokens, never sends email. * Remove dead reconfirmEmail template * Address comments from Copilot * Clear stale pending registration when starting a new flow * Add unit tests for completeSSOEmailConfirmation * Add `verificationMethod` param * Fix camelcase issues * Extract _createSSOUser and _registerAndFinish helpers to deduplicate registration logic * Remove obscure "registration_error" * Prevent FormTextIcon from shrinking * Enable "email_already_registered_sso" error * Misc. improvements to confirm-email-form.tsx * Remove `UserEmailsConfirmationHandler` mock Co-authored-by: Olzhas Askar <olzhas.askar@overleaf.com> * Add info on sso_email.pug page --------- Co-authored-by: Olzhas Askar <olzhas.askar@overleaf.com> GitOrigin-RevId: d0196ebc6d81ff61bcd27726d0b899b743d08d64
This commit is contained in:
committed by
Copybot
co-authored by
Olzhas Askar
parent
2570b6559d
commit
3140e46e68
@@ -271,32 +271,6 @@ templates.passwordResetRequested = ctaTemplate({
|
||||
},
|
||||
})
|
||||
|
||||
templates.confirmEmail = ctaTemplate({
|
||||
subject() {
|
||||
return `Confirm email - ${settings.appName}`
|
||||
},
|
||||
title() {
|
||||
return 'Confirm email'
|
||||
},
|
||||
message(opts) {
|
||||
return [
|
||||
`Please confirm that you have added a new email, ${opts.to}, to your ${settings.appName} account.`,
|
||||
]
|
||||
},
|
||||
secondaryMessage() {
|
||||
return [
|
||||
`If you did not request this, please let us know at <a href="mailto:${settings.adminEmail}">${settings.adminEmail}</a>.`,
|
||||
`If you have any questions or trouble confirming your email address, please get in touch with our support team at ${settings.adminEmail}.`,
|
||||
]
|
||||
},
|
||||
ctaText() {
|
||||
return 'Confirm email'
|
||||
},
|
||||
ctaURL(opts) {
|
||||
return opts.confirmEmailUrl
|
||||
},
|
||||
})
|
||||
|
||||
templates.confirmCode = NoCTAEmailTemplate({
|
||||
greeting(opts) {
|
||||
return ''
|
||||
@@ -391,32 +365,6 @@ templates.projectInvite = ctaTemplate({
|
||||
},
|
||||
})
|
||||
|
||||
templates.reconfirmEmail = ctaTemplate({
|
||||
subject() {
|
||||
return `Reconfirm Email - ${settings.appName}`
|
||||
},
|
||||
title() {
|
||||
return 'Reconfirm Email'
|
||||
},
|
||||
message(opts) {
|
||||
return [
|
||||
`Please reconfirm your email address, ${opts.to}, on your ${settings.appName} account.`,
|
||||
]
|
||||
},
|
||||
secondaryMessage() {
|
||||
return [
|
||||
'If you did not request this, you can simply ignore this message.',
|
||||
`If you have any questions or trouble confirming your email address, please get in touch with our support team at ${settings.adminEmail}.`,
|
||||
]
|
||||
},
|
||||
ctaText() {
|
||||
return 'Reconfirm email'
|
||||
},
|
||||
ctaURL(opts) {
|
||||
return opts.confirmEmailUrl
|
||||
},
|
||||
})
|
||||
|
||||
templates.verifyEmailToJoinTeam = ctaTemplate({
|
||||
subject(opts) {
|
||||
return `${opts.reminder ? 'Reminder: ' : ''}${_.escape(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
||||
import EmailHandler from '../Email/EmailHandler.mjs'
|
||||
import OneTimeTokenHandler from '../Security/OneTimeTokenHandler.mjs'
|
||||
import settings from '@overleaf/settings'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
import UserUpdater from './UserUpdater.mjs'
|
||||
import UserGetter from './UserGetter.mjs'
|
||||
@@ -10,33 +9,9 @@ import crypto from 'node:crypto'
|
||||
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||
|
||||
// Reject email confirmation tokens after 90 days
|
||||
const TOKEN_EXPIRY_IN_S = 90 * 24 * 60 * 60
|
||||
const TOKEN_USE = 'email_confirmation'
|
||||
const CONFIRMATION_CODE_EXPIRY_IN_S = 10 * 60
|
||||
|
||||
async function sendConfirmationEmail(
|
||||
userId,
|
||||
email,
|
||||
emailTemplate = 'confirmEmail'
|
||||
) {
|
||||
email = EmailHelper.parseEmail(email)
|
||||
if (!email) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
const data = { user_id: userId, email }
|
||||
const token = await OneTimeTokenHandler.promises.getNewToken(
|
||||
TOKEN_USE,
|
||||
data,
|
||||
{ expiresIn: TOKEN_EXPIRY_IN_S }
|
||||
)
|
||||
const emailOptions = {
|
||||
to: email,
|
||||
confirmEmailUrl: `${settings.siteUrl}/user/emails/confirm?token=${token}`,
|
||||
sendingUser_id: userId,
|
||||
}
|
||||
await EmailHandler.promises.sendEmail(emailTemplate, emailOptions)
|
||||
}
|
||||
|
||||
async function sendConfirmationCode(email, welcomeUser) {
|
||||
if (!EmailHelper.parseEmail(email)) {
|
||||
throw new Error('invalid email')
|
||||
@@ -95,11 +70,9 @@ async function confirmEmailFromToken(req, token) {
|
||||
|
||||
const UserEmailsConfirmationHandler = {
|
||||
confirmEmailFromToken: callbackify(confirmEmailFromToken),
|
||||
sendConfirmationEmail: callbackify(sendConfirmationEmail),
|
||||
}
|
||||
|
||||
UserEmailsConfirmationHandler.promises = {
|
||||
sendConfirmationEmail,
|
||||
confirmEmailFromToken,
|
||||
sendConfirmationCode,
|
||||
}
|
||||
|
||||
@@ -594,6 +594,7 @@
|
||||
"email_address": "",
|
||||
"email_address_is_invalid": "",
|
||||
"email_already_registered": "",
|
||||
"email_already_registered_sso": "",
|
||||
"email_attribute": "",
|
||||
"email_does_not_belong_to_university": "",
|
||||
"email_limit_reached": "",
|
||||
|
||||
@@ -144,7 +144,6 @@ export function ConfirmEmailForm({
|
||||
|
||||
postJSON(resendEndpoint)
|
||||
.then(data => {
|
||||
setIsResending(false)
|
||||
if (data?.message?.key) {
|
||||
setFeedback({
|
||||
type: 'alert',
|
||||
@@ -334,7 +333,7 @@ function Title({
|
||||
return outerErrorDisplay ? (
|
||||
<div className="mt-4" />
|
||||
) : (
|
||||
<h3 className="h5">{outerErrorDisplay ? null : t('we_sent_code')}</h3>
|
||||
<h3 className="h5">{t('we_sent_code')}</h3>
|
||||
)
|
||||
if (interstitial)
|
||||
return <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
|
||||
@@ -416,6 +415,9 @@ function ErrorMessage({ error }: { error: string }) {
|
||||
case 'please_enter_confirmation_code':
|
||||
return <span>{t('please_enter_confirmation_code')}</span>
|
||||
|
||||
case 'email_already_registered_sso':
|
||||
return <span>{t('email_already_registered_sso')}</span>
|
||||
|
||||
default:
|
||||
return <span>{t('generic_something_went_wrong')}</span>
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ export const getFormTextClass = (type?: TextType) =>
|
||||
function FormTextIcon({ type }: { type?: TextType }) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="ciam-form-text-icon" />
|
||||
return <CheckCircle className="ciam-form-text-icon flex-shrink-0" />
|
||||
case 'error':
|
||||
return <WarningCircle className="ciam-form-text-icon" />
|
||||
return <WarningCircle className="ciam-form-text-icon flex-shrink-0" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1445,7 +1445,6 @@
|
||||
"register_with_another_email": "<a href=\"__link__\">Bliv registreret hos __appName__</a> med en anden e-mailadresse.",
|
||||
"registered": "Registreret",
|
||||
"registering": "Registrerer",
|
||||
"registration_error": "Registreringsfejl",
|
||||
"reject": "Afvis",
|
||||
"reject_change": "Afslå ændring",
|
||||
"related_tags": "Relaterede tags",
|
||||
|
||||
@@ -1245,7 +1245,6 @@
|
||||
"register_with_another_email": "<a href=\"__link__\">Registriere Dich bei __appName__</a> mit einer anderen E-Mail-Adresse.",
|
||||
"registered": "Registriert",
|
||||
"registering": "Registrieren",
|
||||
"registration_error": "Registrierungs-Fehler",
|
||||
"reject": "Verwerfen",
|
||||
"related_tags": "Ähnliche Stichwörter",
|
||||
"relink_your_account": "Verknüpfe dein Konto neu",
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
"confirm_remove_sso_config_enter_email": "To confirm you want to remove your SSO configuration, enter your email address:",
|
||||
"confirm_remove_user_type_email_address": "To confirm you want to remove __userName__ please type the email address associated with their account.",
|
||||
"confirm_secondary_email": "Confirm secondary email",
|
||||
"confirm_your_account": "Confirm your account",
|
||||
"confirm_your_email": "Confirm your email address",
|
||||
"confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.",
|
||||
"confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
|
||||
@@ -807,6 +808,7 @@
|
||||
"english_british": "English (British)",
|
||||
"ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.",
|
||||
"enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
|
||||
"enter_email_to_get_code": "Enter your email address to get a 6-digit confirmation code.",
|
||||
"enter_emails_separated_by_commas": "Enter emails separated by commas",
|
||||
"enter_manually": "Enter manually",
|
||||
"enter_tax_id_number": "Enter tax ID number",
|
||||
@@ -2110,7 +2112,6 @@
|
||||
"register_with_another_email": "<a href=\"__link__\">Register with __appName__</a> using another email.",
|
||||
"registered": "Registered",
|
||||
"registering": "Registering",
|
||||
"registration_error": "Registration error",
|
||||
"reject": "Reject",
|
||||
"reject_change": "Reject change",
|
||||
"reject_selected_changes": "Reject selected changes",
|
||||
|
||||
@@ -899,7 +899,6 @@
|
||||
"register_with_another_email": "<a href=\"__link__\">Inscrivez-vous avec __appName__</a> en utilisant une autre adresse courriel.",
|
||||
"registered": "Inscrit·e",
|
||||
"registering": "Inscription en cours",
|
||||
"registration_error": "Erreur d’inscription",
|
||||
"reject": "Rejeter",
|
||||
"related_tags": "Étiquettes associées",
|
||||
"reload_editor": "Actualiser l’éditeur",
|
||||
|
||||
@@ -474,7 +474,6 @@
|
||||
"register_to_edit_template": "Por favor, registre-se para editar o modelo __templateName__",
|
||||
"registered": "Registrado",
|
||||
"registering": "Registrando",
|
||||
"registration_error": "Erro de Registro",
|
||||
"reject": "Rejeitar",
|
||||
"remove": "remover",
|
||||
"remove_from_group": "Remover do grupo",
|
||||
|
||||
@@ -668,7 +668,6 @@
|
||||
"register_to_edit_template": "Vänligen registrera dig för att redigera __templateName__ mallen",
|
||||
"registered": "Registrerad",
|
||||
"registering": "Registrerar",
|
||||
"registration_error": "Registreringsfel",
|
||||
"reject": "Neka",
|
||||
"reload_editor": "Ladda om redigeraren",
|
||||
"remote_service_error": "Fjärrtjänsten producerade ett fel",
|
||||
|
||||
@@ -1652,7 +1652,6 @@
|
||||
"register_with_another_email": "使用另一个邮件地址<a href=\"__link__\">注册 __appName__</a>",
|
||||
"registered": "已注册",
|
||||
"registering": "正在注册",
|
||||
"registration_error": "注册错误",
|
||||
"reject": "不要",
|
||||
"reject_change": "拒绝修改",
|
||||
"reject_selected_changes": "拒绝选定的更改",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import AbstractMockApi from './AbstractMockApi.mjs'
|
||||
|
||||
class MockOrcidApi extends AbstractMockApi {
|
||||
reset() {
|
||||
this.profiles = {}
|
||||
this.tokens = {}
|
||||
}
|
||||
|
||||
// profile: { orcid, name }
|
||||
addProfile(profile, token, authorizationCode) {
|
||||
this.profiles[token] = { ...profile }
|
||||
this.tokens[authorizationCode] = token
|
||||
}
|
||||
|
||||
applyRoutes() {
|
||||
this.app.post('/oauth/token', (req, res) => {
|
||||
const token = this.tokens[req.body.code]
|
||||
if (!token) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const profile = this.profiles[token]
|
||||
res.json({
|
||||
access_token: token,
|
||||
token_type: 'bearer',
|
||||
scope: '/authenticate',
|
||||
...profile,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default MockOrcidApi
|
||||
|
||||
/**
|
||||
* @function instance
|
||||
* @memberOf MockOrcidApi
|
||||
* @static
|
||||
* @returns {MockOrcidApi}
|
||||
*/
|
||||
@@ -371,41 +371,6 @@ describe('EmailBuilder', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmEmail', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.emailAddress = 'example@overleaf.com'
|
||||
ctx.userId = 'abc123'
|
||||
ctx.opts = {
|
||||
to: ctx.emailAddress,
|
||||
confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=aToken123`,
|
||||
sendingUser_id: ctx.userId,
|
||||
}
|
||||
ctx.email = ctx.EmailBuilder.buildEmail('confirmEmail', ctx.opts)
|
||||
})
|
||||
|
||||
it('should build the email', function (ctx) {
|
||||
expect(ctx.email.html).to.exist
|
||||
expect(ctx.email.text).to.exist
|
||||
})
|
||||
|
||||
describe('HTML email', function () {
|
||||
it('should include a CTA button and a fallback CTA link', function (ctx) {
|
||||
const dom = cheerio.load(ctx.email.html)
|
||||
const buttonLink = dom('a:contains("Confirm email")')
|
||||
expect(buttonLink.length).to.equal(1)
|
||||
expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
|
||||
expect(ctx.email.html).to.contain('copy and paste this link')
|
||||
expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plain text email', function () {
|
||||
it('should contain the CTA link', function (ctx) {
|
||||
expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ownershipTransferConfirmationNewOwner', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.emailAddress = 'example@overleaf.com'
|
||||
@@ -489,41 +454,6 @@ describe('EmailBuilder', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconfirmEmail', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.emailAddress = 'example@overleaf.com'
|
||||
ctx.userId = 'abc123'
|
||||
ctx.opts = {
|
||||
to: ctx.emailAddress,
|
||||
confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=aToken123`,
|
||||
sendingUser_id: ctx.userId,
|
||||
}
|
||||
ctx.email = ctx.EmailBuilder.buildEmail('reconfirmEmail', ctx.opts)
|
||||
})
|
||||
|
||||
it('should build the email', function (ctx) {
|
||||
expect(ctx.email.html).to.exist
|
||||
expect(ctx.email.text).to.exist
|
||||
})
|
||||
|
||||
describe('HTML email', function () {
|
||||
it('should include a CTA button and a fallback CTA link', function (ctx) {
|
||||
const dom = cheerio.load(ctx.email.html)
|
||||
const buttonLink = dom('a:contains("Reconfirm email")')
|
||||
expect(buttonLink.length).to.equal(1)
|
||||
expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
|
||||
expect(ctx.email.html).to.contain('copy and paste this link')
|
||||
expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plain text email', function () {
|
||||
it('should contain the CTA link', function (ctx) {
|
||||
expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyEmailToJoinTeam', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.emailAddress = 'example@overleaf.com'
|
||||
|
||||
@@ -30,12 +30,6 @@ describe('UserEmailsConfirmationHandler', function () {
|
||||
ctx.email = ctx.mockUser.email
|
||||
ctx.req = {}
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.settings = {
|
||||
siteUrl: 'https://emails.example.com',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Security/OneTimeTokenHandler',
|
||||
() => ({
|
||||
@@ -83,72 +77,6 @@ describe('UserEmailsConfirmationHandler', function () {
|
||||
return (ctx.callback = sinon.stub())
|
||||
})
|
||||
|
||||
describe('sendConfirmationEmail', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.OneTimeTokenHandler.promises.getNewToken = sinon
|
||||
.stub()
|
||||
.resolves((ctx.token = 'new-token'))
|
||||
return (ctx.EmailHandler.promises.sendEmail = sinon.stub().resolves())
|
||||
})
|
||||
|
||||
describe('successfully', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
|
||||
ctx.user_id,
|
||||
ctx.email
|
||||
)
|
||||
})
|
||||
|
||||
it('should generate a token for the user which references their id and email', function (ctx) {
|
||||
return ctx.OneTimeTokenHandler.promises.getNewToken
|
||||
.calledWith(
|
||||
'email_confirmation',
|
||||
{ user_id: ctx.user_id, email: ctx.email },
|
||||
{ expiresIn: 90 * 24 * 60 * 60 }
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should send an email to the user', function (ctx) {
|
||||
return ctx.EmailHandler.promises.sendEmail
|
||||
.calledWith('confirmEmail', {
|
||||
to: ctx.email,
|
||||
confirmEmailUrl:
|
||||
'https://emails.example.com/user/emails/confirm?token=new-token',
|
||||
sendingUser_id: ctx.user_id,
|
||||
})
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with invalid email', function () {
|
||||
it('should reject with an error', async function (ctx) {
|
||||
await expect(
|
||||
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
|
||||
ctx.user_id,
|
||||
'!"£$%^&*()'
|
||||
)
|
||||
).to.be.rejectedWith(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a custom template', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
|
||||
ctx.user_id,
|
||||
ctx.email,
|
||||
'myCustomTemplate'
|
||||
)
|
||||
})
|
||||
|
||||
it('should send an email with the given template', function (ctx) {
|
||||
return ctx.EmailHandler.promises.sendEmail
|
||||
.calledWith('myCustomTemplate')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmEmailFromToken', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
|
||||
|
||||
@@ -133,9 +133,7 @@ describe('UserEmailsController', function () {
|
||||
'../../../../app/src/Features/User/UserEmailsConfirmationHandler',
|
||||
() => ({
|
||||
default: (ctx.UserEmailsConfirmationHandler = {
|
||||
promises: {
|
||||
sendConfirmationEmail: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
promises: {},
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user