Improve turnstile error handling (+ cleanup) (#1501)

This commit is contained in:
Boy132 2025-07-09 13:51:43 +02:00 committed by GitHub
parent 5e8cccef19
commit 5a7c6ac6e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 28 additions and 64 deletions

View File

@ -56,9 +56,4 @@ abstract class BaseSchema
->default(env("CAPTCHA_{$id}_SECRET_KEY")), ->default(env("CAPTCHA_{$id}_SECRET_KEY")),
]; ];
} }
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool
{
return true;
}
} }

View File

@ -26,10 +26,5 @@ interface CaptchaSchemaInterface
public function getIcon(): ?string; public function getIcon(): ?string;
/** public function validateResponse(?string $captchaResponse = null): void;
* @return array<string, string|bool>
*/
public function validateResponse(?string $captchaResponse = null): array;
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool;
} }

View File

@ -4,6 +4,7 @@ namespace App\Extensions\Captcha\Schemas\Turnstile;
use App\Extensions\Captcha\CaptchaService; use App\Extensions\Captcha\CaptchaService;
use Closure; use Closure;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
@ -11,10 +12,12 @@ class Rule implements ValidationRule
{ {
public function validate(string $attribute, mixed $value, Closure $fail): void public function validate(string $attribute, mixed $value, Closure $fail): void
{ {
$response = App::call(fn (CaptchaService $service) => $service->getActiveSchema()->validateResponse($value)); try {
App::call(fn (CaptchaService $service) => $service->get('turnstile')->validateResponse($value));
} catch (Exception $exception) {
report($exception);
if (!$response['success']) { $fail('Captcha validation failed: ' . $exception->getMessage());
$fail($response['message'] ?? 'Unknown error occurred, please try again');
} }
} }
} }

View File

@ -66,9 +66,9 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
} }
/** /**
* @return array<string, string|bool> * @throws Exception
*/ */
public function validateResponse(?string $captchaResponse = null): array public function validateResponse(?string $captchaResponse = null): void
{ {
$captchaResponse ??= request()->get('cf-turnstile-response'); $captchaResponse ??= request()->get('cf-turnstile-response');
@ -83,22 +83,33 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ ->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => $secret, 'secret' => $secret,
'response' => $captchaResponse, 'response' => $captchaResponse,
]); ])
->json();
return count($response->json()) ? $response->json() : [ if (!$response['success']) {
'success' => false, match ($response['error-codes'][0] ?? null) {
'message' => 'Unknown error occurred, please try again', 'missing-input-secret' => throw new Exception('The secret parameter was not passed.'),
]; 'invalid-input-secret' => throw new Exception('The secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.'),
'missing-input-response' => throw new Exception('The response parameter (token) was not passed.'),
'invalid-input-response' => throw new Exception('The response parameter (token) is invalid or has expired.'),
'bad-request' => throw new Exception('The request was rejected because it was malformed.'),
'timeout-or-duplicate' => throw new Exception('The response parameter (token) has already been validated before.'),
default => throw new Exception('An internal error happened while validating the response.'),
};
}
if (!$this->verifyDomain($response['hostname'] ?? '')) {
throw new Exception('Domain verification failed.');
}
} }
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool private function verifyDomain(string $hostname): bool
{ {
if (!env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN', true)) { if (!env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN', true)) {
return true; return true;
} }
$requestUrl ??= request()->url; $requestUrl = parse_url(request()->url());
$requestUrl = parse_url($requestUrl);
return $hostname === array_get($requestUrl, 'host'); return $hostname === array_get($requestUrl, 'host');
} }

View File

@ -1,39 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Extensions\Captcha\CaptchaService;
use Closure;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Events\Auth\FailedCaptcha;
use Symfony\Component\HttpKernel\Exception\HttpException;
readonly class VerifyCaptcha
{
public function __construct(private Application $app) {}
public function handle(Request $request, Closure $next, CaptchaService $captchaService): mixed
{
if ($this->app->isLocal()) {
return $next($request);
}
$schemas = $captchaService->getActiveSchemas();
foreach ($schemas as $schema) {
$response = $schema->validateResponse();
if ($response['success'] && $schema->verifyDomain($response['hostname'] ?? '', $request->url())) {
return $next($request);
}
event(new FailedCaptcha($request->ip(), $response['message'] ?? null));
throw new HttpException(Response::HTTP_BAD_REQUEST, "Failed to validate {$schema->getId()} captcha data.");
}
// No captcha enabled
return $next($request);
}
}

View File

@ -44,7 +44,6 @@ return Application::configure(basePath: dirname(__DIR__))
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'node.maintenance' => \App\Http\Middleware\MaintenanceMiddleware::class, 'node.maintenance' => \App\Http\Middleware\MaintenanceMiddleware::class,
'captcha' => \App\Http\Middleware\VerifyCaptcha::class,
]); ]);
}) })
->withSingletons([ ->withSingletons([