From ceba2acbd0da5382d92c7d468ce2f9246a545171 Mon Sep 17 00:00:00 2001 From: Stijn Jakobs Date: Sat, 30 May 2026 13:38:06 +0200 Subject: [PATCH] Add configurable captcha providers --- .env.ci | 2 + .env.example | 17 ++ .../Admin/Settings/AdvancedController.php | 8 +- app/Http/Middleware/VerifyCaptcha.php | 40 +++ app/Http/Middleware/VerifyReCaptcha.php | 71 +----- .../Settings/AdvancedSettingsFormRequest.php | 16 +- app/Http/ViewComposers/AssetComposer.php | 14 +- .../Captcha/CaptchaVerificationService.php | 119 +++++++++ config/captcha.php | 43 ++++ config/recaptcha.php | 35 ++- resources/scripts/api/auth/login.ts | 6 +- .../api/auth/requestPasswordResetEmail.ts | 12 +- .../auth/ForgotPasswordContainer.tsx | 90 ++++++- .../components/auth/LoginContainer.tsx | 97 +++++++- resources/scripts/globals.d.ts | 18 ++ resources/scripts/state/settings.ts | 6 + .../Http/Middleware/VerifyCaptchaTest.php | 232 ++++++++++++++++++ 17 files changed, 702 insertions(+), 124 deletions(-) create mode 100644 app/Http/Middleware/VerifyCaptcha.php create mode 100644 app/Services/Captcha/CaptchaVerificationService.php create mode 100644 config/captcha.php create mode 100644 tests/Unit/Http/Middleware/VerifyCaptchaTest.php diff --git a/.env.ci b/.env.ci index 1a9e848e3..f8e80e0a5 100644 --- a/.env.ci +++ b/.env.ci @@ -18,3 +18,5 @@ MAIL_DRIVER=array QUEUE_DRIVER=sync HASHIDS_SALT=test123 + +CAPTCHA_PROVIDER=none diff --git a/.env.example b/.env.example index fc97db959..bf2cf551e 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,20 @@ MAIL_FROM_NAME="Pterodactyl Panel" # # @see: https://github.com/pterodactyl/panel/pull/3110 # MAIL_EHLO_DOMAIN=panel.example.com + +## +# CAPTCHA Configuration +# Supported providers: recaptcha, turnstile, none +# - recaptcha: Google reCAPTCHA v2 (invisible) +# - turnstile: Cloudflare Turnstile (no mobile QR-code verification) +# - none: Disable CAPTCHA entirely (not recommended for production) +## +CAPTCHA_PROVIDER=recaptcha + +# Google reCAPTCHA keys (used when CAPTCHA_PROVIDER=recaptcha) +# RECAPTCHA_SECRET_KEY= +# RECAPTCHA_WEBSITE_KEY= + +# Cloudflare Turnstile keys (used when CAPTCHA_PROVIDER=turnstile) +# TURNSTILE_SECRET_KEY= +# TURNSTILE_WEBSITE_KEY= diff --git a/app/Http/Controllers/Admin/Settings/AdvancedController.php b/app/Http/Controllers/Admin/Settings/AdvancedController.php index 9c8bc03f9..3cf2befa7 100644 --- a/app/Http/Controllers/Admin/Settings/AdvancedController.php +++ b/app/Http/Controllers/Admin/Settings/AdvancedController.php @@ -30,9 +30,13 @@ public function __construct( public function index(): View { $showRecaptchaWarning = false; + $provider = $this->config->get('captcha.provider', 'none'); + if ( - $this->config->get('recaptcha._shipped_secret_key') === $this->config->get('recaptcha.secret_key') - || $this->config->get('recaptcha._shipped_website_key') === $this->config->get('recaptcha.website_key') + $provider === 'recaptcha' && ( + $this->config->get('captcha.recaptcha._shipped_secret_key') === $this->config->get('captcha.recaptcha.secret_key') + || $this->config->get('captcha.recaptcha._shipped_website_key') === $this->config->get('captcha.recaptcha.website_key') + ) ) { $showRecaptchaWarning = true; } diff --git a/app/Http/Middleware/VerifyCaptcha.php b/app/Http/Middleware/VerifyCaptcha.php new file mode 100644 index 000000000..131dbc99e --- /dev/null +++ b/app/Http/Middleware/VerifyCaptcha.php @@ -0,0 +1,40 @@ +captcha->isEnabled()) { + return $next($request); + } + + if ($this->captcha->verify($request)) { + return $next($request); + } + + $this->dispatcher->dispatch( + new FailedCaptcha($request->ip(), $request->getHost()) + ); + + throw new HttpException( + Response::HTTP_BAD_REQUEST, + 'Failed to validate CAPTCHA data.' + ); + } +} diff --git a/app/Http/Middleware/VerifyReCaptcha.php b/app/Http/Middleware/VerifyReCaptcha.php index ef251c333..2e6f4c2de 100644 --- a/app/Http/Middleware/VerifyReCaptcha.php +++ b/app/Http/Middleware/VerifyReCaptcha.php @@ -2,71 +2,10 @@ namespace Pterodactyl\Http\Middleware; -use GuzzleHttp\Client; -use Illuminate\Http\Request; -use Illuminate\Http\Response; -use Pterodactyl\Events\Auth\FailedCaptcha; -use Illuminate\Contracts\Config\Repository; -use Illuminate\Contracts\Events\Dispatcher; -use Symfony\Component\HttpKernel\Exception\HttpException; - -class VerifyReCaptcha +/** + * @deprecated Use VerifyCaptcha instead. Kept for backward compatibility with + * existing route middleware alias 'recaptcha'. + */ +class VerifyReCaptcha extends VerifyCaptcha { - /** - * VerifyReCaptcha constructor. - */ - public function __construct(private Dispatcher $dispatcher, private Repository $config) - { - } - - /** - * Handle an incoming request. - */ - public function handle(Request $request, \Closure $next): mixed - { - if (!$this->config->get('recaptcha.enabled')) { - return $next($request); - } - - if ($request->filled('g-recaptcha-response')) { - $client = new Client(); - $res = $client->post($this->config->get('recaptcha.domain'), [ - 'form_params' => [ - 'secret' => $this->config->get('recaptcha.secret_key'), - 'response' => $request->input('g-recaptcha-response'), - ], - ]); - - if ($res->getStatusCode() === 200) { - $result = json_decode($res->getBody()); - - if ($result->success && (!$this->config->get('recaptcha.verify_domain') || $this->isResponseVerified($result, $request))) { - return $next($request); - } - } - } - - $this->dispatcher->dispatch( - new FailedCaptcha( - $request->ip(), - !empty($result) ? ($result->hostname ?? null) : null - ) - ); - - throw new HttpException(Response::HTTP_BAD_REQUEST, 'Failed to validate reCAPTCHA data.'); - } - - /** - * Determine if the response from the recaptcha servers was valid. - */ - private function isResponseVerified(\stdClass $result, Request $request): bool - { - if (!$this->config->get('recaptcha.verify_domain')) { - return false; - } - - $url = parse_url($request->url()); - - return $result->hostname === array_get($url, 'host'); - } } diff --git a/app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php b/app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php index 17608d9f2..0b0baa7a1 100644 --- a/app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php +++ b/app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php @@ -12,9 +12,11 @@ class AdvancedSettingsFormRequest extends AdminFormRequest public function rules(): array { return [ - 'recaptcha:enabled' => 'required|in:true,false', - 'recaptcha:secret_key' => 'required|string|max:191', - 'recaptcha:website_key' => 'required|string|max:191', + 'captcha:provider' => 'required|in:recaptcha,turnstile,none', + 'captcha:recaptcha:secret_key' => 'required_if:captcha:provider,recaptcha|nullable|string|max:191', + 'captcha:recaptcha:website_key' => 'required_if:captcha:provider,recaptcha|nullable|string|max:191', + 'captcha:turnstile:secret_key' => 'required_if:captcha:provider,turnstile|nullable|string|max:191', + 'captcha:turnstile:website_key' => 'required_if:captcha:provider,turnstile|nullable|string|max:191', 'pterodactyl:guzzle:timeout' => 'required|integer|between:1,60', 'pterodactyl:guzzle:connect_timeout' => 'required|integer|between:1,60', 'pterodactyl:client_features:allocations:enabled' => 'required|in:true,false', @@ -37,9 +39,11 @@ public function rules(): array public function attributes(): array { return [ - 'recaptcha:enabled' => 'reCAPTCHA Enabled', - 'recaptcha:secret_key' => 'reCAPTCHA Secret Key', - 'recaptcha:website_key' => 'reCAPTCHA Website Key', + 'captcha:provider' => 'CAPTCHA Provider', + 'captcha:recaptcha:secret_key' => 'reCAPTCHA Secret Key', + 'captcha:recaptcha:website_key' => 'reCAPTCHA Website Key', + 'captcha:turnstile:secret_key' => 'Turnstile Secret Key', + 'captcha:turnstile:website_key' => 'Turnstile Website Key', 'pterodactyl:guzzle:timeout' => 'HTTP Request Timeout', 'pterodactyl:guzzle:connect_timeout' => 'HTTP Connection Timeout', 'pterodactyl:client_features:allocations:enabled' => 'Auto Create Allocations Enabled', diff --git a/app/Http/ViewComposers/AssetComposer.php b/app/Http/ViewComposers/AssetComposer.php index d42f8a80a..e6b609669 100644 --- a/app/Http/ViewComposers/AssetComposer.php +++ b/app/Http/ViewComposers/AssetComposer.php @@ -19,13 +19,23 @@ public function __construct(private AssetHashService $assetHashService) */ public function compose(View $view): void { + $provider = config('captcha.provider', 'none'); + $view->with('asset', $this->assetHashService); $view->with('siteConfiguration', [ 'name' => config('app.name') ?? 'Pterodactyl', 'locale' => config('app.locale') ?? 'en', 'recaptcha' => [ - 'enabled' => config('recaptcha.enabled', false), - 'siteKey' => config('recaptcha.website_key') ?? '', + 'enabled' => $provider === 'recaptcha', + 'siteKey' => config('captcha.recaptcha.website_key') ?? '', + ], + 'captcha' => [ + 'provider' => $provider, + 'siteKey' => match ($provider) { + 'recaptcha' => config('captcha.recaptcha.website_key') ?? '', + 'turnstile' => config('captcha.turnstile.website_key') ?? '', + default => '', + }, ], ]); } diff --git a/app/Services/Captcha/CaptchaVerificationService.php b/app/Services/Captcha/CaptchaVerificationService.php new file mode 100644 index 000000000..e53640815 --- /dev/null +++ b/app/Services/Captcha/CaptchaVerificationService.php @@ -0,0 +1,119 @@ +getProvider() !== 'none'; + } + + /** + * Get the configured CAPTCHA provider name. + */ + public function getProvider(): string + { + return $this->config->get('captcha.provider', 'none'); + } + + /** + * Verify the CAPTCHA response token for the configured provider. + */ + public function verify(Request $request): bool + { + $provider = $this->getProvider(); + + return match ($provider) { + 'recaptcha' => $this->verifyRecaptcha($request), + 'turnstile' => $this->verifyTurnstile($request), + default => true, + }; + } + + /** + * Get the request field name that contains the CAPTCHA response token. + */ + public function getResponseField(): string + { + return match ($this->getProvider()) { + 'turnstile' => 'cf-turnstile-response', + default => 'g-recaptcha-response', + }; + } + + /** + * Verify a Google reCAPTCHA response. + */ + private function verifyRecaptcha(Request $request): bool + { + $token = $request->input('g-recaptcha-response'); + if (empty($token)) { + return false; + } + + $res = $this->client->post($this->config->get('captcha.recaptcha.domain'), [ + 'form_params' => [ + 'secret' => $this->config->get('captcha.recaptcha.secret_key'), + 'response' => $token, + ], + ]); + + if ($res->getStatusCode() !== 200) { + return false; + } + + $result = json_decode($res->getBody()); + + if (!$result->success) { + return false; + } + + // Optionally verify the domain matches. + if ($this->config->get('captcha.recaptcha.verify_domain')) { + $url = parse_url($request->url()); + + return ($result->hostname ?? null) === ($url['host'] ?? null); + } + + return true; + } + + /** + * Verify a Cloudflare Turnstile response. + */ + private function verifyTurnstile(Request $request): bool + { + $token = $request->input('cf-turnstile-response'); + if (empty($token)) { + return false; + } + + $res = $this->client->post($this->config->get('captcha.turnstile.domain'), [ + 'form_params' => [ + 'secret' => $this->config->get('captcha.turnstile.secret_key'), + 'response' => $token, + 'remoteip' => $request->ip(), + ], + ]); + + if ($res->getStatusCode() !== 200) { + return false; + } + + $result = json_decode($res->getBody()); + + return $result->success ?? false; + } +} diff --git a/config/captcha.php b/config/captcha.php new file mode 100644 index 000000000..e27fbbad7 --- /dev/null +++ b/config/captcha.php @@ -0,0 +1,43 @@ + env('CAPTCHA_PROVIDER', env('RECAPTCHA_ENABLED', true) ? 'recaptcha' : 'none'), + + /* + |-------------------------------------------------------------------------- + | Google reCAPTCHA + |-------------------------------------------------------------------------- + */ + 'recaptcha' => [ + 'domain' => env('RECAPTCHA_DOMAIN', 'https://www.google.com/recaptcha/api/siteverify'), + 'secret_key' => env('RECAPTCHA_SECRET_KEY', '6LcJcjwUAAAAALOcDJqAEYKTDhwELCkzUkNDQ0J5'), + '_shipped_secret_key' => '6LcJcjwUAAAAALOcDJqAEYKTDhwELCkzUkNDQ0J5', + 'website_key' => env('RECAPTCHA_WEBSITE_KEY', '6LcJcjwUAAAAAO_Xqjrtj9wWufUpYRnK6BW8lnfn'), + '_shipped_website_key' => '6LcJcjwUAAAAAO_Xqjrtj9wWufUpYRnK6BW8lnfn', + 'verify_domain' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Cloudflare Turnstile + |-------------------------------------------------------------------------- + */ + 'turnstile' => [ + 'domain' => env('TURNSTILE_DOMAIN', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), + 'secret_key' => env('TURNSTILE_SECRET_KEY', ''), + 'website_key' => env('TURNSTILE_WEBSITE_KEY', ''), + ], +]; diff --git a/config/recaptcha.php b/config/recaptcha.php index 757e184a5..398bcda83 100644 --- a/config/recaptcha.php +++ b/config/recaptcha.php @@ -1,31 +1,24 @@ env('RECAPTCHA_ENABLED', true), +/* +|-------------------------------------------------------------------------- +| reCAPTCHA Configuration (Deprecated) +|-------------------------------------------------------------------------- +| +| This file is kept for backward compatibility. The panel now uses +| config/captcha.php with multi-provider support. +| +| To migrate: set CAPTCHA_PROVIDER=recaptcha in your .env and configure +| keys via RECAPTCHA_SECRET_KEY / RECAPTCHA_WEBSITE_KEY. +| +*/ - /* - * API endpoint for recaptcha checks. You should not edit this. - */ +return [ + 'enabled' => env('CAPTCHA_PROVIDER', env('RECAPTCHA_ENABLED', true) ? 'recaptcha' : 'none') === 'recaptcha', 'domain' => env('RECAPTCHA_DOMAIN', 'https://www.google.com/recaptcha/api/siteverify'), - - /* - * Use a custom secret key, we use our public one by default - */ 'secret_key' => env('RECAPTCHA_SECRET_KEY', '6LcJcjwUAAAAALOcDJqAEYKTDhwELCkzUkNDQ0J5'), '_shipped_secret_key' => '6LcJcjwUAAAAALOcDJqAEYKTDhwELCkzUkNDQ0J5', - - /* - * Use a custom website key, we use our public one by default - */ 'website_key' => env('RECAPTCHA_WEBSITE_KEY', '6LcJcjwUAAAAAO_Xqjrtj9wWufUpYRnK6BW8lnfn'), '_shipped_website_key' => '6LcJcjwUAAAAAO_Xqjrtj9wWufUpYRnK6BW8lnfn', - - /* - * Domain verification is enabled by default and compares the domain used when solving the captcha - * as public keys can't have domain verification on google's side enabled (obviously). - */ 'verify_domain' => true, ]; diff --git a/resources/scripts/api/auth/login.ts b/resources/scripts/api/auth/login.ts index ff0de7ac6..3dfc7bece 100644 --- a/resources/scripts/api/auth/login.ts +++ b/resources/scripts/api/auth/login.ts @@ -10,16 +10,18 @@ export interface LoginData { username: string; password: string; recaptchaData?: string | null; + turnstileData?: string | null; } -export default ({ username, password, recaptchaData }: LoginData): Promise => { +export default ({ username, password, recaptchaData, turnstileData }: LoginData): Promise => { return new Promise((resolve, reject) => { http.get('/sanctum/csrf-cookie') .then(() => http.post('/auth/login', { user: username, password, - 'g-recaptcha-response': recaptchaData, + 'g-recaptcha-response': recaptchaData || undefined, + 'cf-turnstile-response': turnstileData || undefined, }) ) .then((response) => { diff --git a/resources/scripts/api/auth/requestPasswordResetEmail.ts b/resources/scripts/api/auth/requestPasswordResetEmail.ts index d68fa4447..a58612adf 100644 --- a/resources/scripts/api/auth/requestPasswordResetEmail.ts +++ b/resources/scripts/api/auth/requestPasswordResetEmail.ts @@ -1,8 +1,16 @@ import http from '@/api/http'; -export default (email: string, recaptchaData?: string): Promise => { +export default (email: string, captchaToken?: string, provider?: string): Promise => { return new Promise((resolve, reject) => { - http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData }) + const data: Record = { email }; + + if (provider === 'turnstile') { + data['cf-turnstile-response'] = captchaToken; + } else { + data['g-recaptcha-response'] = captchaToken; + } + + http.post('/auth/password', data) .then((response) => resolve(response.data.status || '')) .catch(reject); }); diff --git a/resources/scripts/components/auth/ForgotPasswordContainer.tsx b/resources/scripts/components/auth/ForgotPasswordContainer.tsx index 76ddf1992..eb18edbca 100644 --- a/resources/scripts/components/auth/ForgotPasswordContainer.tsx +++ b/resources/scripts/components/auth/ForgotPasswordContainer.tsx @@ -22,29 +22,94 @@ export default () => { const [token, setToken] = useState(''); const { clearFlashes, addFlash } = useFlash(); - const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha); + const { enabled: recaptchaEnabled, siteKey: recaptchaSiteKey } = useStoreState( + (state) => state.settings.data!.recaptcha + ); + const { provider: captchaProvider, siteKey: captchaSiteKey } = useStoreState( + (state) => state.settings.data!.captcha + ); + + const turnstileRef = useRef(null); + const turnstileWidgetId = useRef(null); useEffect(() => { clearFlashes(); }, []); + // Load Turnstile script and render widget when provider is turnstile. + useEffect(() => { + if (captchaProvider !== 'turnstile' || !captchaSiteKey) return; + + const scriptId = 'cf-turnstile-script'; + if (!document.getElementById(scriptId)) { + const script = document.createElement('script'); + script.id = scriptId; + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + script.async = true; + script.defer = true; + document.head.appendChild(script); + } + + const renderWidget = () => { + if (turnstileRef.current && window.turnstile && !turnstileWidgetId.current) { + turnstileWidgetId.current = window.turnstile.render(turnstileRef.current, { + sitekey: captchaSiteKey, + callback: (response: string) => { + setToken(response); + }, + 'expired-callback': () => { + setToken(''); + }, + }); + } + }; + + const interval = setInterval(() => { + if (window.turnstile) { + renderWidget(); + clearInterval(interval); + } + }, 100); + + return () => { + clearInterval(interval); + if (turnstileWidgetId.current && window.turnstile) { + window.turnstile.remove(turnstileWidgetId.current); + turnstileWidgetId.current = null; + } + }; + }, [captchaProvider, captchaSiteKey]); + + const resetCaptcha = () => { + setToken(''); + if (captchaProvider === 'recaptcha' && ref.current) { + ref.current.reset(); + } else if (captchaProvider === 'turnstile' && turnstileWidgetId.current && window.turnstile) { + window.turnstile.reset(turnstileWidgetId.current); + } + }; + const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers) => { clearFlashes(); - // If there is no token in the state yet, request the token and then abort this submit request - // since it will be re-submitted when the recaptcha data is returned by the component. - if (recaptchaEnabled && !token) { + // For reCAPTCHA: if there is no token yet, execute the invisible challenge. + if (captchaProvider === 'recaptcha' && !token) { ref.current!.execute().catch((error) => { console.error(error); - setSubmitting(false); addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) }); }); + return; + } + // For Turnstile: the widget is visible and the token should already be set. + if (captchaProvider === 'turnstile' && !token) { + setSubmitting(false); + addFlash({ type: 'error', title: 'Error', message: 'Please complete the CAPTCHA challenge.' }); return; } - requestPasswordResetEmail(email, token) + requestPasswordResetEmail(email, token, captchaProvider) .then((response) => { resetForm(); addFlash({ type: 'success', title: 'Success', message: response }); @@ -54,9 +119,7 @@ export default () => { addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) }); }) .then(() => { - setToken(''); - if (ref.current) ref.current.reset(); - + resetCaptcha(); setSubmitting(false); }); }; @@ -87,11 +150,11 @@ export default () => { Send Email - {recaptchaEnabled && ( + {captchaProvider === 'recaptcha' && ( { setToken(response); submitForm(); @@ -102,6 +165,11 @@ export default () => { }} /> )} + {captchaProvider === 'turnstile' && ( +
+
+
+ )}
{ const [token, setToken] = useState(''); const { clearFlashes, clearAndAddHttpError } = useFlash(); - const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha); + const { enabled: recaptchaEnabled, siteKey: recaptchaSiteKey } = useStoreState( + (state) => state.settings.data!.recaptcha + ); + const { provider: captchaProvider, siteKey: captchaSiteKey } = useStoreState( + (state) => state.settings.data!.captcha + ); + + const turnstileRef = useRef(null); + const turnstileWidgetId = useRef(null); useEffect(() => { clearFlashes(); }, []); + // Load Turnstile script and render widget when provider is turnstile. + useEffect(() => { + if (captchaProvider !== 'turnstile' || !captchaSiteKey) return; + + const scriptId = 'cf-turnstile-script'; + if (!document.getElementById(scriptId)) { + const script = document.createElement('script'); + script.id = scriptId; + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + script.async = true; + script.defer = true; + document.head.appendChild(script); + } + + const renderWidget = () => { + if (turnstileRef.current && window.turnstile && !turnstileWidgetId.current) { + turnstileWidgetId.current = window.turnstile.render(turnstileRef.current, { + sitekey: captchaSiteKey, + callback: (response: string) => { + setToken(response); + }, + 'expired-callback': () => { + setToken(''); + }, + }); + } + }; + + // Wait for the script to load. + const interval = setInterval(() => { + if (window.turnstile) { + renderWidget(); + clearInterval(interval); + } + }, 100); + + return () => { + clearInterval(interval); + if (turnstileWidgetId.current && window.turnstile) { + window.turnstile.remove(turnstileWidgetId.current); + turnstileWidgetId.current = null; + } + }; + }, [captchaProvider, captchaSiteKey]); + + const resetCaptcha = () => { + setToken(''); + if (captchaProvider === 'recaptcha' && ref.current) { + ref.current.reset(); + } else if (captchaProvider === 'turnstile' && turnstileWidgetId.current && window.turnstile) { + window.turnstile.reset(turnstileWidgetId.current); + } + }; + const onSubmit = (values: Values, { setSubmitting }: FormikHelpers) => { clearFlashes(); - // If there is no token in the state yet, request the token and then abort this submit request - // since it will be re-submitted when the recaptcha data is returned by the component. - if (recaptchaEnabled && !token) { + // For reCAPTCHA: if there is no token yet, execute the invisible challenge. + if (captchaProvider === 'recaptcha' && !token) { ref.current!.execute().catch((error) => { console.error(error); - setSubmitting(false); clearAndAddHttpError({ error }); }); + return; + } + // For Turnstile: the widget is visible and the token should already be set. + // If not, show an error. + if (captchaProvider === 'turnstile' && !token) { + setSubmitting(false); + clearAndAddHttpError({ error: new Error('Please complete the CAPTCHA challenge.') }); return; } - login({ ...values, recaptchaData: token }) + login({ + ...values, + recaptchaData: captchaProvider === 'recaptcha' ? token : null, + turnstileData: captchaProvider === 'turnstile' ? token : null, + }) .then((response) => { if (response.complete) { // @ts-expect-error this is valid @@ -55,10 +126,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => { }) .catch((error) => { console.error(error); - - setToken(''); - if (ref.current) ref.current.reset(); - + resetCaptcha(); setSubmitting(false); clearAndAddHttpError({ error }); }); @@ -84,11 +152,11 @@ const LoginContainer = ({ history }: RouteComponentProps) => { Login
- {recaptchaEnabled && ( + {captchaProvider === 'recaptcha' && ( { setToken(response); submitForm(); @@ -99,6 +167,11 @@ const LoginContainer = ({ history }: RouteComponentProps) => { }} /> )} + {captchaProvider === 'turnstile' && ( +
+
+
+ )}
void; + 'expired-callback'?: () => void; + 'error-callback'?: () => void; + theme?: 'light' | 'dark' | 'auto'; + size?: 'normal' | 'compact'; + }): string; + reset(widgetId: string): void; + remove(widgetId: string): void; + getResponse(widgetId: string): string | undefined; +} + +interface Window { + turnstile?: TurnstileInstance; +} diff --git a/resources/scripts/state/settings.ts b/resources/scripts/state/settings.ts index 20dbbdc6e..8295edbe3 100644 --- a/resources/scripts/state/settings.ts +++ b/resources/scripts/state/settings.ts @@ -1,5 +1,10 @@ import { action, Action } from 'easy-peasy'; +export interface CaptchaSettings { + provider: 'recaptcha' | 'turnstile' | 'none'; + siteKey: string; +} + export interface SiteSettings { name: string; locale: string; @@ -7,6 +12,7 @@ export interface SiteSettings { enabled: boolean; siteKey: string; }; + captcha: CaptchaSettings; } export interface SettingsStore { diff --git a/tests/Unit/Http/Middleware/VerifyCaptchaTest.php b/tests/Unit/Http/Middleware/VerifyCaptchaTest.php new file mode 100644 index 000000000..03c4fb76f --- /dev/null +++ b/tests/Unit/Http/Middleware/VerifyCaptchaTest.php @@ -0,0 +1,232 @@ +config = m::mock(Repository::class); + $this->dispatcher = m::mock(Dispatcher::class); + $this->client = m::mock(Client::class); + } + + /** + * Test that requests pass through when CAPTCHA is disabled (provider = none). + */ + public function test_disabled_captcha_passes_through(): void + { + $this->config->shouldReceive('get')->with('captcha.provider', 'none')->andReturn('none'); + + $service = new CaptchaVerificationService($this->config, $this->client); + $middleware = new VerifyCaptcha($this->dispatcher, $service); + + $request = Request::create('/auth/login', 'POST'); + $next = function ($req) { + return new \Illuminate\Http\Response('OK'); + }; + + $response = $middleware->handle($request, $next); + $this->assertEquals('OK', $response->getContent()); + } + + /** + * Test that a valid reCAPTCHA response passes verification. + */ + public function test_valid_recaptcha_passes(): void + { + $this->config->shouldReceive('get')->with('captcha.provider', 'none')->andReturn('recaptcha'); + $this->config->shouldReceive('get')->with('captcha.recaptcha.domain')->andReturn('https://www.google.com/recaptcha/api/siteverify'); + $this->config->shouldReceive('get')->with('captcha.recaptcha.secret_key')->andReturn('test-secret'); + $this->config->shouldReceive('get')->with('captcha.recaptcha.verify_domain')->andReturn(false); + + $this->client->shouldReceive('post') + ->once() + ->with('https://www.google.com/recaptcha/api/siteverify', m::on(function ($params) { + return $params['form_params']['secret'] === 'test-secret' + && $params['form_params']['response'] === 'valid-token'; + })) + ->andReturn(new Response(200, [], json_encode(['success' => true]))); + + $service = new CaptchaVerificationService($this->config, $this->client); + $middleware = new VerifyCaptcha($this->dispatcher, $service); + + $request = Request::create('/auth/login', 'POST', ['g-recaptcha-response' => 'valid-token']); + $next = function ($req) { + return new \Illuminate\Http\Response('OK'); + }; + + $response = $middleware->handle($request, $next); + $this->assertEquals('OK', $response->getContent()); + } + + /** + * Test that a valid Turnstile response passes verification. + */ + public function test_valid_turnstile_passes(): void + { + $this->config->shouldReceive('get')->with('captcha.provider', 'none')->andReturn('turnstile'); + $this->config->shouldReceive('get')->with('captcha.turnstile.domain')->andReturn('https://challenges.cloudflare.com/turnstile/v0/siteverify'); + $this->config->shouldReceive('get')->with('captcha.turnstile.secret_key')->andReturn('turnstile-secret'); + + $this->client->shouldReceive('post') + ->once() + ->with('https://challenges.cloudflare.com/turnstile/v0/siteverify', m::on(function ($params) { + return $params['form_params']['secret'] === 'turnstile-secret' + && $params['form_params']['response'] === 'valid-turnstile-token'; + })) + ->andReturn(new Response(200, [], json_encode(['success' => true]))); + + $service = new CaptchaVerificationService($this->config, $this->client); + $middleware = new VerifyCaptcha($this->dispatcher, $service); + + $request = Request::create('/auth/login', 'POST', ['cf-turnstile-response' => 'valid-turnstile-token']); + $next = function ($req) { + return new \Illuminate\Http\Response('OK'); + }; + + $response = $middleware->handle($request, $next); + $this->assertEquals('OK', $response->getContent()); + } + + /** + * Test that a failed reCAPTCHA response throws an exception. + */ + public function test_failed_recaptcha_throws_exception(): void + { + $this->config->shouldReceive('get')->with('captcha.provider', 'none')->andReturn('recaptcha'); + $this->config->shouldReceive('get')->with('captcha.recaptcha.domain')->andReturn('https://www.google.com/recaptcha/api/siteverify'); + $this->config->shouldReceive('get')->with('captcha.recaptcha.secret_key')->andReturn('test-secret'); + + $this->client->shouldReceive('post') + ->once() + ->andReturn(new Response(200, [], json_encode(['success' => false]))); + + $this->dispatcher->shouldReceive('dispatch')->once()->with(m::type(FailedCaptcha::class)); + + $service = new CaptchaVerificationService($this->config, $this->client); + $middleware = new VerifyCaptcha($this->dispatcher, $service); + + $request = Request::create('/auth/login', 'POST', ['g-recaptcha-response' => 'invalid-token']); + $next = function ($req) { + return new \Illuminate\Http\Response('OK'); + }; + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Failed to validate CAPTCHA data.'); + + $middleware->handle($request, $next); + } + + /** + * Test that a failed Turnstile response throws an exception. + */ + public function test_failed_turnstile_throws_exception(): void + { + $this->config->shouldReceive('get')->with('captcha.provider', 'none')->andReturn('turnstile'); + $this->config->shouldReceive('get')->with('captcha.turnstile.domain')->andReturn('https://challenges.cloudflare.com/turnstile/v0/siteverify'); + $this->config->shouldReceive('get')->with('captcha.turnstile.secret_key')->andReturn('turnstile-secret'); + + $this->client->shouldReceive('post') + ->once() + ->andReturn(new Response(200, [], json_encode(['success' => false]))); + + $this->dispatcher->shouldReceive('dispatch')->once()->with(m::type(FailedCaptcha::class)); + + $service = new CaptchaVerificationService($this->config, $this->client); + $middleware = new VerifyCaptcha($this->dispatcher, $service); + + $request = Request::create('/auth/login', 'POST', ['cf-turnstile-response' => 'invalid-token']); + $next = function ($req) { + return new \Illuminate\Http\Response('OK'); + }; + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Failed to validate CAPTCHA data.'); + + $middleware->handle($request, $next); + } + + /** + * Test that a missing CAPTCHA token when provider is enabled throws an exception. + */ + public function test_missing_token_with_recaptcha_enabled_throws_exception(): void + { + $this->config->shouldReceive('get')->with('captcha.provider', 'none')->andReturn('recaptcha'); + + $this->dispatcher->shouldReceive('dispatch')->once()->with(m::type(FailedCaptcha::class)); + + $service = new CaptchaVerificationService($this->config, $this->client); + $middleware = new VerifyCaptcha($this->dispatcher, $service); + + $request = Request::create('/auth/login', 'POST', []); + $next = function ($req) { + return new \Illuminate\Http\Response('OK'); + }; + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Failed to validate CAPTCHA data.'); + + $middleware->handle($request, $next); + } + + /** + * Test that a missing Turnstile token when provider is turnstile throws an exception. + */ + public function test_missing_token_with_turnstile_enabled_throws_exception(): void + { + $this->config->shouldReceive('get')->with('captcha.provider', 'none')->andReturn('turnstile'); + + $this->dispatcher->shouldReceive('dispatch')->once()->with(m::type(FailedCaptcha::class)); + + $service = new CaptchaVerificationService($this->config, $this->client); + $middleware = new VerifyCaptcha($this->dispatcher, $service); + + $request = Request::create('/auth/login', 'POST', []); + $next = function ($req) { + return new \Illuminate\Http\Response('OK'); + }; + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Failed to validate CAPTCHA data.'); + + $middleware->handle($request, $next); + } + + /** + * Test that an unknown provider is treated as disabled (passes through). + */ + public function test_unknown_provider_passes_through(): void + { + $this->config->shouldReceive('get')->with('captcha.provider', 'none')->andReturn('unknown_provider'); + + $service = new CaptchaVerificationService($this->config, $this->client); + $middleware = new VerifyCaptcha($this->dispatcher, $service); + + $request = Request::create('/auth/login', 'POST'); + $next = function ($req) { + return new \Illuminate\Http\Response('OK'); + }; + + $response = $middleware->handle($request, $next); + $this->assertEquals('OK', $response->getContent()); + } +}