diff --git a/package-lock.json b/package-lock.json index 2a54c49f55..0c502e2237 100644 --- a/package-lock.json +++ b/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", diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index d369f950f9..30dcfb4a9e 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/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, diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 8c0c1fb74f..8d3cef2a1c 100644 --- a/services/web/app/src/router.js +++ b/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( diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index c7174f6239..74192b588c 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -642,6 +642,7 @@ module.exports = { login: true, passwordReset: true, register: true, + addEmail: true, }, }, diff --git a/services/web/frontend/js/features/settings/components/emails/add-email.tsx b/services/web/frontend/js/features/settings/components/emails/add-email.tsx index 298582e7e4..7b9a21715d 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email.tsx +++ b/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 ( +
@@ -161,6 +169,7 @@ function AddEmail() { return ( + diff --git a/services/web/frontend/js/pages/user/settings.js b/services/web/frontend/js/pages/user/settings.js index 22fe3d9712..b0945350c5 100644 --- a/services/web/frontend/js/pages/user/settings.js +++ b/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(, element) } diff --git a/services/web/frontend/js/shared/components/recaptcha-2.tsx b/services/web/frontend/js/shared/components/recaptcha-2.tsx new file mode 100644 index 0000000000..fb6e6696d8 --- /dev/null +++ b/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 ( + + ) +}) diff --git a/services/web/frontend/js/shared/hooks/use-recaptcha.ts b/services/web/frontend/js/shared/hooks/use-recaptcha.ts new file mode 100644 index 0000000000..93a13cbed6 --- /dev/null +++ b/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 = createRef() + const getReCaptchaToken = async (): Promise => { + if (!ref.current) { + return null + } + return await ref.current.executeAsync() + } + return { ref, getReCaptchaToken } +} diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx index 2bbf058eee..72cee254a0 100644 --- a/services/web/frontend/stories/decorators/scope.tsx +++ b/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', diff --git a/services/web/package.json b/services/web/package.json index 9522639e02..66c132f0c0 100644 --- a/services/web/package.json +++ b/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", diff --git a/services/web/test/acceptance/config/settings.test.defaults.js b/services/web/test/acceptance/config/settings.test.defaults.js index a2288eea03..78467726a0 100644 --- a/services/web/test/acceptance/config/settings.test.defaults.js +++ b/services/web/test/acceptance/config/settings.test.defaults.js @@ -251,6 +251,7 @@ module.exports = { login: false, passwordReset: true, register: true, + addEmail: true, }, }, diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx index 337de5e4ff..9a832f5fd3 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx @@ -349,7 +349,7 @@ describe('', 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('', 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, diff --git a/services/web/types/exposed-settings.ts b/services/web/types/exposed-settings.ts index b6f46086db..415ccd0948 100644 --- a/services/web/types/exposed-settings.ts +++ b/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 diff --git a/services/web/types/window.ts b/services/web/types/window.ts index f74b011e78..cae4ae2db4 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -35,5 +35,10 @@ declare global { crypto: { randomUUID: () => string } + // For react-google-recaptcha + recaptchaOptions?: { + enterprise?: boolean + useRecaptchaNet?: boolean + } } }