From 6b6df4247ff08adaf122f9c19043ba857c507d66 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Fri, 23 Oct 2026 03:51:15 -0700 Subject: [PATCH] Use reCAPTCHA v3 on password reset (#135) ## Summary - Closes #134. - Password reset loads reCAPTCHA v3 and sends `grecaptcha.execute(siteKey, { action: 'password_reset' })` as `recaptchaToken`. - Removes `react-google-recaptcha`. That package is a v2 widget, and the Hesychia key is v3, so Google rejected the site key on https://hesychia.ai/password_reset/ before submit. - Site key stays `6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ`. The backend already checks the v3 token with `CAPTCHA_SECRET_KEY` and rejects a score under 0.5. ## Test plan - [x] `CI=true npm test -- --watchAll=false --coverage=false --testPathPattern=pages/PasswordReset` (6 passed) - [ ] After deploy, open https://hesychia.ai/password_reset/ and confirm the invalid reCAPTCHA error is gone - [ ] Submit a real account email and confirm the reset email sends - [ ] Submit again with a bad or blocked client and confirm the page shows "Unable to reset password" Reviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/135 --- llm-fe/package-lock.json | 37 ------------ llm-fe/package.json | 2 - .../PasswordReset/PasswordReset.test.tsx | 55 ++++++++++------- .../pages/PasswordReset/PasswordReset.tsx | 26 +++----- .../pages/PasswordReset/recaptchaV3.test.ts | 53 +++++++++++++++++ .../llm-fe/pages/PasswordReset/recaptchaV3.ts | 59 +++++++++++++++++++ 6 files changed, 154 insertions(+), 78 deletions(-) create mode 100644 llm-fe/src/llm-fe/pages/PasswordReset/recaptchaV3.test.ts create mode 100644 llm-fe/src/llm-fe/pages/PasswordReset/recaptchaV3.ts diff --git a/llm-fe/package-lock.json b/llm-fe/package-lock.json index b3b6483..5fa5105 100644 --- a/llm-fe/package-lock.json +++ b/llm-fe/package-lock.json @@ -64,11 +64,9 @@ "@types/lodash": "~4.17.13", "@types/react": "^18.3.16", "@types/react-dom": "^18.3.5", - "@types/react-google-recaptcha": "^2.1.9", "@types/react-syntax-highlighter": "^15.5.13", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-google-recaptcha": "^3.1.0", "typescript": "^4.9.5" } }, @@ -6540,15 +6538,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/react-google-recaptcha": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", - "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", - "dev": true, - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/react-syntax-highlighter": { "version": "15.5.13", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", @@ -19090,19 +19079,6 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, - "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==", - "dev": true, - "dependencies": { - "hoist-non-react-statics": "^3.3.0", - "prop-types": "^15.5.0" - }, - "peerDependencies": { - "react": ">=16.4.1" - } - }, "node_modules/react-bootstrap": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz", @@ -19301,19 +19277,6 @@ "react": ">=16.3.0" } }, - "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==", - "dev": true, - "dependencies": { - "prop-types": "^15.5.0", - "react-async-script": "^1.2.0" - }, - "peerDependencies": { - "react": ">=16.4.1" - } - }, "node_modules/react-is": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", diff --git a/llm-fe/package.json b/llm-fe/package.json index 079e545..d55a281 100644 --- a/llm-fe/package.json +++ b/llm-fe/package.json @@ -92,11 +92,9 @@ "@types/lodash": "~4.17.13", "@types/react": "^18.3.16", "@types/react-dom": "^18.3.5", - "@types/react-google-recaptcha": "^2.1.9", "@types/react-syntax-highlighter": "^15.5.13", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-google-recaptcha": "^3.1.0", "typescript": "^4.9.5" }, "overrides": { diff --git a/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.test.tsx b/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.test.tsx index 2691d90..4286bab 100644 --- a/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.test.tsx +++ b/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.test.tsx @@ -3,29 +3,13 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import PasswordReset from './PasswordReset'; +import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY } from './recaptchaV3'; const mockPost = jest.fn(); -const mockExecuteAsync = jest.fn(); -const mockReset = jest.fn(); +const mockExecute = jest.fn(); jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); -jest.mock('react-google-recaptcha', () => { - const ReactLib = require('react'); - return ReactLib.forwardRef( - ( - _props: unknown, - ref: React.Ref<{ executeAsync: () => Promise; reset: () => void }> - ) => { - ReactLib.useImperativeHandle(ref, () => ({ - executeAsync: mockExecuteAsync, - reset: mockReset, - })); - return
; - } - ); -}); - jest.mock('../../../axiosApi', () => ({ cleanAxiosInstance: { post: (...args: unknown[]) => mockPost(...args), @@ -48,13 +32,23 @@ const renderPasswordReset = () => describe('PasswordReset', () => { beforeEach(() => { mockPost.mockReset(); - mockExecuteAsync.mockReset(); - mockReset.mockReset(); - mockExecuteAsync.mockResolvedValue('captcha-token'); + mockExecute.mockReset(); + mockExecute.mockResolvedValue('captcha-token'); mockPost.mockResolvedValue({ status: 200 }); + window.grecaptcha = { + ready: (callback) => callback(), + execute: mockExecute, + }; }); - it('submits email with captcha token and navigates to confirmation', async () => { + afterEach(() => { + delete window.grecaptcha; + document + .querySelectorAll('script[data-recaptcha-v3="true"]') + .forEach((node) => node.remove()); + }); + + it('submits email with a v3 captcha token and navigates to confirmation', async () => { const user = userEvent.setup(); renderPasswordReset(); @@ -62,6 +56,9 @@ describe('PasswordReset', () => { await user.click(screen.getByRole('button', { name: 'Reset Password' })); await waitFor(() => { + expect(mockExecute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, { + action: RECAPTCHA_ACTION, + }); expect(mockPost).toHaveBeenCalledWith('user/reset_password/', { email: 'user@example.com', recaptchaToken: 'captcha-token', @@ -70,6 +67,20 @@ describe('PasswordReset', () => { expect(await screen.findByText('Confirmation page')).toBeInTheDocument(); }); + it('shows error when captcha fails and does not post', async () => { + mockExecute.mockRejectedValue(new Error('invalid site key')); + const user = userEvent.setup(); + renderPasswordReset(); + + await user.type(screen.getByPlaceholderText('Email Address'), 'user@example.com'); + await user.click(screen.getByRole('button', { name: 'Reset Password' })); + + expect( + await screen.findByText('Unable to reset password. Please try again.') + ).toBeInTheDocument(); + expect(mockPost).not.toHaveBeenCalled(); + }); + it('shows error when request fails', async () => { mockPost.mockRejectedValue(new Error('network')); const user = userEvent.setup(); diff --git a/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.tsx b/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.tsx index 2435fa2..daad37c 100644 --- a/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.tsx +++ b/llm-fe/src/llm-fe/pages/PasswordReset/PasswordReset.tsx @@ -1,11 +1,11 @@ import { Form, Formik, Field } from 'formik'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { cleanAxiosInstance } from '../../../axiosApi'; import { useNavigate } from 'react-router-dom'; -import ReCAPTCHA from 'react-google-recaptcha'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import styled from 'styled-components'; import * as Yup from 'yup'; +import { executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3'; const PageContainer = styled.div` position: relative; @@ -139,20 +139,20 @@ const validationSchema = Yup.object().shape({ const PasswordReset = (): JSX.Element => { const navigate = useNavigate(); - const recaptchaRef = useRef(null); const [errorMessage, setErrorMessage] = useState(''); + useEffect(() => { + loadRecaptcha().catch(() => { + // Submit reports a failure if the v3 script never becomes ready. + }); + }, []); + const handlePasswordResetEmail = async ({ email, }: EmailPasswordResetValues): Promise => { setErrorMessage(''); try { - let token = ''; - if (recaptchaRef.current) { - token = (await recaptchaRef.current.executeAsync()) || ''; - recaptchaRef.current.reset(); - } - + const token = await executePasswordResetCaptcha(); await cleanAxiosInstance.post('user/reset_password/', { email, recaptchaToken: token, @@ -186,14 +186,6 @@ const PasswordReset = (): JSX.Element => { placeholder="Email Address" type="email" /> -
- -
Reset Password diff --git a/llm-fe/src/llm-fe/pages/PasswordReset/recaptchaV3.test.ts b/llm-fe/src/llm-fe/pages/PasswordReset/recaptchaV3.test.ts new file mode 100644 index 0000000..7503c63 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/PasswordReset/recaptchaV3.test.ts @@ -0,0 +1,53 @@ +import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY, executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3'; + +describe('recaptchaV3', () => { + afterEach(() => { + delete window.grecaptcha; + document + .querySelectorAll('script[data-recaptcha-v3="true"]') + .forEach((node) => node.remove()); + }); + + it('loads the v3 script once when grecaptcha is missing', async () => { + const pending = loadRecaptcha(); + const again = loadRecaptcha(); + const scripts = document.querySelectorAll('script[data-recaptcha-v3="true"]'); + + expect(scripts).toHaveLength(1); + expect(scripts[0]).toHaveAttribute( + 'src', + `https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}` + ); + + window.grecaptcha = { + ready: (callback) => callback(), + execute: jest.fn(), + }; + scripts[0].dispatchEvent(new Event('load')); + await expect(pending).resolves.toBeUndefined(); + await expect(again).resolves.toBeUndefined(); + }); + + it('executes the password_reset action', async () => { + const execute = jest.fn().mockResolvedValue('token-1'); + window.grecaptcha = { + ready: (callback) => callback(), + execute, + }; + + await expect(executePasswordResetCaptcha()).resolves.toBe('token-1'); + expect(execute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, { + action: RECAPTCHA_ACTION, + }); + expect(document.querySelector('script[data-recaptcha-v3="true"]')).toBeNull(); + }); + + it('rejects an empty token', async () => { + window.grecaptcha = { + ready: (callback) => callback(), + execute: jest.fn().mockResolvedValue(''), + }; + + await expect(executePasswordResetCaptcha()).rejects.toThrow('empty reCAPTCHA token'); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/PasswordReset/recaptchaV3.ts b/llm-fe/src/llm-fe/pages/PasswordReset/recaptchaV3.ts new file mode 100644 index 0000000..4a04900 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/PasswordReset/recaptchaV3.ts @@ -0,0 +1,59 @@ +export const RECAPTCHA_SITE_KEY = '6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ'; +export const RECAPTCHA_ACTION = 'password_reset'; + +type Grecaptcha = { + ready: (callback: () => void) => void; + execute: (siteKey: string, options: { action: string }) => Promise; +}; + +declare global { + interface Window { + grecaptcha?: Grecaptcha; + } +} + +let loading: Promise | null = null; + +export function loadRecaptcha(siteKey: string = RECAPTCHA_SITE_KEY): Promise { + if (window.grecaptcha) { + return Promise.resolve(); + } + if (loading && document.querySelector('script[data-recaptcha-v3="true"]')) { + return loading; + } + + loading = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`; + script.async = true; + script.dataset.recaptchaV3 = 'true'; + script.onload = () => resolve(); + script.onerror = () => { + loading = null; + script.remove(); + reject(new Error('reCAPTCHA failed to load')); + }; + document.head.appendChild(script); + }); + return loading; +} + +export async function executePasswordResetCaptcha( + siteKey: string = RECAPTCHA_SITE_KEY +): Promise { + await loadRecaptcha(siteKey); + const grecaptcha = window.grecaptcha; + if (!grecaptcha) { + throw new Error('reCAPTCHA unavailable'); + } + + await new Promise((resolve) => { + grecaptcha.ready(resolve); + }); + + const token = await grecaptcha.execute(siteKey, { action: RECAPTCHA_ACTION }); + if (!token) { + throw new Error('empty reCAPTCHA token'); + } + return token; +}