From ff3cfbf1fc01b31569f8d62dc86ae4355a26871d Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 6 Nov 2026 11:26:42 -0300 Subject: [PATCH 1/5] fix(auth-ui): recover from Turnstile load failures on auth forms When the Turnstile iframe cannot be loaded (e.g. a network error reaching challenges.cloudflare.com) the widget never renders and onError is not always invoked. The registration, forgot/reset/set password and email verification forms then told the user to "check the captcha" while there was nothing to check. Move the captcha handling into a shared TurnstileCaptcha component and useTurnstileCaptcha hook: - submitting before a token exists asks the user to wait instead - no token after 15s, or a Turnstile error, shows a failure message with a "Retry security check" action that re-runs the widget - an expired or timed-out token re-runs the widget - with no site key configured nothing renders and the form is not blocked Each page now exports its component and only mounts when #root exists so it can be tested. --- resources/js/components/turnstile_captcha.js | 112 ++++++++++++ .../email_verification/email_verification.js | 54 +++--- .../js/forgot_password/forgot_password.js | 55 +++--- resources/js/reset_password/reset_password.js | 55 +++--- resources/js/set_password/set_password.js | 55 +++--- resources/js/signup/signup.js | 56 +++--- tests/js/components/turnstile_captcha.test.js | 150 ++++++++++++++++ tests/js/helpers/turnstile_mock.js | 25 +++ tests/js/turnstile_pages.test.js | 168 ++++++++++++++++++ 9 files changed, 556 insertions(+), 174 deletions(-) create mode 100644 resources/js/components/turnstile_captcha.js create mode 100644 tests/js/components/turnstile_captcha.test.js create mode 100644 tests/js/helpers/turnstile_mock.js create mode 100644 tests/js/turnstile_pages.test.js diff --git a/resources/js/components/turnstile_captcha.js b/resources/js/components/turnstile_captcha.js new file mode 100644 index 000000000..7e0ec5236 --- /dev/null +++ b/resources/js/components/turnstile_captcha.js @@ -0,0 +1,112 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; + +// Turnstile does not always invoke onError when its iframe cannot be loaded (e.g. a network +// error reaching challenges.cloudflare.com): the widget just never renders and never issues a +// token. Without a token the backend rejects the form, so after this long without one we treat +// the security check as failed and let the user retry it. +export const CAPTCHA_LOAD_TIMEOUT_MS = 15000; + +export const CAPTCHA_STATUS = { + VERIFYING: "verifying", + SOLVED: "solved", + FAILED: "failed", +}; + +export const CAPTCHA_MESSAGES = { + VERIFYING: "Please wait while we verify you are human, then submit again.", + FAILED: + "We couldn't load the security check. Check your connection or disable any content blocker, then retry.", +}; + +/** + * State for a Turnstile-protected form. With an empty siteKey the captcha is disabled: + * nothing renders and ensureToken() always lets the form through. + */ +export const useTurnstileCaptcha = (siteKey) => { + const ref = useRef(null); + const [status, setStatus] = useState(CAPTCHA_STATUS.VERIFYING); + const [submitAttempted, setSubmitAttempted] = useState(false); + + useEffect(() => { + if (!siteKey || status !== CAPTCHA_STATUS.VERIFYING) { + return undefined; + } + const timer = setTimeout( + () => setStatus(CAPTCHA_STATUS.FAILED), + CAPTCHA_LOAD_TIMEOUT_MS + ); + return () => clearTimeout(timer); + }, [siteKey, status]); + + // Call before posting the form: true when it may be posted, otherwise it surfaces why not. + const ensureToken = () => { + if (!siteKey || ref.current?.getResponse()) { + return true; + } + setSubmitAttempted(true); + return false; + }; + + const onSuccess = () => { + setStatus(CAPTCHA_STATUS.SOLVED); + setSubmitAttempted(false); + }; + + const restart = () => { + setStatus(CAPTCHA_STATUS.VERIFYING); + ref.current?.reset(); + }; + + const retry = () => { + setSubmitAttempted(false); + restart(); + }; + + const onError = () => setStatus(CAPTCHA_STATUS.FAILED); + + const message = + status === CAPTCHA_STATUS.FAILED + ? CAPTCHA_MESSAGES.FAILED + : status === CAPTCHA_STATUS.VERIFYING && submitAttempted + ? CAPTCHA_MESSAGES.VERIFYING + : null; + + return { siteKey, ref, status, message, ensureToken, onSuccess, restart, retry, onError }; +}; + +/** + * Renders the Turnstile widget plus its status message and retry action. + * `captcha` is the object returned by useTurnstileCaptcha(). + */ +export const TurnstileCaptcha = ({ captcha, className, errorClassName }) => { + if (!captcha.siteKey) { + return null; + } + return ( + + + {captcha.message && ( +
+ {captcha.message} +
+ )} + {captcha.status === CAPTCHA_STATUS.FAILED && ( + + )} +
+ ); +}; diff --git a/resources/js/email_verification/email_verification.js b/resources/js/email_verification/email_verification.js index 9d5b82ae0..5e355a64d 100644 --- a/resources/js/email_verification/email_verification.js +++ b/resources/js/email_verification/email_verification.js @@ -1,6 +1,5 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef } from "react"; import ReactDOM from "react-dom"; -import { Turnstile } from "@marsidev/react-turnstile"; import Button from "@material-ui/core/Button"; import Card from "@material-ui/core/Card"; import CardHeader from "@material-ui/core/CardHeader"; @@ -15,6 +14,7 @@ import { MuiThemeProvider, createTheme } from "@material-ui/core/styles"; import { useFormik } from "formik"; import { object, string, ref } from "yup"; import Banner from "../components/banner/banner"; +import { TurnstileCaptcha, useTurnstileCaptcha } from "../components/turnstile_captcha"; import styles from "./email_verification.module.scss"; @@ -37,8 +37,7 @@ const EmailVerificationPage = ({ submitButtonText, }) => { const formEl = useRef(null); - const captcha = useRef(null); - const [captchaConfirmation, setCaptchaConfirmation] = useState(null); + const captcha = useTurnstileCaptcha(captchaPublicKey); useEffect(() => { if (emailVerificationError) { @@ -56,20 +55,13 @@ const EmailVerificationPage = ({ initialValues: initialValues, validationSchema: validationSchema, onSubmit: () => { - if (!captcha.current?.getResponse()) { - setCaptchaConfirmation("Remember to check the captcha"); + if (!captcha.ensureToken()) { return; } doHtmlFormPost(); }, }); - const onChangeCaptchaProvider = () => { - if (captcha.current?.getResponse()) { - setCaptchaConfirmation(null); - } - }; - return ( @@ -120,22 +112,11 @@ const EmailVerificationPage = ({ /> - - { captcha.current?.reset(); }} - onError={() => setCaptchaConfirmation('The security check encountered an error. Please refresh the page and try again.')} - /> - {captchaConfirmation && ( -
- {captchaConfirmation} -
- )} -
+ + + ); +}; + +const submit = () => fireEvent.click(screen.getByRole('button', { name: 'Submit' })); +const retryButton = () => screen.queryByRole('button', { name: /retry security check/i }); +const solve = () => { + mockTurnstile.token = 'turnstile-token'; + act(() => mockTurnstile.props.onSuccess('turnstile-token')); +}; + +describe('TurnstileCaptcha / useTurnstileCaptcha', () => { + let onPost; + + beforeEach(() => { + mockTurnstile.clear(); + onPost = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('asks the user to wait, instead of to "check the captcha", when submitted before a token exists', () => { + render(); + + submit(); + + expect(screen.getByRole('alert')).toHaveTextContent(CAPTCHA_MESSAGES.VERIFYING); + expect(screen.queryByText(/remember to check the captcha/i)).not.toBeInTheDocument(); + expect(onPost).not.toHaveBeenCalled(); + }); + + it('lets the form through and clears the waiting message once a token is issued', () => { + render(); + + submit(); + solve(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + submit(); + expect(onPost).toHaveBeenCalledTimes(1); + }); + + it('posts the response under the field name the backend validates', () => { + render(); + + expect(mockTurnstile.props.siteKey).toBe('site-key'); + expect(mockTurnstile.props.options).toEqual({ responseFieldName: 'cf-turnstile-response' }); + }); + + it('reports a failed security check with a retry when no token arrives (iframe never loaded)', () => { + jest.useFakeTimers(); + render(); + + act(() => jest.advanceTimersByTime(CAPTCHA_LOAD_TIMEOUT_MS - 1)); + expect(screen.queryByText(CAPTCHA_MESSAGES.FAILED)).not.toBeInTheDocument(); + + act(() => jest.advanceTimersByTime(1)); + expect(screen.getByRole('alert')).toHaveTextContent(CAPTCHA_MESSAGES.FAILED); + + fireEvent.click(retryButton()); + expect(mockTurnstile.reset).toHaveBeenCalledTimes(1); + expect(screen.queryByText(CAPTCHA_MESSAGES.FAILED)).not.toBeInTheDocument(); + expect(retryButton()).not.toBeInTheDocument(); + + // The retried widget gets a fresh timeout window. + act(() => jest.advanceTimersByTime(CAPTCHA_LOAD_TIMEOUT_MS)); + expect(screen.getByText(CAPTCHA_MESSAGES.FAILED)).toBeInTheDocument(); + }); + + it('does not report a failure once a token arrived before the timeout', () => { + jest.useFakeTimers(); + render(); + + solve(); + act(() => jest.advanceTimersByTime(CAPTCHA_LOAD_TIMEOUT_MS * 2)); + + expect(screen.queryByText(CAPTCHA_MESSAGES.FAILED)).not.toBeInTheDocument(); + }); + + it('reports a failed security check with a retry when Turnstile raises an error', () => { + render(); + + act(() => mockTurnstile.props.onError('200500')); + + expect(screen.getByRole('alert')).toHaveTextContent(CAPTCHA_MESSAGES.FAILED); + expect(retryButton()).toBeInTheDocument(); + }); + + it('recovers if Turnstile succeeds on its own after an error', () => { + render(); + + act(() => mockTurnstile.props.onError('200500')); + solve(); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(retryButton()).not.toBeInTheDocument(); + }); + + it('re-runs the widget when the token expires and blocks the post until a new one arrives', () => { + render(); + + solve(); + mockTurnstile.token = undefined; + act(() => mockTurnstile.props.onExpire()); + + expect(mockTurnstile.reset).toHaveBeenCalledTimes(1); + submit(); + expect(onPost).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent(CAPTCHA_MESSAGES.VERIFYING); + }); + + it('renders nothing and never blocks the post when no site key is configured', () => { + jest.useFakeTimers(); + render(); + + expect(screen.queryByTestId('turnstile')).not.toBeInTheDocument(); + act(() => jest.advanceTimersByTime(CAPTCHA_LOAD_TIMEOUT_MS * 2)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + submit(); + expect(onPost).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/js/helpers/turnstile_mock.js b/tests/js/helpers/turnstile_mock.js new file mode 100644 index 000000000..a365ec9eb --- /dev/null +++ b/tests/js/helpers/turnstile_mock.js @@ -0,0 +1,25 @@ +// Test double for @marsidev/react-turnstile. Use it with: +// jest.mock('@marsidev/react-turnstile', () => require('/helpers/turnstile_mock')); +// It records the callbacks the page wires up (so a test can fire onSuccess / onError) and +// exposes the imperative handle (getResponse / reset) the page reads through its ref. +import React, { forwardRef, useImperativeHandle } from 'react'; + +export const mockTurnstile = { + props: null, + token: undefined, + reset: jest.fn(), + clear() { + this.props = null; + this.token = undefined; + this.reset.mockClear(); + }, +}; + +export const Turnstile = forwardRef((props, ref) => { + mockTurnstile.props = props; + useImperativeHandle(ref, () => ({ + getResponse: () => mockTurnstile.token, + reset: mockTurnstile.reset, + })); + return
; +}); diff --git a/tests/js/turnstile_pages.test.js b/tests/js/turnstile_pages.test.js new file mode 100644 index 000000000..49b548e54 --- /dev/null +++ b/tests/js/turnstile_pages.test.js @@ -0,0 +1,168 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; + +jest.mock('@marsidev/react-turnstile', () => require('./helpers/turnstile_mock')); +jest.mock('react-password-strength-bar', () => () => null); + +import { mockTurnstile } from './helpers/turnstile_mock'; +import { CAPTCHA_MESSAGES } from '../../resources/js/components/turnstile_captcha'; +import { SignUpPage } from '../../resources/js/signup/signup'; +import { ForgotPasswordPage } from '../../resources/js/forgot_password/forgot_password'; +import { ResetPasswordPage } from '../../resources/js/reset_password/reset_password'; +import { SetPasswordPage } from '../../resources/js/set_password/set_password'; +import { EmailVerificationPage } from '../../resources/js/email_verification/email_verification'; + +// Every Turnstile-protected auth page must gate its HTML form post on the shared captcha: +// no post (and a "please wait" message, not "check the captcha") until a token exists. +// The captcha's own states (timeout, error, retry) are covered in components/turnstile_captcha.test.js. + +const PASSWORD = 'Abcdef123!'; +const PASSWORD_POLICY = { + min_length: 10, + max_length: 30, + shape_pattern: '^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*+\\-])[A-Za-z0-9#?!@$%^&*+\\-]+$', + allowed_special_characters: '[A-Za-z0-9#?!@$%^&*+-]', + shape_warning: 'Password must include upper, lower, number and special character', +}; +const COUNTRIES = [{ value: 'US', text: 'United States' }]; +const COMMON = { + appLogo: '', + captchaPublicKey: 'site-key', + csrfToken: 'csrf-token', + infoBannerContent: '', + showInfoBanner: false, + sessionStatus: '', + submitButtonText: 'Submit form', +}; + +const PAGES = [ + { + name: 'SignUpPage', + Page: SignUpPage, + submitLabel: /register now/i, + props: { + ...COMMON, + clientId: '', + codeOfConductUrl: '', + countries: COUNTRIES, + initialValues: { + first_name: 'Ada', last_name: 'Lovelace', email: 'ada@example.com', + country_iso_code: 'US', password: PASSWORD, password_confirmation: PASSWORD, + }, + passwordPolicy: PASSWORD_POLICY, + redirectUri: '', + signInAction: '/auth/login', + signUpAction: '/auth/register', + signUpError: '', + tenantName: 'FNTECH', + }, + }, + { + name: 'ForgotPasswordPage', + Page: ForgotPasswordPage, + submitLabel: 'Submit form', + props: { + ...COMMON, + clientId: '', + forgotPasswordAction: '/auth/password/email', + forgotPasswordError: '', + initialValues: { email: 'ada@example.com' }, + redirectUri: '', + }, + }, + { + name: 'ResetPasswordPage', + Page: ResetPasswordPage, + submitLabel: 'Submit form', + props: { + ...COMMON, + token: 'reset-token', + initialValues: { email: 'ada@example.com', password: PASSWORD, password_confirmation: PASSWORD }, + passwordPolicy: PASSWORD_POLICY, + resetPasswordAction: '/auth/password/reset', + resetPasswordError: '', + }, + }, + { + name: 'SetPasswordPage', + Page: SetPasswordPage, + submitLabel: 'Submit form', + props: { + ...COMMON, + clientId: '', + countries: COUNTRIES, + initialValues: { + email: 'ada@example.com', first_name: 'Ada', last_name: 'Lovelace', company: 'Acme', + country_iso_code: 'US', password: PASSWORD, password_confirmation: PASSWORD, + }, + passwordPolicy: PASSWORD_POLICY, + redirectUri: '', + setPasswordAction: '/auth/password/set', + setPasswordError: '', + token: 'set-token', + }, + }, + { + name: 'EmailVerificationPage', + Page: EmailVerificationPage, + submitLabel: 'Submit form', + props: { + ...COMMON, + emailVerificationAction: '/auth/verification/resend', + emailVerificationError: '', + initialValues: { email: 'ada@example.com' }, + }, + }, +]; + +describe.each(PAGES)('$name security check wiring', ({ Page, props, submitLabel }) => { + let formSubmit; + const clickSubmit = () => fireEvent.click(screen.getByRole('button', { name: submitLabel })); + + beforeEach(() => { + mockTurnstile.clear(); + formSubmit = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {}); + }); + + afterEach(() => { + formSubmit.mockRestore(); + }); + + it('does not post before Turnstile issues a token and asks the user to wait', async () => { + render(); + + clickSubmit(); + + expect(await screen.findByText(CAPTCHA_MESSAGES.VERIFYING)).toBeInTheDocument(); + expect(screen.queryByText(/remember to check the captcha/i)).not.toBeInTheDocument(); + expect(formSubmit).not.toHaveBeenCalled(); + }); + + it('posts the form once Turnstile has issued a token', async () => { + render(); + + mockTurnstile.token = 'turnstile-token'; + act(() => mockTurnstile.props.onSuccess('turnstile-token')); + clickSubmit(); + + await waitFor(() => expect(formSubmit).toHaveBeenCalledTimes(1)); + }); + + it('offers a retry when Turnstile fails', () => { + render(); + + act(() => mockTurnstile.props.onError('200500')); + + expect(screen.getByText(CAPTCHA_MESSAGES.FAILED)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /retry security check/i })).toBeInTheDocument(); + }); + + it('neither renders the widget nor blocks the post when no site key is configured', async () => { + render(); + + expect(screen.queryByTestId('turnstile')).not.toBeInTheDocument(); + clickSubmit(); + + await waitFor(() => expect(formSubmit).toHaveBeenCalledTimes(1)); + }); +}); From c0243ec9e00e1ecdd1afc10cab458e3fcf8604ef Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 6 Nov 2026 11:26:47 -0300 Subject: [PATCH 2/5] fix(registration): stop logging the raw sign-up payload UserService::registerUser logged json_encode($payload) at debug level. The payload is the raw sign-up form, so every registration wrote the plaintext password, its confirmation, the Turnstile token and the CSRF token to the application log (prod runs at debug). Log the email and the submitted field names only. --- app/Services/Auth/UserService.php | 14 +++++- tests/UserServiceRegisterLoggingTest.php | 62 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/UserServiceRegisterLoggingTest.php diff --git a/app/Services/Auth/UserService.php b/app/Services/Auth/UserService.php index f4df159d8..ff22114ee 100644 --- a/app/Services/Auth/UserService.php +++ b/app/Services/Auth/UserService.php @@ -128,9 +128,19 @@ public function registerUser(array $payload, ?OAuth2OTP $otp = null):User { return $this->tx_service->transaction(function () use ($payload, $otp) { - Log::debug(sprintf("UserService::registerUser payload %s", json_encode($payload))); - $email = trim($payload['email']); + + // The payload is the raw sign-up form (password, confirmation, captcha and CSRF + // tokens): log field names only, never values. + Log::debug + ( + sprintf + ( + "UserService::registerUser email %s fields %s", + $email, + implode(',', array_keys($payload)) + ) + ); $former_user = $this->user_repository->getByEmailOrName($email); if (!is_null($former_user)) diff --git a/tests/UserServiceRegisterLoggingTest.php b/tests/UserServiceRegisterLoggingTest.php new file mode 100644 index 000000000..236958c31 --- /dev/null +++ b/tests/UserServiceRegisterLoggingTest.php @@ -0,0 +1,62 @@ +message . ' ' . json_encode($event->context); + }); + + $user = $this->app->make(IUserService::class)->registerUser([ + 'first_name' => 'Log', + 'last_name' => 'Safe', + 'email' => $email, + 'country_iso_code' => 'US', + 'password' => $password, + 'password_confirmation' => $password, + 'cf-turnstile-response' => $captcha, + '_token' => $csrf, + ]); + + $this->assertSame($email, $user->getEmail()); + + $log = implode("\n", $logged); + $this->assertStringContainsString( + 'UserService::registerUser', + $log, + 'registerUser log lines must be captured for this test to be meaningful' + ); + $this->assertStringNotContainsString($password, $log, 'password leaked to the log'); + $this->assertStringNotContainsString($captcha, $log, 'Turnstile token leaked to the log'); + $this->assertStringNotContainsString($csrf, $log, 'CSRF token leaked to the log'); + } +} From 444cde1a38ccbe34fb2fe889ac98accf689f3436 Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 6 Nov 2026 12:36:40 -0300 Subject: [PATCH 3/5] fix(auth-ui): keep interactive challenges alive and reload api.js on retry - An interactive Turnstile challenge can take longer than the 15s load timeout. onBeforeInteractive now moves the captcha to an interactive state that is not timed out; submitting meanwhile asks the user to complete the check. A restart (expiry, challenge timeout) re-arms the timeout. - When api.js itself failed to load (blocked or a network error) there is no widget for reset() to act on, and @marsidev/react-turnstile does not inject the script again while the failed