From cd8891380a17f7d55cba8c1cfa45a7bcf7fdb001 Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 6 Nov 2026 12:07:00 -0300 Subject: [PATCH 1/2] fix(login): recover from Turnstile load failures on the login forms Once the failed-attempts threshold is reached the password and OTP login forms render Turnstile. When its iframe cannot be loaded the widget never renders and onError is not always invoked, and the password form answered a submit with "you must check CAPTCHA" while there was nothing to check. Use the shared TurnstileCaptcha component and useTurnstileCaptcha hook in both forms: submitting before a token exists asks the user to wait, no token after 15s or a Turnstile error shows a failure message with a retry action, and an expired token re-runs the widget. The OTP form is gated too, matching UserController::postLogin, which requires the token for both flows above the threshold. The captcha state moves out of LoginPage (captcha_value, its three handlers, the validation branch and the componentDidUpdate reset that only cleared it): each form owns it and remounts on a flow change. The shared component, its test and the Turnstile test double are the same files as in the registration fix (OpenStackweb/openstackid#184). --- resources/js/components/turnstile_captcha.js | 112 +++++++++++++ .../js/login/components/otp_input_form.js | 25 ++- .../login/components/password_input_form.js | 25 ++- resources/js/login/login.js | 45 ------ tests/js/components/turnstile_captcha.test.js | 150 ++++++++++++++++++ tests/js/helpers/turnstile_mock.js | 25 +++ .../js/login/components/login-captcha.test.js | 119 ++++++++++++++ 7 files changed, 425 insertions(+), 76 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/login/components/login-captcha.test.js diff --git a/resources/js/components/turnstile_captcha.js b/resources/js/components/turnstile_captcha.js new file mode 100644 index 00000000..7e0ec523 --- /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/login/components/otp_input_form.js b/resources/js/login/components/otp_input_form.js index 1634f815..12ae7821 100644 --- a/resources/js/login/components/otp_input_form.js +++ b/resources/js/login/components/otp_input_form.js @@ -1,9 +1,9 @@ import React from "react"; -import { Turnstile } from "@marsidev/react-turnstile"; import Button from "@material-ui/core/Button"; import Link from "@material-ui/core/Link"; import OtpCodeInput from "./otp_code_input"; import useOtpCountdown from "./use_otp_countdown"; +import { TurnstileCaptcha, useTurnstileCaptcha } from "../../components/turnstile_captcha"; import styles from "../login.module.scss"; const OTPInputForm = ({ @@ -20,13 +20,11 @@ const OTPInputForm = ({ csrfToken, shouldShowCaptcha, captchaPublicKey, - onChangeCaptchaProvider, - onExpireCaptchaProvider, - onErrorCaptchaProvider, onReset, loginAttempts, }) => { - const showCaptcha = shouldShowCaptcha(); + // The captcha only applies once the failed-attempts threshold is reached. + const captcha = useTurnstileCaptcha(shouldShowCaptcha() ? captchaPublicKey : ""); const { secondsLeft, expired } = useOtpCountdown(otpLifetime ?? 0, codeVersion); // The countdown only renders when this page view knows when the code was // issued (a fresh emitOTP in this session). After a failed-submit page @@ -36,7 +34,7 @@ const OTPInputForm = ({ const blockExpired = countdownActive && expired; const handleSubmit = (ev) => { - if (blockExpired || !onAuthenticate(ev.target)) + if (blockExpired || !captcha.ensureToken() || !onAuthenticate(ev.target)) { ev.preventDefault(); } @@ -98,16 +96,11 @@ const OTPInputForm = ({ id="login_attempts" name="login_attempts" /> - {showCaptcha && captchaPublicKey && ( - - )} + ); }; diff --git a/resources/js/login/components/password_input_form.js b/resources/js/login/components/password_input_form.js index 96e5a1cc..adfc947d 100644 --- a/resources/js/login/components/password_input_form.js +++ b/resources/js/login/components/password_input_form.js @@ -1,5 +1,4 @@ import React from "react"; -import { Turnstile } from "@marsidev/react-turnstile"; import TextField from "@material-ui/core/TextField"; import Button from "@material-ui/core/Button"; import Grid from "@material-ui/core/Grid"; @@ -12,6 +11,7 @@ import IconButton from "@material-ui/core/IconButton"; import ExistingAccountActions from "./existing_account_actions"; import styles from "../login.module.scss"; import HTMLRender from "../../shared/HTMLRender"; +import { TurnstileCaptcha, useTurnstileCaptcha } from "../../components/turnstile_captcha"; const PasswordInputForm = ({ formAction, @@ -27,9 +27,6 @@ const PasswordInputForm = ({ csrfToken, shouldShowCaptcha, captchaPublicKey, - onChangeCaptchaProvider, - onExpireCaptchaProvider, - onErrorCaptchaProvider, handleEmitOtpAction, forgotPasswordAction, loginAttempts, @@ -41,8 +38,11 @@ const PasswordInputForm = ({ // their save/update prompt off the browser's real submit event, and the backend // login strategies respond with a redirect + flashed session state that only a // top-level navigation consumes correctly. + // The captcha only applies once the failed-attempts threshold is reached. + const captcha = useTurnstileCaptcha(shouldShowCaptcha() ? captchaPublicKey : ""); + const handleSubmit = (ev) => { - if (!onAuthenticate(ev.target)) { + if (!captcha.ensureToken() || !onAuthenticate(ev.target)) { ev.preventDefault(); } }; @@ -166,16 +166,11 @@ const PasswordInputForm = ({ id="login_attempts" name="login_attempts" /> - {shouldShowCaptcha() && captchaPublicKey && ( - - )} + diff --git a/tests/js/components/turnstile_captcha.test.js b/tests/js/components/turnstile_captcha.test.js new file mode 100644 index 00000000..56eddc6b --- /dev/null +++ b/tests/js/components/turnstile_captcha.test.js @@ -0,0 +1,150 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +jest.mock('@marsidev/react-turnstile', () => require('../helpers/turnstile_mock')); + +import { mockTurnstile } from '../helpers/turnstile_mock'; +import { + TurnstileCaptcha, + useTurnstileCaptcha, + CAPTCHA_LOAD_TIMEOUT_MS, + CAPTCHA_MESSAGES, +} from '../../../resources/js/components/turnstile_captcha'; + +// Minimal Turnstile-protected form, wired the way the auth pages wire it. +const ProtectedForm = ({ siteKey, onPost }) => { + const captcha = useTurnstileCaptcha(siteKey); + return ( +
{ + e.preventDefault(); + if (captcha.ensureToken()) onPost(); + }} + > + + + + ); +}; + +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 00000000..a365ec9e --- /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/login/components/login-captcha.test.js b/tests/js/login/components/login-captcha.test.js new file mode 100644 index 00000000..ce45f6f6 --- /dev/null +++ b/tests/js/login/components/login-captcha.test.js @@ -0,0 +1,119 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +jest.mock('@marsidev/react-turnstile', () => require('../../helpers/turnstile_mock')); + +import { mockTurnstile } from '../../helpers/turnstile_mock'; +import { CAPTCHA_MESSAGES } from '../../../../resources/js/components/turnstile_captcha'; +import PasswordInputForm from '../../../../resources/js/login/components/password_input_form'; +import OTPInputForm from '../../../../resources/js/login/components/otp_input_form'; + +// Once the failed-attempts threshold is reached both login forms gate their native POST on +// a Turnstile token. Without one they must not call onAuthenticate and must ask the user to +// wait (or offer a retry when Turnstile fails), instead of "you must check CAPTCHA". + +const COMMON = { + formAction: '/auth/login', + disableInput: false, + userNameValue: 'ada@example.com', + csrfToken: 'csrf-token', + captchaPublicKey: 'site-key', + loginAttempts: 3, +}; + +const FORMS = [ + { + name: 'PasswordInputForm', + Form: PasswordInputForm, + submitLabel: /^continue$/i, + props: { + ...COMMON, + showPassword: false, + passwordValue: 'secret-password', + passwordError: '', + onUserPasswordChange: jest.fn(), + handleClickShowPassword: jest.fn(), + handleMouseDownPassword: jest.fn(), + handleEmitOtpAction: jest.fn(), + forgotPasswordAction: '/auth/password/reset', + maxLoginFailedAttempts: 10, + userIsActive: true, + helpAction: '/help', + }, + }, + { + name: 'OTPInputForm', + Form: OTPInputForm, + submitLabel: /^continue$/i, + props: { + ...COMMON, + otpCode: '123456', + otpError: '', + otpLength: 6, + otpLifetime: null, + codeVersion: 0, + onCodeChange: jest.fn(), + onReset: jest.fn(), + }, + }, +]; + +describe.each(FORMS)('$name captcha gate', ({ Form, props, submitLabel }) => { + let onAuthenticate; + + const renderForm = (overrides = {}) => + render(
); + const clickSubmit = () => fireEvent.click(screen.getByRole('button', { name: submitLabel })); + + beforeEach(() => { + mockTurnstile.clear(); + // Returning false keeps jsdom from attempting the native navigation. + onAuthenticate = jest.fn(() => false); + }); + + it('has no captcha and submits normally below the failed-attempts threshold', () => { + renderForm({ shouldShowCaptcha: () => false }); + + expect(screen.queryByTestId('turnstile')).not.toBeInTheDocument(); + clickSubmit(); + expect(onAuthenticate).toHaveBeenCalledTimes(1); + }); + + it('does not authenticate before Turnstile issues a token and asks the user to wait', () => { + renderForm({ shouldShowCaptcha: () => true }); + + clickSubmit(); + + expect(onAuthenticate).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent(CAPTCHA_MESSAGES.VERIFYING); + expect(screen.queryByText(/must check captcha/i)).not.toBeInTheDocument(); + }); + + it('authenticates once Turnstile has issued a token', () => { + renderForm({ shouldShowCaptcha: () => true }); + + mockTurnstile.token = 'turnstile-token'; + act(() => mockTurnstile.props.onSuccess('turnstile-token')); + clickSubmit(); + + expect(onAuthenticate).toHaveBeenCalledTimes(1); + }); + + it('offers a retry when Turnstile fails', () => { + renderForm({ shouldShowCaptcha: () => true }); + + act(() => mockTurnstile.props.onError('200500')); + + expect(screen.getByText(CAPTCHA_MESSAGES.FAILED)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /retry security check/i })); + expect(mockTurnstile.reset).toHaveBeenCalledTimes(1); + }); + + it('does not gate the submit when no site key is configured', () => { + renderForm({ shouldShowCaptcha: () => true, captchaPublicKey: '' }); + + expect(screen.queryByTestId('turnstile')).not.toBeInTheDocument(); + clickSubmit(); + expect(onAuthenticate).toHaveBeenCalledTimes(1); + }); +}); From 80e8546564b656904a6910153bee433f0ce9f259 Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 6 Nov 2026 12:37:57 -0300 Subject: [PATCH 2/2] fix(auth-ui): keep interactive challenges alive and reload api.js on retry Same change to the shared Turnstile component as in OpenStackweb/openstackid#184 (444cde1a), so both copies stay identical: an interactive challenge is no longer timed out as a load failure, and retry reloads api.js when the script itself never loaded. The login captcha test now simulates a loaded api.js, so retry exercises the widget reset path. --- resources/js/components/turnstile_captcha.js | 49 +++++++++++++--- tests/js/components/turnstile_captcha.test.js | 57 +++++++++++++++++++ tests/js/helpers/turnstile_mock.js | 7 ++- .../js/login/components/login-captcha.test.js | 6 ++ 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/resources/js/components/turnstile_captcha.js b/resources/js/components/turnstile_captcha.js index 7e0ec523..3658adc7 100644 --- a/resources/js/components/turnstile_captcha.js +++ b/resources/js/components/turnstile_captcha.js @@ -11,16 +11,22 @@ export const CAPTCHA_LOAD_TIMEOUT_MS = 15000; export const CAPTCHA_STATUS = { VERIFYING: "verifying", + // Turnstile is showing a challenge the user has to complete: not a load failure. + INTERACTIVE: "interactive", SOLVED: "solved", FAILED: "failed", }; export const CAPTCHA_MESSAGES = { VERIFYING: "Please wait while we verify you are human, then submit again.", + INTERACTIVE: "Complete the security check, then submit again.", FAILED: "We couldn't load the security check. Check your connection or disable any content blocker, then retry.", }; +// Id of the