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']))