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

[web] Add recaptcha to add-email

GitOrigin-RevId: 0540e0dbc3103dcaac87dd7fabeedbc5892c371c
This commit is contained in:
Mathias Jakobsen 2023-06-26 10:32:49 +02:00 committed by Copybot
parent 64ca8ce094
commit af76768eb7
14 changed files with 143 additions and 14 deletions

66
package-lock.json generated
View file

@ -12079,6 +12079,15 @@
"@types/react": "*" "@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": { "node_modules/@types/react-linkify": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz",
@ -32630,6 +32639,18 @@
"node": ">=0.10.0" "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": { "node_modules/react-bootstrap": {
"version": "0.33.1", "version": "0.33.1",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz",
@ -32802,6 +32823,18 @@
"react": ">=16.13.1" "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": { "node_modules/react-i18next": {
"version": "11.18.6", "version": "11.18.6",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz", "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-dnd-html5-backend": "^11.1.3",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-error-boundary": "^2.3.1", "react-error-boundary": "^2.3.1",
"react-google-recaptcha": "^3.1.0",
"react-i18next": "^11.18.6", "react-i18next": "^11.18.6",
"react-linkify": "^1.0.0-alpha", "react-linkify": "^1.0.0-alpha",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
@ -41373,6 +41407,7 @@
"@types/react-bootstrap": "^0.32.29", "@types/react-bootstrap": "^0.32.29",
"@types/react-color": "^3.0.6", "@types/react-color": "^3.0.6",
"@types/react-dom": "^17.0.13", "@types/react-dom": "^17.0.13",
"@types/react-google-recaptcha": "^2.1.5",
"@types/react-linkify": "^1.0.0", "@types/react-linkify": "^1.0.0",
"@types/recurly__recurly-js": "^4.22.0", "@types/recurly__recurly-js": "^4.22.0",
"@types/sinon-chai": "^3.2.8", "@types/sinon-chai": "^3.2.8",
@ -50187,7 +50222,8 @@
"@types/react-bootstrap": "^0.32.29", "@types/react-bootstrap": "^0.32.29",
"@types/react-color": "^3.0.6", "@types/react-color": "^3.0.6",
"@types/react-dom": "^17.0.13", "@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/recurly__recurly-js": "^4.22.0",
"@types/sinon-chai": "^3.2.8", "@types/sinon-chai": "^3.2.8",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
@ -50353,6 +50389,7 @@
"react-dnd-html5-backend": "^11.1.3", "react-dnd-html5-backend": "^11.1.3",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-error-boundary": "^2.3.1", "react-error-boundary": "^2.3.1",
"react-google-recaptcha": "^3.1.0",
"react-i18next": "^11.18.6", "react-i18next": "^11.18.6",
"react-linkify": "^1.0.0-alpha", "react-linkify": "^1.0.0-alpha",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
@ -54056,6 +54093,15 @@
"@types/react": "*" "@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": { "@types/react-linkify": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz",
@ -70114,6 +70160,15 @@
"object-assign": "^4.1.1" "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": { "react-bootstrap": {
"version": "0.33.1", "version": "0.33.1",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz",
@ -70248,6 +70303,15 @@
"@babel/runtime": "^7.11.2" "@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": { "react-i18next": {
"version": "11.18.6", "version": "11.18.6",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
/>
)
})

View file

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

View file

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

View file

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

View file

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

View file

@ -349,7 +349,7 @@ describe('<EmailsSection />', function () {
const [[, request]] = fetchMock.calls(/\/user\/emails/) 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, email: userEmailData.email,
university: { university: {
id: userEmailData.affiliation?.institution.id, id: userEmailData.affiliation?.institution.id,
@ -494,7 +494,7 @@ describe('<EmailsSection />', function () {
const [[, request]] = fetchMock.calls(/\/user\/emails/) 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, email: userEmailData.email,
university: { university: {
name: newUniversity, name: newUniversity,

View file

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

View file

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