├── .php-cs-fixer.php ├── README.md ├── SECURITY.md ├── composer.json ├── phpunit.xml ├── resources └── lang │ └── en │ └── recaptcha.php └── src ├── Exceptions └── LivewireRecaptchaException.php ├── LivewireRecaptcha.php ├── LivewireRecaptchaServiceProvider.php ├── ValidatesRecaptcha.php ├── directive.recaptcha.v2.blade.php └── directive.recaptcha.v3.blade.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | true, 8 | 'array_syntax' => ['syntax' => 'short'], 9 | 'binary_operator_spaces' => [ 10 | 'default' => 'single_space', 11 | // 'operators' => ['=>' => null], // single space makes code look more coherent in style. But sometimes it is not beter, in that case, manually override. 12 | ], 13 | 'blank_line_after_namespace' => true, 14 | 'blank_line_after_opening_tag' => true, 15 | 'blank_line_before_statement' => [ 16 | 'statements' => ['return'], 17 | ], 18 | 'braces' => true, 19 | 'cast_spaces' => true, 20 | 'class_attributes_separation' => [ 21 | 'elements' => [ 22 | 'const' => 'only_if_meta', 23 | 'method' => 'one', 24 | 'property' => 'one', 25 | 'trait_import' => 'none', 26 | ], 27 | ], 28 | 'class_definition' => [ 29 | 'multi_line_extends_each_single_line' => true, 30 | 'single_item_single_line' => true, 31 | 'single_line' => true, 32 | ], 33 | 'concat_space' => [ 34 | 'spacing' => 'none', 35 | ], 36 | 'constant_case' => ['case' => 'lower'], 37 | 'declare_equal_normalize' => true, 38 | 'elseif' => true, 39 | 'encoding' => true, 40 | 'full_opening_tag' => true, 41 | 'fully_qualified_strict_types' => false, 42 | // added by Shift 43 | 'function_declaration' => true, 44 | 'function_typehint_space' => true, 45 | 'general_phpdoc_tag_rename' => true, 46 | 'heredoc_to_nowdoc' => true, 47 | 'include' => true, 48 | 'increment_style' => ['style' => 'post'], 49 | 'indentation_type' => true, 50 | 'linebreak_after_opening_tag' => true, 51 | 'line_ending' => true, 52 | 'lowercase_cast' => true, 53 | 'lowercase_keywords' => true, 54 | 'lowercase_static_reference' => true, 55 | // added from Symfony 56 | 'magic_method_casing' => true, 57 | // added from Symfony 58 | 'magic_constant_casing' => true, 59 | 'method_argument_space' => [ 60 | 'on_multiline' => 'ignore', 61 | ], 62 | 'multiline_whitespace_before_semicolons' => [ 63 | 'strategy' => 'no_multi_line', 64 | ], 65 | 'native_function_casing' => true, 66 | 'no_alias_functions' => true, 67 | 'no_extra_blank_lines' => [ 68 | 'tokens' => [ 69 | 'extra', 70 | 'throw', 71 | 'use', 72 | 'switch', 73 | 'case', 74 | 'default', 75 | ], 76 | ], 77 | 'no_blank_lines_after_class_opening' => true, 78 | 'no_blank_lines_after_phpdoc' => true, 79 | 'no_closing_tag' => true, 80 | 'no_empty_phpdoc' => true, 81 | 'no_empty_statement' => true, 82 | 'no_leading_import_slash' => true, 83 | 'no_leading_namespace_whitespace' => true, 84 | 'no_mixed_echo_print' => [ 85 | 'use' => 'echo', 86 | ], 87 | 'no_multiline_whitespace_around_double_arrow' => true, 88 | 'no_short_bool_cast' => true, 89 | 'no_singleline_whitespace_before_semicolons' => true, 90 | 'no_spaces_after_function_name' => true, 91 | 'no_spaces_around_offset' => [ 92 | 'positions' => [ 93 | 'inside', 94 | 'outside', 95 | ], 96 | ], 97 | 'no_spaces_inside_parenthesis' => true, 98 | 'no_trailing_comma_in_list_call' => true, 99 | 'no_trailing_comma_in_singleline_array' => true, 100 | 'no_trailing_whitespace' => true, 101 | 'no_trailing_whitespace_in_comment' => true, 102 | 'no_unneeded_control_parentheses' => [ 103 | 'statements' => [ 104 | 'break', 105 | 'clone', 106 | 'continue', 107 | 'echo_print', 108 | 'return', 109 | 'switch_case', 110 | 'yield', 111 | ], 112 | ], 113 | 'no_unreachable_default_argument_value' => true, 114 | 'no_useless_return' => true, 115 | 'no_whitespace_before_comma_in_array' => true, 116 | 'no_whitespace_in_blank_line' => true, 117 | 'normalize_index_brace' => true, 118 | 'not_operator_with_successor_space' => true, 119 | 'object_operator_without_whitespace' => true, 120 | 'ordered_imports' => [ 121 | 'sort_algorithm' => 'alpha', 122 | 'imports_order' => [ 123 | 'class', 124 | 'function', 125 | 'const', 126 | ], 127 | ], 128 | 'psr_autoloading' => true, 129 | 'phpdoc_indent' => true, 130 | 'phpdoc_inline_tag_normalizer' => true, 131 | 'phpdoc_no_access' => true, 132 | 'phpdoc_no_package' => true, 133 | 'phpdoc_no_useless_inheritdoc' => true, 134 | 'phpdoc_scalar' => true, 135 | 'phpdoc_single_line_var_spacing' => true, 136 | 'phpdoc_summary' => false, 137 | 'phpdoc_to_comment' => false, 138 | // override to preserve user preference 139 | 'phpdoc_tag_type' => true, 140 | 'phpdoc_trim' => true, 141 | 'phpdoc_types' => true, 142 | 'phpdoc_var_without_name' => true, 143 | 'self_accessor' => true, 144 | 'short_scalar_cast' => true, 145 | 'simplified_null_return' => false, 146 | // disabled as "risky" 147 | 'single_blank_line_at_eof' => true, 148 | 'single_blank_line_before_namespace' => true, 149 | 'single_class_element_per_statement' => [ 150 | 'elements' => [ 151 | 'const', 152 | 'property', 153 | ], 154 | ], 155 | 'single_import_per_statement' => true, 156 | 'single_line_after_imports' => true, 157 | 'single_line_comment_style' => [ 158 | 'comment_types' => ['hash'], 159 | ], 160 | 'single_quote' => true, 161 | 'space_after_semicolon' => true, 162 | 'standardize_not_equals' => true, 163 | 'switch_case_semicolon_to_colon' => true, 164 | 'switch_case_space' => true, 165 | 'ternary_operator_spaces' => true, 166 | 'trailing_comma_in_multiline' => [ 167 | 'elements' => [ 168 | 'arrays', 169 | 'parameters', 170 | ], 171 | ], 172 | 'trim_array_spaces' => true, 173 | 'types_spaces' => [ 174 | 'space' => 'single', 175 | ], 176 | 'unary_operator_spaces' => true, 177 | 'visibility_required' => [ 178 | 'elements' => [ 179 | 'method', 180 | 'property', 181 | 'const', 182 | ], 183 | ], 184 | 'whitespace_after_comma_in_array' => true, 185 | 186 | // DCC 187 | 'align_multiline_comment' => ['comment_type' => 'phpdocs_like'], 188 | 'simplified_if_return' => true, 189 | 'method_chaining_indentation' => true, 190 | ]; 191 | 192 | $finder = Finder::create() 193 | ->in([ 194 | __DIR__ . '/src', 195 | __DIR__ . '/tests', 196 | ]) 197 | ->name('*.php') 198 | ->notName('*.blade.php') 199 | ->ignoreDotFiles(true) 200 | ->ignoreVCS(true); 201 | 202 | return (new Config) 203 | ->setFinder($finder) 204 | ->setRules($rules) 205 | ->setRiskyAllowed(true) 206 | ->setUsingCache(true); 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Livewire ReCAPTCHA v3/v2/v2-invisible 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/dutchcodingcompany/livewire-recaptcha.svg?style=flat-square)](https://packagist.org/packages/dutchcodingcompany/livewire-recaptcha) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/dutchcodingcompany/livewire-recaptcha/run-tests.yml?branch=main&label=tests)](https://github.com/dutchcodingcompany/livewire-recaptcha/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/dutchcodingcompany/livewire-recaptcha/php-cs-fixer.yml?branch=main&label=style)](https://github.com/dutchcodingcompany/livewire-recaptcha/actions?query=workflow%3A"Check+%26+fix+styling"+branch%3Amain) 6 | [![GitHub PHPStan Action Status](https://img.shields.io/github/actions/workflow/status/dutchcodingcompany/livewire-recaptcha/phpstan.yml?branch=main&label=phpstan)](https://github.com/DutchCodingCompany/livewire-recaptcha/actions?query=workflow%3APHPStan++branch%3Amain) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/dutchcodingcompany/livewire-recaptcha.svg?style=flat-square)](https://packagist.org/packages/dutchcodingcompany/livewire-recaptcha) 8 | 9 | This package provides a custom Livewire directive to protect your Livewire functions with a _Google reCAPTCHA (v2 + v2 10 | invisible + v3)_ check. 11 | 12 | ## Installation 13 | 14 | ```shell 15 | composer require dutchcodingcompany/livewire-recaptcha 16 | ``` 17 | 18 | ## Configuration 19 | 20 | Read https://developers.google.com/recaptcha/intro on how to create your own key pair for the specific ReCaptcha 21 | version you are going to implement. 22 | 23 | This package supports the following versions. Note that each version requires a different sitekey/secretkey pair: 24 | 25 | | **Version** | **Docs** | **Notes** | 26 | |----------------------|-------------------------------------------------------------------|-----------------------------| 27 | | **v3** (recommended) | [V3 Docs](https://developers.google.com/recaptcha/docs/v3) | | 28 | | **v2** | [V2 Docs](https://developers.google.com/recaptcha/docs/display) | | 29 | | **v2 invisible** | [V2 Docs](https://developers.google.com/recaptcha/docs/invisible) | Use `'size' => 'invisible'` | 30 | 31 | Your options should reside in the `config/services.php` file: 32 | 33 | ```php 34 | // V3 config: 35 | 'google' => [ 36 | 'recaptcha' => [ 37 | 'site_key' => env('GOOGLE_RECAPTCHA_SITE_KEY'), 38 | 'secret_key' => env('GOOGLE_RECAPTCHA_SECRET_KEY'), 39 | 'version' => 'v3', 40 | 'score' => 0.5, // An integer between 0 and 1, that indicates the minimum score to pass the Captcha challenge. 41 | ], 42 | ], 43 | 44 | // V2 config: 45 | 'google' => [ 46 | 'recaptcha' => [ 47 | 'site_key' => env('GOOGLE_RECAPTCHA_SITE_KEY'), 48 | 'secret_key' => env('GOOGLE_RECAPTCHA_SECRET_KEY'), 49 | 'version' => 'v2', 50 | 'size' => 'normal', // 'normal', 'compact' or 'invisible'. 51 | 'theme' => 'light', // 'light' or 'dark'. 52 | ], 53 | ], 54 | ``` 55 | 56 | #### Component 57 | 58 | In your Livewire component, at your form submission method, add the `#[ValidatesRecaptcha]` attribute: 59 | 60 | ```php 61 | use Livewire\Component; 62 | use DutchCodingCompany\LivewireRecaptcha\ValidatesRecaptcha; 63 | 64 | class SomeComponent extends Component 65 | { 66 | // (optional) Set a response property on your component. 67 | // If not given, the `gRecaptchaResponse` property is dynamically assigned. 68 | public string $gRecaptchaResponse; 69 | 70 | #[ValidatesRecaptcha] 71 | public function save(): mixed 72 | { 73 | // Your logic here will only be called if the captcha passes... 74 | } 75 | } 76 | ``` 77 | 78 | For fine-grained control, you can pass a custom secret key and minimum score (applies only to V3) using: 79 | 80 | ```php 81 | #[ValidatesRecaptcha(secretKey: 'mysecretkey', score: 0.9)] 82 | ``` 83 | 84 | #### View 85 | 86 | On the view side, you have to include the Blade directive `@livewireRecaptcha`. This adds two scripts to the page, 87 | one for the reCAPTCHA script and one for the custom Livewire directive to hook into the form submission. 88 | 89 | Preferrably these scripts are only added to the page that has the Captcha-protected form (alternatively, you can add 90 | the `@livewireRecaptcha` directive on a higher level, lets say your layout). 91 | 92 | Secondly, add the new directive `wire:recaptcha` to the form element that you want to protect. 93 | 94 | ```html 95 | 96 | 97 | 98 | @if($errors->has('gRecaptchaResponse')) 99 |
{{ $errors->first('gRecaptchaResponse') }}
100 | @endif 101 | 102 | 103 |
104 | 105 | 106 |
107 | 108 | 109 | @livewireRecaptcha 110 | ``` 111 | 112 | You can override any of the configuration values using: 113 | 114 | ```html 115 | @livewireRecaptcha( 116 | version: 'v2', 117 | siteKey: 'abcd_efgh-hijk_LMNOP', 118 | theme: 'dark', 119 | size: 'compact', 120 | ) 121 | ``` 122 | 123 | #### Finishing up 124 | 125 | The Google ReCAPTCHA validation will automatically occur before the actual form is submitted. Before the `save()` method 126 | is executed, a serverside request will be sent to Google to verify the Captcha challenge. Once the reCAPTCHA 127 | response has been successful, your actual Livewire component method will be executed. 128 | 129 | #### Error handling 130 | 131 | When an error occurs with the Captcha validation, a ValidationException is thrown for the key `gRecaptchaResponse`. 132 | There is a translatable error message available under `'livewire-recaptcha::recaptcha.invalid_response'`. 133 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 1.x | :white_check_mark: | 11 | | 0.x | :no_entry_sign: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | If you discover a security vulnerability within this plugin, please email Dutch Coding Company via [server@dutchcodingcompany.com](mailto:server@dutchcodingcompany.com). 16 | All security vulnerabilities will be promptly addressed. 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dutchcodingcompany/livewire-recaptcha", 3 | "description": "Add Google Recaptcha V3 support to your Laravel Livewire components", 4 | "keywords": [ 5 | "DutchCodingCompany", 6 | "Livewire", 7 | "Google ReCaptcha", 8 | "Laravel" 9 | ], 10 | "homepage": "https://github.com/dutchcodingcompany/livewire-recaptcha", 11 | "license": "MIT", 12 | "require": { 13 | "php": "^8.2", 14 | "livewire/livewire": "^3.0" 15 | }, 16 | "require-dev": { 17 | "friendsofphp/php-cs-fixer": "^3.8", 18 | "larastan/larastan": "^2.9", 19 | "orchestra/testbench": "^9.0", 20 | "phpunit/phpunit": "^11.1" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "DutchCodingCompany\\LivewireRecaptcha\\": "src/", 25 | "DutchCodingCompany\\LivewireRecaptcha\\Tests\\": "tests/" 26 | } 27 | }, 28 | "scripts": { 29 | "phpstan": "vendor/bin/phpstan analyse", 30 | "test": "vendor/bin/phpunit", 31 | "php-cs-fixer": "vendor/bin/php-cs-fixer fix" 32 | }, 33 | "extra": { 34 | "laravel": { 35 | "providers": [ 36 | "DutchCodingCompany\\LivewireRecaptcha\\LivewireRecaptchaServiceProvider" 37 | ] 38 | } 39 | }, 40 | "minimum-stability": "stable" 41 | } 42 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests 10 | 11 | 12 | 13 | 14 | src 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /resources/lang/en/recaptcha.php: -------------------------------------------------------------------------------- 1 | 'Invalid reCAPTCHA response.', 5 | ]; 6 | -------------------------------------------------------------------------------- /src/Exceptions/LivewireRecaptchaException.php: -------------------------------------------------------------------------------- 1 | $siteKey ?? config('services.google.recaptcha.site_key'), 28 | 'theme' => $theme ?? config('services.google.recaptcha.theme') ?? 'light', 29 | 'size' => $size ?? config('services.google.recaptcha.size') ?? 'normal', 30 | ]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/LivewireRecaptchaServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 13 | $this->publishes([ 14 | __DIR__.'/../resources/lang' => lang_path('vendor/livewire-recaptcha'), 15 | ], 'aaa'); 16 | } 17 | 18 | $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'livewire-recaptcha'); 19 | 20 | Blade::directive( 21 | 'livewireRecaptcha', 22 | static fn (string $expression): string => "", 23 | ); 24 | } 25 | 26 | public function register(): void 27 | { 28 | // 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ValidatesRecaptcha.php: -------------------------------------------------------------------------------- 1 | secretKey ??= config('services.google.recaptcha.secret_key'); 21 | $this->score ??= config('services.google.recaptcha.score') ?? 0.5; 22 | } 23 | 24 | /** 25 | * @param array $params 26 | * @param \Closure(?\Closure $closure): void $returnEarly 27 | * @throws \Illuminate\Http\Client\ConnectionException 28 | */ 29 | public function call(array $params, Closure $returnEarly): void 30 | { 31 | if (isset($this->component->gRecaptchaResponse)) { 32 | $response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [ 33 | 'secret' => $this->secretKey, 34 | 'response' => $this->component->gRecaptchaResponse, 35 | 'remoteip' => request()->ip(), 36 | ])->json(); 37 | } 38 | 39 | // Check success value and score. The score falls back to 1 since it is not present for v2. 40 | if (($response['success'] ?? false) && ($response['score'] ?? 1) >= $this->score) { 41 | $returnEarly( 42 | wrap($this->component)->{$this->subName}(...$params) 43 | ); 44 | 45 | return; 46 | } 47 | 48 | $returnEarly( 49 | trigger('exception', $this->component, ValidationException::withMessages([ 50 | 'gRecaptchaResponse' => __('livewire-recaptcha::recaptcha.invalid_response'), 51 | ]), fn () => true) 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/directive.recaptcha.v2.blade.php: -------------------------------------------------------------------------------- 1 | 48 | 49 | -------------------------------------------------------------------------------- /src/directive.recaptcha.v3.blade.php: -------------------------------------------------------------------------------- 1 | 29 | 30 | --------------------------------------------------------------------------------