Преглед изворни кода

[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
Antoine Clausse пре 2 месеци
родитељ
комит
3140e46e68

+ 0 - 52
services/web/app/src/Features/Email/EmailBuilder.mjs

@@ -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(

+ 0 - 27
services/web/app/src/Features/User/UserEmailsConfirmationHandler.mjs

@@ -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,
 }

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -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": "",

+ 4 - 2
services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx

@@ -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>
   }

+ 2 - 2
services/web/frontend/js/shared/components/ds/ds-form-text.tsx

@@ -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
   }

+ 0 - 1
services/web/locales/da.json

@@ -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",

+ 0 - 1
services/web/locales/de.json

@@ -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",

+ 2 - 1
services/web/locales/en.json

@@ -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",

+ 0 - 1
services/web/locales/fr.json

@@ -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",

+ 0 - 1
services/web/locales/pt.json

@@ -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",

+ 0 - 1
services/web/locales/sv.json

@@ -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",

+ 0 - 1
services/web/locales/zh-CN.json

@@ -1652,7 +1652,6 @@
   "register_with_another_email": "使用另一个邮件地址<a href=\"__link__\">注册 __appName__</a>",
   "registered": "已注册",
   "registering": "正在注册",
-  "registration_error": "注册错误",
   "reject": "不要",
   "reject_change": "拒绝修改",
   "reject_selected_changes": "拒绝选定的更改",

+ 39 - 0
services/web/test/acceptance/src/mocks/MockOrcidApi.mjs

@@ -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}
+ */

+ 0 - 70
services/web/test/unit/src/Email/EmailBuilder.test.mjs

@@ -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'

+ 0 - 72
services/web/test/unit/src/User/UserEmailsConfirmationHandler.test.mjs

@@ -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

+ 1 - 3
services/web/test/unit/src/User/UserEmailsController.test.mjs

@@ -133,9 +133,7 @@ describe('UserEmailsController', function () {
       '../../../../app/src/Features/User/UserEmailsConfirmationHandler',
       () => ({
         default: (ctx.UserEmailsConfirmationHandler = {
-          promises: {
-            sendConfirmationEmail: vi.fn().mockResolvedValue(undefined),
-          },
+          promises: {},
         }),
       })
     )