Browse Source

Merge pull request #13372 from overleaf/mj-captcha-add-email

[web] Add recaptcha to add-email

GitOrigin-RevId: 0540e0dbc3103dcaac87dd7fabeedbc5892c371c
Mathias Jakobsen 3 years ago
parent
commit
af76768eb7

+ 65 - 1
package-lock.json

@@ -12079,6 +12079,15 @@
         "@types/react": "*"
       }
     },
+    "node_modules/@types/react-google-recaptcha": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.5.tgz",
+      "integrity": "sha512-iWTjmVttlNgp0teyh7eBXqNOQzVq2RWNiFROWjraOptRnb1OcHJehQnji0sjqIRAk9K0z8stjyhU+OLpPb0N6w==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
     "node_modules/@types/react-linkify": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz",
@@ -32630,6 +32639,18 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-async-script": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
+      "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
+      "dependencies": {
+        "hoist-non-react-statics": "^3.3.0",
+        "prop-types": "^15.5.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.4.1"
+      }
+    },
     "node_modules/react-bootstrap": {
       "version": "0.33.1",
       "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz",
@@ -32802,6 +32823,18 @@
         "react": ">=16.13.1"
       }
     },
+    "node_modules/react-google-recaptcha": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
+      "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
+      "dependencies": {
+        "prop-types": "^15.5.0",
+        "react-async-script": "^1.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.4.1"
+      }
+    },
     "node_modules/react-i18next": {
       "version": "11.18.6",
       "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",
@@ -41330,6 +41363,7 @@
         "react-dnd-html5-backend": "^11.1.3",
         "react-dom": "^17.0.2",
         "react-error-boundary": "^2.3.1",
+        "react-google-recaptcha": "^3.1.0",
         "react-i18next": "^11.18.6",
         "react-linkify": "^1.0.0-alpha",
         "react-refresh": "^0.14.0",
@@ -41373,6 +41407,7 @@
         "@types/react-bootstrap": "^0.32.29",
         "@types/react-color": "^3.0.6",
         "@types/react-dom": "^17.0.13",
+        "@types/react-google-recaptcha": "^2.1.5",
         "@types/react-linkify": "^1.0.0",
         "@types/recurly__recurly-js": "^4.22.0",
         "@types/sinon-chai": "^3.2.8",
@@ -50187,7 +50222,8 @@
         "@types/react-bootstrap": "^0.32.29",
         "@types/react-color": "^3.0.6",
         "@types/react-dom": "^17.0.13",
-        "@types/react-linkify": "1.0.0",
+        "@types/react-google-recaptcha": "^2.1.5",
+        "@types/react-linkify": "^1.0.0",
         "@types/recurly__recurly-js": "^4.22.0",
         "@types/sinon-chai": "^3.2.8",
         "@types/uuid": "^8.3.4",
@@ -50353,6 +50389,7 @@
         "react-dnd-html5-backend": "^11.1.3",
         "react-dom": "^17.0.2",
         "react-error-boundary": "^2.3.1",
+        "react-google-recaptcha": "^3.1.0",
         "react-i18next": "^11.18.6",
         "react-linkify": "^1.0.0-alpha",
         "react-refresh": "^0.14.0",
@@ -54056,6 +54093,15 @@
         "@types/react": "*"
       }
     },
+    "@types/react-google-recaptcha": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.5.tgz",
+      "integrity": "sha512-iWTjmVttlNgp0teyh7eBXqNOQzVq2RWNiFROWjraOptRnb1OcHJehQnji0sjqIRAk9K0z8stjyhU+OLpPb0N6w==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/react-linkify": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz",
@@ -70114,6 +70160,15 @@
         "object-assign": "^4.1.1"
       }
     },
+    "react-async-script": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
+      "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
+      "requires": {
+        "hoist-non-react-statics": "^3.3.0",
+        "prop-types": "^15.5.0"
+      }
+    },
     "react-bootstrap": {
       "version": "0.33.1",
       "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz",
@@ -70248,6 +70303,15 @@
         "@babel/runtime": "^7.11.2"
       }
     },
