From c715f0a58ce098b7c14791f4aabc1a3d15de81df Mon Sep 17 00:00:00 2001 From: Spencer Spenst Date: Sat, 25 May 2024 15:14:23 -0700 Subject: [PATCH] add back / fix recaptcha --- components/forms/signupForm.tsx | 66 +++++++++++++++++----- pages/[subdomain]/play-as-guest/index.tsx | 63 +++++++++++---------- pages/[subdomain]/signup/index.tsx | 12 +++- pages/api/signup/index.ts | 27 ++++++++- tests/pages/api/signup/signup.test.ts | 68 +++++++++++++++++++++++ 5 files changed, 188 insertions(+), 48 deletions(-) diff --git a/components/forms/signupForm.tsx b/components/forms/signupForm.tsx index c50788200..c22bf9d0a 100644 --- a/components/forms/signupForm.tsx +++ b/components/forms/signupForm.tsx @@ -12,18 +12,21 @@ import { AppContext } from '../../contexts/appContext'; import LoadingSpinner from '../page/loadingSpinner'; import FormTemplate from './formTemplate'; -export default function SignupForm() { +interface SignupFormProps { + recaptchaPublicKey: string | null; +} + +export default function SignupForm({ recaptchaPublicKey }: SignupFormProps) { const { cache } = useSWRConfig(); const [email, setEmail] = useState(''); const { mutateUser, setShouldAttemptAuth } = useContext(AppContext); const [password, setPassword] = useState(''); const recaptchaRef = useRef(null); const router = useRouter(); + const [showRecaptcha, setShowRecaptcha] = useState(false); const [username, setUsername] = useState(''); - function onSubmit(event: React.FormEvent) { - event.preventDefault(); - + function onSubmit(recaptchaToken: string | null) { if (password.length < 8 || password.length > 50) { toast.dismiss(); toast.error('Password must be between 8 and 50 characters'); @@ -31,21 +34,46 @@ export default function SignupForm() { return; } + if (recaptchaPublicKey) { + if (!showRecaptcha) { + setShowRecaptcha(true); + + return; + } + + if (!recaptchaToken) { + toast.error('Please complete the recaptcha'); + + return; + } + + if (recaptchaRef.current) { + recaptchaRef.current.reset(); + } + } + toast.dismiss(); toast.loading('Registering...'); const tutorialCompletedAt = window.localStorage.getItem('tutorialCompletedAt') || '0'; const utm_source = window.localStorage.getItem('utm_source') || ''; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body: any = { + email: email, + name: username, + password: password, + tutorialCompletedAt: parseInt(tutorialCompletedAt), + utm_source: utm_source + }; + + if (recaptchaToken) { + body.recaptchaToken = recaptchaToken; + } + fetch('/api/signup', { method: 'POST', - body: JSON.stringify({ - email: email, - name: username, - password: password, - tutorialCompletedAt: parseInt(tutorialCompletedAt), - utm_source: utm_source - }), + body: JSON.stringify(body), credentials: 'include', headers: { 'Content-Type': 'application/json' @@ -131,7 +159,7 @@ export default function SignupForm() { return ( -
+
@@ -191,7 +219,17 @@ export default function SignupForm() { I agree to the terms of service and reviewed the privacy policy.
- +
+ {recaptchaPublicKey && showRecaptcha ? + onSubmit(token)} + ref={recaptchaRef} + sitekey={recaptchaPublicKey} + /> + : + + } +
@@ -213,7 +251,7 @@ export default function SignupForm() {
-
+
); } diff --git a/pages/[subdomain]/play-as-guest/index.tsx b/pages/[subdomain]/play-as-guest/index.tsx index 0c21d97f8..8df6f97bb 100644 --- a/pages/[subdomain]/play-as-guest/index.tsx +++ b/pages/[subdomain]/play-as-guest/index.tsx @@ -26,28 +26,26 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } return { - props: { recaptchaPublicKey: process.env.RECAPTCHA_PUBLIC_KEY || '' }, + props: { + recaptchaPublicKey: process.env.RECAPTCHA_PUBLIC_KEY ?? null, + }, }; } +interface PlayAsGuestProps { + recaptchaPublicKey: string | null; +} + /* istanbul ignore next */ -export default function PlayAsGuest({ recaptchaPublicKey }: {recaptchaPublicKey?: string}) { +export default function PlayAsGuest({ recaptchaPublicKey }: PlayAsGuestProps) { const { cache } = useSWRConfig(); const { mutateUser, setShouldAttemptAuth, userConfig } = useContext(AppContext); const [name, setName] = useState(''); const recaptchaRef = useRef(null); - const recaptchaToken = useRef(''); const [registrationState, setRegistrationState] = useState('registering'); const [showRecaptcha, setShowRecaptcha] = useState(false); const [temporaryPassword, setTemporaryPassword] = useState(''); - function onRecaptchaChange(value: string | null) { - if (value) { - recaptchaToken.current = value; - setTimeout(fetchSignup, 50); - } - } - const CopyToClipboardButton = ({ text }: { text: string }) => { const [isCopied, setIsCopied] = useState(false); @@ -77,7 +75,7 @@ export default function PlayAsGuest({ recaptchaPublicKey }: {recaptchaPublicKey? ); }; - async function fetchSignup() { + async function fetchSignup(recaptchaToken: string | null) { if (recaptchaPublicKey) { if (!showRecaptcha) { setShowRecaptcha(true); @@ -85,11 +83,15 @@ export default function PlayAsGuest({ recaptchaPublicKey }: {recaptchaPublicKey? return; } - if (!recaptchaToken.current) { + if (!recaptchaToken) { toast.error('Please complete the recaptcha'); return; } + + if (recaptchaRef.current) { + recaptchaRef.current.reset(); + } } const tutorialCompletedAt = window.localStorage.getItem('tutorialCompletedAt') || '0'; @@ -97,28 +99,30 @@ export default function PlayAsGuest({ recaptchaPublicKey }: {recaptchaPublicKey? toast.dismiss(); toast.loading('Creating guest account...'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body: any = { + email: 'guest@guest.com', + guest: true, + name: 'Guest', + password: 'guest-account', + tutorialCompletedAt: tutorialCompletedAt, + utm_source: utm_source, + }; + + if (recaptchaToken) { + body.recaptchaToken = recaptchaToken; + } + const res = await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - - body: JSON.stringify({ - name: 'Guest', - email: 'guest@guest.com', - password: 'guest-account', - recaptchaToken: recaptchaToken.current, - guest: true, - tutorialCompletedAt: tutorialCompletedAt, - utm_source: utm_source - }) + body: JSON.stringify(body) }); - if (recaptchaRef.current) { - recaptchaRef.current.reset(); - } - if (!res.ok) { toast.dismiss(); toast.error('Error creating guest account'); @@ -208,13 +212,12 @@ export default function PlayAsGuest({ recaptchaPublicKey }: {recaptchaPublicKey? {recaptchaPublicKey && showRecaptcha ? fetchSignup(token)} ref={recaptchaRef} - sitekey={recaptchaPublicKey ?? ''} + sitekey={recaptchaPublicKey} /> : - } diff --git a/pages/[subdomain]/signup/index.tsx b/pages/[subdomain]/signup/index.tsx index 7271b5ffc..ced0343b4 100644 --- a/pages/[subdomain]/signup/index.tsx +++ b/pages/[subdomain]/signup/index.tsx @@ -18,15 +18,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } return { - props: {}, + props: { + recaptchaPublicKey: process.env.RECAPTCHA_PUBLIC_KEY ?? null, + }, }; } +interface SignUpProps { + recaptchaPublicKey: string | null; +} + /* istanbul ignore next */ -export default function SignUp() { +export default function SignUp({ recaptchaPublicKey }: SignUpProps) { return ( - + ); } diff --git a/pages/api/signup/index.ts b/pages/api/signup/index.ts index 7564b6ebe..760eebfc2 100644 --- a/pages/api/signup/index.ts +++ b/pages/api/signup/index.ts @@ -68,12 +68,37 @@ export default apiWrapper({ POST: { guest: ValidType('boolean', false), name: ValidType('string'), password: ValidType('string'), + recaptchaToken: ValidType('string', false), tutorialCompletedAt: ValidNumber(false), }, } }, async (req: NextApiRequestWrapper, res: NextApiResponse) => { await dbConnect(); - const { email, guest, name, password, tutorialCompletedAt, utm_source } = req.body; + const { email, guest, name, password, recaptchaToken, tutorialCompletedAt, utm_source } = req.body; + + const RECAPTCHA_SECRET = process.env.RECAPTCHA_SECRET || ''; + + if (RECAPTCHA_SECRET && RECAPTCHA_SECRET.length > 0) { + if (!recaptchaToken) { + return res.status(400).json({ error: 'Please fill out recaptcha' }); + } + + const recaptchaResponse = await fetch('https://www.google.com/recaptcha/api/siteverify', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `secret=${RECAPTCHA_SECRET}&response=${recaptchaToken}`, + }); + + const recaptchaData = await recaptchaResponse.json(); + + if (!recaptchaResponse.ok || !recaptchaData?.success) { + const errorMessage = `Error validating recaptcha [Status: ${recaptchaResponse.status}], [Data: ${JSON.stringify(recaptchaData)}]`; + + logger.error(errorMessage); + + return res.status(400).json({ error: errorMessage }); + } + } let trimmedEmail: string, trimmedName: string, passwordValue: string; diff --git a/tests/pages/api/signup/signup.test.ts b/tests/pages/api/signup/signup.test.ts index 827b216d4..944852ef3 100644 --- a/tests/pages/api/signup/signup.test.ts +++ b/tests/pages/api/signup/signup.test.ts @@ -39,6 +39,74 @@ jest.mock('nodemailer', () => ({ describe('pages/api/signup', () => { const cookie = getTokenCookieValue(TestId.USER); + test('Creating a user but not passing recaptcha should fail with 400', async () => { + process.env.RECAPTCHA_SECRET = 'defined'; + jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger)); + await testApiHandler({ + pagesHandler: async (_, res) => { + const req: NextApiRequestWithAuth = { + method: 'POST', + cookies: { + token: cookie, + }, + body: { + name: 'test_new', + email: 'test@gmail.com', + password: 'password', + }, + headers: { + 'content-type': 'application/json', + }, + } as unknown as NextApiRequestWithAuth; + + await signupUserHandler(req, res); + }, + test: async ({ fetch }) => { + const res = await fetch(); + const response = await res.json(); + + expect(response.error).toBe('Please fill out recaptcha'); + expect(res.status).toBe(400); + }, + }); + }); + test('Creating a user, pass recaptcha where fetch fails', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger)); + + process.env.RECAPTCHA_SECRET = 'defined'; + + // mock fetch failing with 400 + fetchMock.mockResponseOnce(JSON.stringify({ 'mock': true }), { status: 408 }); + + await testApiHandler({ + pagesHandler: async (_, res) => { + const req: NextApiRequestWithAuth = { + method: 'POST', + cookies: { + token: cookie, + }, + body: { + name: 'test_new', + email: 'test@gmail.com', + password: 'password', + recaptchaToken: 'token', + }, + headers: { + 'content-type': 'application/json', + }, + } as unknown as NextApiRequestWithAuth; + + await signupUserHandler(req, res); + }, + test: async ({ fetch }) => { + const res = await fetch(); + const response = await res.json(); + + expect(response.error).toBe('Error validating recaptcha [Status: 408], [Data: {"mock":true}]'); + expect(res.status).toBe(400); + }, + }); + }); test('Creating a user without a body should fail with 400', async () => { jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger)); await testApiHandler({