From 9494a6cbbe73fc82c5c311c3a51d1f060178d1d4 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 22 Aug 2026 00:16:29 +0100 Subject: [PATCH] Add Cloudflare Turnstile to the registration flow Registration was open to bot signups. This gates it behind the existing Turnstile widget already used by the lead submission form. Extends App\Rules\Turnstile with optional action pinning, a request timeout, a token length bound, opt-in hostname pinning, and fail-closed handling of connection errors and non-2xx responses. Default constructor args leave the lead form's usage unchanged. Register validation moves into a RegisterRequest, mirroring the sibling LoginRequest, so the controller body stays as it was. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 6 + .../Auth/CustomerAuthController.php | 9 +- app/Http/Requests/Auth/RegisterRequest.php | 42 ++++ app/Rules/Turnstile.php | 76 +++++- config/services.php | 1 + resources/views/auth/register.blade.php | 2 + .../views/components/layouts/auth.blade.php | 2 + .../views/components/turnstile.blade.php | 21 ++ .../Auth/RegistrationTurnstileTest.php | 222 ++++++++++++++++++ 9 files changed, 368 insertions(+), 13 deletions(-) create mode 100644 app/Http/Requests/Auth/RegisterRequest.php create mode 100644 resources/views/components/turnstile.blade.php create mode 100644 tests/Feature/Auth/RegistrationTurnstileTest.php diff --git a/.env.example b/.env.example index addba1e4..21be3d0b 100644 --- a/.env.example +++ b/.env.example @@ -95,3 +95,9 @@ ANYSTACK_TRIAL_POLICY_ID= FILAMENT_USERS= BIFROST_API_KEY=your-secure-api-key-here + +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= +# Optional comma-separated allow list of hostnames the challenge may be solved on. +# Leave empty to rely on the widget's domain list in the Cloudflare dashboard. +TURNSTILE_HOSTNAMES= diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php index 8b77bfe9..880ca4c6 100644 --- a/app/Http/Controllers/Auth/CustomerAuthController.php +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -5,6 +5,7 @@ use App\Enums\TeamUserStatus; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; +use App\Http\Requests\Auth\RegisterRequest; use App\Models\Plugin; use App\Models\TeamUser; use App\Models\User; @@ -32,14 +33,8 @@ public function showRegister(): View return view('auth.register'); } - public function register(Request $request): RedirectResponse + public function register(RegisterRequest $request): RedirectResponse { - $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email:rfc,dns', 'max:255', 'unique:users'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], - ]); - $user = User::create([ 'name' => $request->name, 'email' => $request->email, diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php new file mode 100644 index 00000000..2d0e1bdb --- /dev/null +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -0,0 +1,42 @@ +> + */ + public function rules(): array + { + $rules = [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email:rfc,dns', 'max:255', 'unique:users'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]; + + if (config('services.turnstile.secret_key')) { + $rules['cf-turnstile-response'] = ['required', new Turnstile('register')]; + } + + return $rules; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'cf-turnstile-response.required' => 'Please complete the security check.', + ]; + } +} diff --git a/app/Rules/Turnstile.php b/app/Rules/Turnstile.php index 9a5198ba..6c0df0d3 100644 --- a/app/Rules/Turnstile.php +++ b/app/Rules/Turnstile.php @@ -4,11 +4,29 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Translation\PotentiallyTranslatedString; class Turnstile implements ValidationRule { + private const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + + private const TIMEOUT_SECONDS = 10; + + /** + * Cloudflare documents tokens as being up to 2048 characters, so anything + * longer is junk that isn't worth a siteverify round trip. + */ + private const MAX_TOKEN_LENGTH = 2048; + + /** + * @param string|null $expectedAction The `data-action` the widget was rendered with. When + * given, a token minted by a different form is rejected. + */ + public function __construct(private ?string $expectedAction = null) {} + /** * Run the validation rule. * @@ -22,20 +40,66 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - if (empty($value)) { + if (! is_string($value) || $value === '') { $fail('Please complete the security check.'); return; } - $response = Http::asForm()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ - 'secret' => $secretKey, - 'response' => $value, - 'remoteip' => request()->ip(), - ]); + if (strlen($value) > self::MAX_TOKEN_LENGTH) { + $fail('Security verification failed. Please try again.'); + + return; + } + + try { + $response = Http::asForm() + ->timeout(self::TIMEOUT_SECONDS) + ->post(self::SITEVERIFY_URL, [ + 'secret' => $secretKey, + 'response' => $value, + 'remoteip' => request()->ip(), + ]); + } catch (ConnectionException $e) { + Log::warning('Turnstile siteverify request failed.', ['exception' => $e->getMessage()]); + + $fail('Security verification failed. Please try again.'); + + return; + } if (! $response->successful() || ! $response->json('success')) { $fail('Security verification failed. Please try again.'); + + return; + } + + if ($this->expectedAction !== null && $response->json('action') !== $this->expectedAction) { + $fail('Security verification failed. Please try again.'); + + return; + } + + if (! $this->hostnameIsAllowed($response->json('hostname'))) { + $fail('Security verification failed. Please try again.'); } } + + /** + * Hostname pinning is opt-in: with no allow list configured we defer to the + * domain list already enforced on the widget by Cloudflare. + */ + private function hostnameIsAllowed(mixed $hostname): bool + { + $allowed = array_filter(array_map( + trim(...), + explode(',', (string) config('services.turnstile.hostnames')) + )); + + if ($allowed === []) { + return true; + } + + return is_string($hostname) && in_array($hostname, $allowed, true); + } } diff --git a/config/services.php b/config/services.php index ddd1b38b..f2be8686 100644 --- a/config/services.php +++ b/config/services.php @@ -63,6 +63,7 @@ 'turnstile' => [ 'site_key' => env('TURNSTILE_SITE_KEY'), 'secret_key' => env('TURNSTILE_SECRET_KEY'), + 'hostnames' => env('TURNSTILE_HOSTNAMES'), ], 'satis' => [ diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 6f77e866..2cd92172 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -30,6 +30,8 @@ Confirm Password + + Create account diff --git a/resources/views/components/layouts/auth.blade.php b/resources/views/components/layouts/auth.blade.php index eb8c5f21..a70f67be 100644 --- a/resources/views/components/layouts/auth.blade.php +++ b/resources/views/components/layouts/auth.blade.php @@ -38,6 +38,8 @@ @livewireStyles @vite('resources/css/app.css') + + @stack('head') null, 'size' => 'flexible']) + +@if (config('services.turnstile.site_key')) + @once + @push('head') + + @endpush + @endonce + +
+
class('cf-turnstile') }} + data-sitekey="{{ config('services.turnstile.site_key') }}" + @if ($action) data-action="{{ $action }}" @endif + data-theme="auto" + data-size="{{ $size }}" + >
+ + +
+@endif diff --git a/tests/Feature/Auth/RegistrationTurnstileTest.php b/tests/Feature/Auth/RegistrationTurnstileTest.php new file mode 100644 index 00000000..33e7f914 --- /dev/null +++ b/tests/Feature/Auth/RegistrationTurnstileTest.php @@ -0,0 +1,222 @@ + $overrides + * @return array + */ + private function registrationPayload(array $overrides = []): array + { + return array_merge([ + 'name' => 'Test User', + 'email' => 'turnstile-user@gmail.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'cf-turnstile-response' => 'a-token', + ], $overrides); + } + + /** + * @param array $overrides + */ + private function fakeSiteverify(array $overrides = []): void + { + Http::fake([ + 'challenges.cloudflare.com/*' => Http::response(array_merge([ + 'success' => true, + 'action' => 'register', + 'hostname' => 'nativephp.com', + ], $overrides)), + ]); + } + + // --- Widget rendering --- + + public function test_register_page_renders_the_widget_when_a_site_key_is_configured(): void + { + config(['services.turnstile.site_key' => self::SITE_KEY]); + + $response = $this->withoutVite()->get('/register'); + + $response->assertStatus(200); + $response->assertSee('challenges.cloudflare.com/turnstile/v0/api.js', false); + $response->assertSee('cf-turnstile', false); + $response->assertSee('data-sitekey="'.self::SITE_KEY.'"', false); + $response->assertSee('data-action="register"', false); + } + + public function test_register_page_omits_the_widget_when_no_site_key_is_configured(): void + { + config(['services.turnstile.site_key' => null]); + + $response = $this->withoutVite()->get('/register'); + + $response->assertStatus(200); + $response->assertDontSee('challenges.cloudflare.com/turnstile/v0/api.js', false); + $response->assertDontSee('cf-turnstile', false); + } + + // --- Server-side verification --- + + public function test_registration_is_unguarded_when_no_secret_key_is_configured(): void + { + config(['services.turnstile.secret_key' => null]); + + Http::fake(); + + $response = $this->post('/register', $this->registrationPayload(['cf-turnstile-response' => null])); + + $response->assertRedirect(route('dashboard')); + $this->assertAuthenticated(); + Http::assertNothingSent(); + } + + public function test_registration_succeeds_with_a_verified_token(): void + { + config(['services.turnstile.secret_key' => 'test-secret']); + $this->fakeSiteverify(); + + $response = $this->post('/register', $this->registrationPayload()); + + $response->assertRedirect(route('dashboard')); + $this->assertAuthenticated(); + $this->assertDatabaseHas('users', ['email' => 'turnstile-user@gmail.com']); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + && $request['secret'] === 'test-secret' + && $request['response'] === 'a-token'); + } + + public function test_registration_is_rejected_when_the_token_is_missing(): void + { + config(['services.turnstile.secret_key' => 'test-secret']); + + Http::fake(); + + $response = $this->from('/register')->post('/register', $this->registrationPayload([ + 'cf-turnstile-response' => null, + ])); + + $response->assertRedirect('/register'); + $response->assertSessionHasErrors(['cf-turnstile-response' => 'Please complete the security check.']); + $this->assertGuest(); + $this->assertDatabaseCount('users', 0); + Http::assertNothingSent(); + } + + public function test_registration_is_rejected_when_siteverify_fails(): void + { + config(['services.turnstile.secret_key' => 'test-secret']); + $this->fakeSiteverify(['success' => false, 'error-codes' => ['invalid-input-response']]); + + $response = $this->from('/register')->post('/register', $this->registrationPayload()); + + $response->assertRedirect('/register'); + $response->assertSessionHasErrors('cf-turnstile-response'); + $this->assertGuest(); + $this->assertDatabaseCount('users', 0); + } + + public function test_registration_is_rejected_when_the_token_was_minted_for_another_form(): void + { + config(['services.turnstile.secret_key' => 'test-secret']); + $this->fakeSiteverify(['action' => 'lead-submission']); + + $response = $this->from('/register')->post('/register', $this->registrationPayload()); + + $response->assertRedirect('/register'); + $response->assertSessionHasErrors('cf-turnstile-response'); + $this->assertGuest(); + $this->assertDatabaseCount('users', 0); + } + + public function test_registration_fails_closed_when_cloudflare_is_unreachable(): void + { + config(['services.turnstile.secret_key' => 'test-secret']); + + Http::fake(function (): void { + throw new ConnectionException('Connection timed out'); + }); + + $response = $this->from('/register')->post('/register', $this->registrationPayload()); + + $response->assertRedirect('/register'); + $response->assertSessionHasErrors('cf-turnstile-response'); + $this->assertGuest(); + $this->assertDatabaseCount('users', 0); + } + + public function test_registration_fails_closed_when_siteverify_returns_a_server_error(): void + { + config(['services.turnstile.secret_key' => 'test-secret']); + + Http::fake(['challenges.cloudflare.com/*' => Http::response('', 500)]); + + $response = $this->from('/register')->post('/register', $this->registrationPayload()); + + $response->assertRedirect('/register'); + $response->assertSessionHasErrors('cf-turnstile-response'); + $this->assertGuest(); + } + + public function test_an_oversized_token_is_rejected_without_calling_cloudflare(): void + { + config(['services.turnstile.secret_key' => 'test-secret']); + + Http::fake(); + + $response = $this->from('/register')->post('/register', $this->registrationPayload([ + 'cf-turnstile-response' => str_repeat('a', 2049), + ])); + + $response->assertRedirect('/register'); + $response->assertSessionHasErrors('cf-turnstile-response'); + $this->assertGuest(); + Http::assertNothingSent(); + } + + // --- Optional hostname pinning --- + + public function test_registration_succeeds_when_the_hostname_is_on_the_allow_list(): void + { + config([ + 'services.turnstile.secret_key' => 'test-secret', + 'services.turnstile.hostnames' => 'nativephp.com, www.nativephp.com', + ]); + $this->fakeSiteverify(['hostname' => 'www.nativephp.com']); + + $response = $this->post('/register', $this->registrationPayload()); + + $response->assertRedirect(route('dashboard')); + $this->assertAuthenticated(); + } + + public function test_registration_is_rejected_when_the_hostname_is_not_on_the_allow_list(): void + { + config([ + 'services.turnstile.secret_key' => 'test-secret', + 'services.turnstile.hostnames' => 'nativephp.com', + ]); + $this->fakeSiteverify(['hostname' => 'phishing.example.com']); + + $response = $this->from('/register')->post('/register', $this->registrationPayload()); + + $response->assertRedirect('/register'); + $response->assertSessionHasErrors('cf-turnstile-response'); + $this->assertGuest(); + $this->assertDatabaseCount('users', 0); + } +}