+    "react-google-recaptcha": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
+      "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
+      "requires": {
+        "prop-types": "^15.5.0",
+        "react-async-script": "^1.2.0"
+      }
+    },
     "react-i18next": {
       "version": "11.18.6",
       "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",

+ 3 - 4
services/web/app/src/infrastructure/ExpressLocals.js

@@ -393,10 +393,9 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
       emailConfirmationDisabled: Settings.emailConfirmationDisabled,
       maxEntitiesPerProject: Settings.maxEntitiesPerProject,
       maxUploadSize: Settings.maxUploadSize,
-      recaptchaSiteKeyV3:
-        Settings.recaptcha != null ? Settings.recaptcha.siteKeyV3 : undefined,
-      recaptchaDisabled:
-        Settings.recaptcha != null ? Settings.recaptcha.disabled : undefined,
+      recaptchaSiteKey: Settings.recaptcha?.siteKey,
+      recaptchaSiteKeyV3: Settings.recaptcha?.siteKeyV3,
+      recaptchaDisabled: Settings.recaptcha?.disabled,
       textExtensions: Settings.textExtensions,
       validRootDocExtensions: Settings.validRootDocExtensions,
       sentryAllowedOriginRegex: Settings.sentry.allowedOriginRegex,

+ 1 - 0
services/web/app/src/router.js

@@ -349,6 +349,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
       '/user/emails',
       AuthenticationController.requireLogin(),
       RateLimiterMiddleware.rateLimit(rateLimiters.addEmail),
+      CaptchaMiddleware.validateCaptcha('addEmail'),
       UserEmailsController.add
     )
     webRouter.post(

+ 1 - 0
services/web/config/settings.defaults.js

@@ -642,6 +642,7 @@ module.exports = {
       login: true,
       passwordReset: true,
       register: true,
+      addEmail: true,
     },
   },
 

+ 16 - 7
services/web/frontend/js/features/settings/components/emails/add-email.tsx

@@ -16,6 +16,8 @@ import { University } from '../../../../../../types/university'
 import { CountryCode } from '../../data/countries-list'
 import { isValidEmail } from '../../../../shared/utils/email'
 import getMeta from '../../../../utils/meta'
