From 3347febb3c49d88338217b4d6381be90c5b02905 Mon Sep 17 00:00:00 2001 From: elvira Date: Fri, 25 Sep 2026 16:19:13 +0300 Subject: [PATCH] login hcaptcha --- .env.example | 3 + app/Http/Controllers/Auth/LoginController.php | 6 ++ app/Rules/HCaptcha.php | 66 +++++++++++++++++++ composer.lock | 31 ++++----- config/services.php | 9 +++ resources/views/auth/login.blade.php | 17 +++++ resources/views/product/show.blade.php | 2 +- 7 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 app/Rules/HCaptcha.php diff --git a/.env.example b/.env.example index b25ec48..01acd1f 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,9 @@ MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" CONTACT_EMAIL= +HCAPTCHA_SITEKEY= +HCAPTCHA_SECRET= + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 049a559..6245d73 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Rules\HCaptcha; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -43,8 +44,13 @@ public function create(string $locale, Request $request): View public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse { + // Captcha only here: verify() and resend() need the email this step + // puts in the session, so they can't be reached without passing it. $validated = $request->validate([ 'email' => ['required', 'email', 'max:255'], + 'h-captcha-response' => ['bail', 'required', new HCaptcha], + ], [ + 'h-captcha-response.required' => __('storefront.auth.captcha_failed'), ]); $email = Str::lower(trim($validated['email'])); diff --git a/app/Rules/HCaptcha.php b/app/Rules/HCaptcha.php new file mode 100644 index 0000000..9061b82 --- /dev/null +++ b/app/Rules/HCaptcha.php @@ -0,0 +1,66 @@ + ['required', new HCaptcha]`. + * + * Fails closed: if hCaptcha can't be reached the submission is rejected, since + * letting it through would reopen the hole this exists to close (bots making + * us send email to arbitrary addresses). + */ +class HCaptcha implements ValidationRule +{ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || $value === '') { + $fail(__('storefront.auth.captcha_failed')); + + return; + } + + try { + $response = Http::asForm() + ->timeout(5) + ->post('https://api.hcaptcha.com/siteverify', [ + 'secret' => config('services.hcaptcha.secret'), + 'response' => $value, + // Rejects tokens solved against someone else's sitekey. + 'sitekey' => config('services.hcaptcha.sitekey'), + 'remoteip' => request()->ip(), + ]); + } catch (ConnectionException $e) { + Log::warning('hCaptcha siteverify unreachable', ['error' => $e->getMessage()]); + $fail(__('storefront.auth.captcha_failed')); + + return; + } + + if (! $response->successful() || $response->json('success') !== true) { + // A bad/missing secret or sitekey would otherwise look like every + // shopper failing the captcha. + $configErrors = array_intersect((array) $response->json('error-codes'), [ + 'missing-input-secret', + 'invalid-input-secret', + 'sitekey-secret-mismatch', + 'invalid-sitekey', + ]); + + if ($response->failed() || $configErrors) { + Log::warning('hCaptcha siteverify error', [ + 'status' => $response->status(), + 'error-codes' => $response->json('error-codes'), + ]); + } + + $fail(__('storefront.auth.captcha_failed')); + } + } +} diff --git a/composer.lock b/composer.lock index e035eaa..026a065 100644 --- a/composer.lock +++ b/composer.lock @@ -515,11 +515,11 @@ }, { "name": "boboko/core", - "version": "0.20.0", + "version": "0.22.0", "source": { "type": "git", "url": "https://code.radical-elements.com/boboko/core.git", - "reference": "c7035d678275a6c7aae6eb2a2ee1b12569da1a3d" + "reference": "985f53efa2d34849f35c8e0f4129f861be762e76" }, "require": { "laravel/framework": "^12.0", @@ -557,6 +557,7 @@ "Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\CartServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider", + "Modules\\Core\\Providers\\FileServiceProvider", "Modules\\Core\\Providers\\ShippingServiceProvider", "Modules\\Core\\Providers\\OrderServiceProvider", "Modules\\Core\\Providers\\PrivacyServiceProvider" @@ -569,7 +570,7 @@ } }, "description": "Core module — authentication and shared panel behaviour", - "time": "2026-09-23T06:47:28+00:00" + "time": "2026-09-25T12:58:41+00:00" }, { "name": "brick/math", @@ -2937,16 +2938,16 @@ }, { "name": "kirschbaum-development/eloquent-power-joins", - "version": "4.3.3", + "version": "4.3.4", "source": { "type": "git", "url": "https://github.com/kirschbaum-development/eloquent-power-joins.git", - "reference": "c609dbbe4ad2051b667e937f1ab554067519d64b" + "reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/c609dbbe4ad2051b667e937f1ab554067519d64b", - "reference": "c609dbbe4ad2051b667e937f1ab554067519d64b", + "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/a0e6c0420c381861e1830693c4b74bdac7f7fb5f", + "reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f", "shasum": "" }, "require": { @@ -2994,9 +2995,9 @@ ], "support": { "issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues", - "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.3" + "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.4" }, - "time": "2026-07-23T11:41:37+00:00" + "time": "2026-09-25T11:10:42+00:00" }, { "name": "laravel/framework", @@ -12600,16 +12601,16 @@ }, { "name": "technikermathe/blade-lucide-icons", - "version": "v3.181.0", + "version": "v3.182.0", "source": { "type": "git", "url": "https://github.com/PascaleBeier/blade-lucide-icons.git", - "reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98" + "reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98", - "reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98", + "url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/11253aa7d9aa7430da61d3534c62dc69a95b703b", + "reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b", "shasum": "" }, "require": { @@ -12659,9 +12660,9 @@ ], "support": { "issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues", - "source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.181.0" + "source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.182.0" }, - "time": "2026-09-18T02:09:04+00:00" + "time": "2026-09-25T02:29:00+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", diff --git a/config/services.php b/config/services.php index d7dbed7..dc5f182 100644 --- a/config/services.php +++ b/config/services.php @@ -40,6 +40,15 @@ 'email' => env('CONTACT_EMAIL'), ], + // Bot check on guest forms (login), verified by App\Rules\HCaptcha. For + // local dev use hCaptcha's test keys, the real ones reject localhost: + // sitekey 10000000-ffff-ffff-ffff-000000000001, + // secret 0x0000000000000000000000000000000000000000. + 'hcaptcha' => [ + 'sitekey' => env('HCAPTCHA_SITEKEY'), + 'secret' => env('HCAPTCHA_SECRET'), + ], + 'stoic' => [ 'sso_secret' => env('STOIC_SSO_SECRET'), 'host' => env('STOIC_HOST'), diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 36ef0a7..da4a8f8 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -6,6 +6,11 @@ @endpush +{{-- Only on pages with a captcha, not in the global layout. --}} +@push('scripts') + +@endpush + @section('content')
@@ -52,6 +57,18 @@ ]) !!}

+ {{-- hCaptcha checkbox, verified by App\Rules\HCaptcha. The widget adds + the `h-captcha-response` field itself. Fixed height reserves the + iframe's space so the button doesn't jump when it loads. --}} + +
has('h-captcha-response')) aria-describedby="login-captcha-error" @endif + >
+
+
{{ __('storefront.auth.send_code') }}
diff --git a/resources/views/product/show.blade.php b/resources/views/product/show.blade.php index 90fd37e..92a57ca 100644 --- a/resources/views/product/show.blade.php +++ b/resources/views/product/show.blade.php @@ -270,7 +270,7 @@ class="flex flex-col gap-6" @if(!empty($product['recommendations']))