diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index 01be9c019..3af801ae4 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -47,6 +47,7 @@ import { } from './google_auth' import { createWebAuthToken } from './jwt_helpers' import { createMobileAccountCreationResponse } from './mobile/account_creation' +import { verifyChallengeRecaptcha } from '../../utils/recaptcha' export interface SignupRequest { email: string @@ -55,6 +56,7 @@ export interface SignupRequest { username: string bio?: string pictureUrl?: string + recaptchaToken?: string } const signToken = promisify(jwt.sign) @@ -64,20 +66,28 @@ const cookieParams = { maxAge: 365 * 24 * 60 * 60 * 1000, } +const isURLPresent = (input: string): boolean => { + const urlRegex = /(https?:\/\/[^\s]+)/g + return urlRegex.test(input) +} + export const isValidSignupRequest = (obj: any): obj is SignupRequest => { return ( 'email' in obj && obj.email.trim().length > 0 && obj.email.trim().length < 512 && // email must not be empty + !isURLPresent(obj.email) && 'password' in obj && obj.password.length >= 8 && obj.password.trim().length < 512 && // password must be at least 8 characters 'name' in obj && obj.name.trim().length > 0 && obj.name.trim().length < 512 && // name must not be empty + !isURLPresent(obj.name) && 'username' in obj && obj.username.trim().length > 0 && - obj.username.trim().length < 512 // username must not be empty + obj.username.trim().length < 512 && // username must not be empty + !isURLPresent(obj.username) ) } @@ -499,16 +509,27 @@ export function authRouter() { `${env.client.url}/auth/email-signup?errorCodes=INVALID_CREDENTIALS` ) } - const { email, password, name, username, bio, pictureUrl } = req.body + const { + email, + password, + name, + username, + bio, + pictureUrl, + recaptchaToken, + } = req.body - function isURLPresent(input: string): boolean { - const urlRegex = /(https?:\/\/[^\s]+)/g - return urlRegex.test(input) - } + if (recaptchaToken && process.env.RECAPTCHA_CHALLENGE_SECRET_KEY) { + const verified = await verifyChallengeRecaptcha(recaptchaToken) + console.log('recaptcha result: ', recaptchaToken, verified) + // temporarily do not fail here so we can deploy this in stages + // just log the verification - if (isURLPresent(email) || isURLPresent(name) || isURLPresent(username)) { - res.redirect(`${env.client.url}/auth/email-signup?errorCodes=UNKNOWN`) - return + // if (!verified) { + // return res.redirect( + // `${env.client.url}/auth/email-signup?errorCodes=UNKNOWN` + // ) + // } } // trim whitespace in email address diff --git a/packages/api/src/utils/recaptcha.ts b/packages/api/src/utils/recaptcha.ts new file mode 100644 index 000000000..2fabdfc57 --- /dev/null +++ b/packages/api/src/utils/recaptcha.ts @@ -0,0 +1,39 @@ +import axios from 'axios' + +type RecaptchaResponse = { + success: boolean + hostname: string + score?: number + action?: string +} + +export const isRecaptchaResponse = (data: any): data is RecaptchaResponse => { + return 'success' in data && 'hostname' in data +} + +export const verifyChallengeRecaptcha = async ( + token: string +): Promise => { + if (!process.env.RECAPTCHA_CHALLENGE_SECRET_KEY) { + return false + } + + const url = `https://www.google.com/recaptcha/api/siteverify` + const params = new URLSearchParams({ + secret: process.env.RECAPTCHA_CHALLENGE_SECRET_KEY, + response: token, + }) + + try { + const response = await axios.post(url, params) + console.log('recaptcha response: ', response.data) + + if (isRecaptchaResponse(response.data)) { + return response.data.success + } + return false + } catch (error) { + console.error('Error verifying reCAPTCHA:', error) + return false + } +} diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts index f62900e18..58bb48ab6 100644 --- a/packages/api/test/routers/auth.test.ts +++ b/packages/api/test/routers/auth.test.ts @@ -18,6 +18,7 @@ import { import * as util from '../../src/utils/sendEmail' import { createTestUser } from '../db' import { generateFakeUuid, request } from '../util' +import { isValidSignupRequest } from '../../src/routers/auth/auth_router' chai.use(sinonChai) @@ -631,13 +632,60 @@ describe('auth router', () => { 'ios' ).expect(200) const user = await userRepository.findOneByOrFail({ name }) - const { count } = await searchLibraryItems( - { query: 'in:all' }, - user.id - ) + const { count } = await searchLibraryItems({ query: 'in:all' }, user.id) expect(count).to.eql(4) }) }) }) }) + +describe('isValidSignupRequest', () => { + it('returns true for normal looking requests', async () => { + const result = isValidSignupRequest({ + email: 'email@omnivore.app', + password: 'superDuperPassword', + name: "The User's Name", + username: 'foouser', + }) + expect(result).to.be.true + }) + it('returns false for requests w/missing info', async () => { + let result = isValidSignupRequest({ + password: 'superDuperPassword', + name: "The User's Name", + username: 'foouser', + }) + expect(result).to.be.false + + result = isValidSignupRequest({ + email: 'email@omnivore.app', + name: "The User's Name", + username: 'foouser', + }) + expect(result).to.be.false + + result = isValidSignupRequest({ + email: 'email@omnivore.app', + password: 'superDuperPassword', + username: 'foouser', + }) + expect(result).to.be.false + + result = isValidSignupRequest({ + email: 'email@omnivore.app', + password: 'superDuperPassword', + name: "The User's Name", + }) + expect(result).to.be.false + }) + + it('returns false for requests w/malicious info', async () => { + let result = isValidSignupRequest({ + password: 'superDuperPassword', + name: "You've won a cake sign up here: https://foo.bar", + username: 'foouser', + }) + expect(result).to.be.false + }) +}) diff --git a/packages/web/components/templates/auth/EmailSignup.tsx b/packages/web/components/templates/auth/EmailSignup.tsx index 1f920f7ba..3776e1000 100644 --- a/packages/web/components/templates/auth/EmailSignup.tsx +++ b/packages/web/components/templates/auth/EmailSignup.tsx @@ -1,7 +1,7 @@ import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' import { Button } from '../../elements/Button' import { StyledText, StyledTextSpan } from '../../elements/StyledText' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { BorderedFormInput, FormLabel } from '../../elements/FormElements' import { TermAndConditionsFooter } from '../LoginForm' import { fetchEndpoint } from '../../../lib/appConfig' @@ -10,26 +10,19 @@ import { logoutMutation } from '../../../lib/networking/mutations/logoutMutation import { useRouter } from 'next/router' import { formatMessage } from '../../../locales/en/messages' import { parseErrorCodes } from '../../../lib/queryParamParser' +import { + GoogleReCaptchaProvider, + GoogleReCaptchaCheckbox, +} from '@google-recaptcha/react' import Link from 'next/link' -export function EmailSignup(): JSX.Element { - const router = useRouter() +const SignUpForm = (): JSX.Element => { const [email, setEmail] = useState() const [password, setPassword] = useState() const [fullname, setFullname] = useState() const [username, setUsername] = useState() const [debouncedUsername, setDebouncedUsername] = useState() - const [errorMessage, setErrorMessage] = useState() - - useEffect(() => { - if (!router.isReady) return - const errorCode = parseErrorCodes(router.query) - const errorMsg = errorCode - ? formatMessage({ id: `error.${errorCode}` }) - : undefined - setErrorMessage(errorMsg) - }, [router.isReady, router.query]) const { isUsernameValid, usernameErrorMessage } = useValidateUsernameQuery({ username: debouncedUsername ?? '', @@ -46,174 +39,236 @@ export function EmailSignup(): JSX.Element { ) return ( -
- - - Sign Up - - - - Email - { - e.preventDefault() - setEmail(e.target.value) - }} - required - /> - - - - Password - setPassword(e.target.value)} - required - /> - - - - Full Name - setFullname(e.target.value)} - required - /> - - - - Username - - - {username && username.length > 0 && usernameErrorMessage && ( - - {usernameErrorMessage} - - )} - {isUsernameValid && ( - - Username is available. - - )} - - - {errorMessage && {errorMessage}} - + + + Email + { + e.preventDefault() + setEmail(e.target.value) + }} + required + /> + + + Password + setPassword(e.target.value)} + required + /> + + + Full Name + setFullname(e.target.value)} + required + /> + + + Username + + + {username && username.length > 0 && usernameErrorMessage && ( - Omnivore will send you daily tips for your first week as a new user. - If you don't like them you can unsubscribe. + {usernameErrorMessage} - - - - - - + )} + {isUsernameValid && ( - Already have an account?{' '} - - - Login instead - - + Username is available. - - - + )} +
+ ) +} + +type RecaptchaProps = { + setRecaptchaToken: (token: string) => void +} + +const Recaptcha = (props: RecaptchaProps): JSX.Element => { + return ( + <> + { + console.log('recaptcha: ', token) + props.setRecaptchaToken(token) + }} + /> + + ) +} + +export function EmailSignup(): JSX.Element { + const router = useRouter() + const recaptchaTokenRef = useRef(null) + const [errorMessage, setErrorMessage] = useState() + + useEffect(() => { + if (!router.isReady) return + const errorCode = parseErrorCodes(router.query) + const errorMsg = errorCode + ? formatMessage({ id: `error.${errorCode}` }) + : undefined + setErrorMessage(errorMsg) + }, [router.isReady, router.query]) + + return ( + <> +
+ + + Sign Up + + + + + {process.env.NEXT_PUBLIC_RECAPTCHA_CHALLENGE_SECRET_KEY && ( + <> + { + if (recaptchaTokenRef.current) { + recaptchaTokenRef.current.value = token + } else { + console.log('error updating recaptcha token') + } + }} + /> + + + )} + + {errorMessage && ( + {errorMessage} + )} + + + Omnivore will send you daily tips for your first week as a new user. + If you don't like them you can unsubscribe. + + + + + + + + + Already have an account?{' '} + + + Login instead + + + + + +
+ ) } diff --git a/packages/web/next.config.js b/packages/web/next.config.js index 07228c7f9..6cf984120 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -1,13 +1,13 @@ const ContentSecurityPolicy = ` default-src 'self'; base-uri 'self'; - connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://api.segment.io https://cdn.segment.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com; + connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com; font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com; form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com; frame-ancestors 'none'; - frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com; + frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/; manifest-src 'self'; - script-src 'self' 'unsafe-inline' 'unsafe-eval' accounts.google.com https://widget.intercom.io https://js.intercomcdn.com https://platform.twitter.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.segment.com; + script-src 'self' 'unsafe-inline' 'unsafe-eval' accounts.google.com https://widget.intercom.io https://js.intercomcdn.com https://platform.twitter.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; style-src 'self' 'unsafe-inline' https://accounts.google.com https://cdnjs.cloudflare.com; img-src 'self' blob: data: https:; worker-src 'self' blob:; diff --git a/packages/web/package.json b/packages/web/package.json index 467f44d12..c10a0573c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@floating-ui/react": "^0.26.9", + "@google-recaptcha/react": "^1.0.3", "@radix-ui/react-avatar": "^0.1.1", "@radix-ui/react-checkbox": "^0.1.5", "@radix-ui/react-dialog": "1.0.5", @@ -106,4 +107,4 @@ "volta": { "extends": "../../package.json" } -} \ No newline at end of file +} diff --git a/packages/web/pages/auth/email-signup.tsx b/packages/web/pages/auth/email-signup.tsx index 7c2d24d8d..3487ecc8c 100644 --- a/packages/web/pages/auth/email-signup.tsx +++ b/packages/web/pages/auth/email-signup.tsx @@ -1,15 +1,24 @@ import { PageMetaData } from '../../components/patterns/PageMetaData' import { ProfileLayout } from '../../components/templates/ProfileLayout' import { EmailSignup } from '../../components/templates/auth/EmailSignup' +import { GoogleReCaptchaProvider } from '@google-recaptcha/react' export default function EmailRegistrationPage(): JSX.Element { return ( <> - - - - -
+ + + + + + ) }