+import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
+import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
 
 function AddEmail() {
   const { t } = useTranslation()
@@ -40,6 +42,7 @@ function AddEmail() {
   } = useUserEmailsContext()
 
   const emailAddressLimit = getMeta('ol-emailAddressLimit', 10)
+  const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha()
 
   useEffect(() => {
     setUserEmailsContextLoading(isLoading)
@@ -84,13 +87,17 @@ function AddEmail() {
       }
 
     runAsync(
-      postJSON('/user/emails', {
-        body: {
-          email: newEmail,
-          ...knownUniversityData,
-          ...unknownUniversityData,
-        },
-      })
+      (async () => {
+        const token = await getReCaptchaToken()
+        await postJSON('/user/emails', {
+          body: {
+            email: newEmail,
+            ...knownUniversityData,
+            ...unknownUniversityData,
+            'g-recaptcha-response': token,
+          },
+        })
+      })()
     )
       .then(() => {
         getEmails()
@@ -137,6 +144,7 @@ function AddEmail() {
   if (!isValidEmail(newEmail)) {
     return (
       <Layout isError={isError} error={error}>
+        <ReCaptcha2 page="addEmail" ref={recaptchaRef} />
         <form>
           <Col md={8}>
             <Cell>
@@ -161,6 +169,7 @@ function AddEmail() {
 
   return (
     <Layout isError={isError} error={error}>
+      <ReCaptcha2 page="addEmail" ref={recaptchaRef} />
       <form>
         <Col md={8}>
           <Cell>

+ 4 - 0
services/web/frontend/js/pages/user/settings.js

@@ -8,6 +8,10 @@ import ReactDOM from 'react-dom'
 import SettingsPageRoot from '../../features/settings/components/root.tsx'
 
 const element = document.getElementById('settings-page-root')
+// For react-google-recaptcha
+window.recaptchaOptions = {
+  enterprise: true,
+}
 if (element) {
   ReactDOM.render(<SettingsPageRoot />, element)
 }

+ 27 - 0
services/web/frontend/js/shared/components/recaptcha-2.tsx

@@ -0,0 +1,27 @@
+import { forwardRef } from 'react'
+import ReCAPTCHA from 'react-google-recaptcha'
+
+const siteKey = window.ExposedSettings.recaptchaSiteKey
+const recaptchaDisabled = window.ExposedSettings.recaptchaDisabled
+type Page = keyof typeof recaptchaDisabled
+
+export const ReCaptcha2 = forwardRef<
+  ReCAPTCHA,
+  { page: Page; onChange?: (token: string | null) => void }
+>(function ReCaptcha2({ page: site, onChange }, ref) {
+  if (!siteKey) {
+    return null
+  }
+  if (site && recaptchaDisabled[site]) {
+    return null
+  }
+  return (
+    <ReCAPTCHA
+      ref={ref}
+      size="invisible"
+      sitekey={siteKey}
+      onChange={onChange}
+      badge="inline"
+    />
+  )
+})

+ 13 - 0
services/web/frontend/js/shared/hooks/use-recaptcha.ts

@@ -0,0 +1,13 @@
+import { LegacyRef, createRef } from 'react'
+import ReCAPTCHA from 'react-google-recaptcha'
+
+export const useRecaptcha = () => {
+  const ref: LegacyRef<ReCAPTCHA> = createRef<ReCAPTCHA>()
+  const getReCaptchaToken = async (): Promise<string | null> => {
+    if (!ref.current) {
+      return null
+    }
+    return await ref.current.executeAsync()
+  }
+  return { ref, getReCaptchaToken }
+}

+ 1 - 0
services/web/frontend/stories/decorators/scope.tsx

@@ -149,6 +149,7 @@ const initialize = () => {
       login: true,
       passwordReset: true,
       register: true,
+      addEmail: true,
     },
     sentryAllowedOriginRegex: '',
     siteUrl: 'http://localhost',

+ 2 - 0
services/web/package.json

@@ -229,6 +229,7 @@
     "react-dnd-html5-backend": "^11.1.3",
     "react-dom": "^17.0.2",
     "react-error-boundary": "^2.3.1",
+    "react-google-recaptcha": "^3.1.0",
     "react-i18next": "^11.18.6",
     "react-linkify": "^1.0.0-alpha",
     "react-refresh": "^0.14.0",
@@ -272,6 +273,7 @@
     "@types/react-bootstrap": "^0.32.29",
     "@types/react-color": "^3.0.6",
     "@types/react-dom": "^17.0.13",
+    "@types/react-google-recaptcha": "^2.1.5",
     "@types/react-linkify": "^1.0.0",
     "@types/recurly__recurly-js": "^4.22.0",
     "@types/sinon-chai": "^3.2.8",

+ 1 - 0
services/web/test/acceptance/config/settings.test.defaults.js

@@ -251,6 +251,7 @@ module.exports = {
       login: false,
       passwordReset: true,
       register: true,
+      addEmail: true,
     },
   },
 

+ 2 - 2
services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx

@@ -349,7 +349,7 @@ describe('<EmailsSection />', function () {
 
     const [[, request]] = fetchMock.calls(/\/user\/emails/)
 
-    expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.equal({
+    expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
       email: userEmailData.email,
       university: {
         id: userEmailData.affiliation?.institution.id,
@@ -494,7 +494,7 @@ describe('<EmailsSection />', function () {
 
     const [[, request]] = fetchMock.calls(/\/user\/emails/)
 
-    expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.equal({
+    expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
       email: userEmailData.email,
       university: {
         name: newUniversity,

+ 2 - 0
services/web/types/exposed-settings.ts

@@ -27,8 +27,10 @@ export type ExposedSettings = {
     login: boolean
     passwordReset: boolean
     register: boolean
+    addEmail: boolean
   }
   recaptchaSiteKeyV3?: string
+  recaptchaSiteKey?: string
   samlInitPath?: string
   sentryAllowedOriginRegex: string
   sentryDsn?: string

+ 5 - 0
services/web/types/window.ts

@@ -35,5 +35,10 @@ declare global {
     crypto: {
       randomUUID: () => string
     }
+    // For react-google-recaptcha
+    recaptchaOptions?: {
+      enterprise?: boolean
+      useRecaptchaNet?: boolean
+    }
   }
 }