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); + } +}