Skip to content

Commit b3cf802

Browse files
committed
add v2 support
1 parent b86c2b1 commit b3cf802

11 files changed

+173
-69
lines changed

README.md

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,59 @@
1-
# Livewire ReCAPTCHA v3
1+
# Livewire ReCAPTCHA v3/v2/v2-invisible
2+
23
[![Latest Version on Packagist](https://img.shields.io/packagist/v/dutchcodingcompany/livewire-recaptcha.svg?style=flat-square)](https://packagist.org/packages/dutchcodingcompany/livewire-recaptcha)
34
[![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/dutchcodingcompany/livewire-recaptcha/run-tests?label=tests)](https://github.com/dutchcodingcompany/livewire-recaptcha/actions?query=workflow%3Arun-tests+branch%3Amain)
45
[![GitHub Code Style Action Status](https://img.shields.io/github/workflow/status/dutchcodingcompany/livewire-recaptcha/Check%20&%20fix%20styling?label=code%20style)](https://github.com/dutchcodingcompany/livewire-recaptcha/actions?query=workflow%3A"Check+%26+fix+styling"+branch%3Amain)
56
[![Total Downloads](https://img.shields.io/packagist/dt/dutchcodingcompany/livewire-recaptcha.svg?style=flat-square)](https://packagist.org/packages/dutchcodingcompany/livewire-recaptcha)
67

7-
This package provides a custom Livewire directive to protect your Livewire functions with a _Google reCAPTCHA v3_ check.
8+
This package provides a custom Livewire directive to protect your Livewire functions with a _Google reCAPTCHA (v2 + v2
9+
invisible + v3)_ check.
810

911
## Installation
12+
1013
```shell
1114
composer require dutchcodingcompany/livewire-recaptcha
1215
```
1316

1417
## Configuration
15-
Next, read https://developers.google.com/recaptcha/docs/v3 on how to create your own key pair.
1618

17-
The site key and secret key should be defined in your `config/services.php` file:
19+
Read https://developers.google.com/recaptcha/intro on how to create your own key pair for the specific ReCaptcha
20+
version you are going to implement.
21+
22+
This package supports the following versions. Note that each version requires a different sitekey/secretkey pair:
23+
24+
| **Version** | **Docs** | **Notes** |
25+
|----------------------|-------------------------------------------------------------------|-----------------------------|
26+
| **v3** (recommended) | [V3 Docs](https://developers.google.com/recaptcha/docs/v3) | |
27+
| **v2** | [V2 Docs](https://developers.google.com/recaptcha/docs/display) | |
28+
| **v2 invisible** | [V2 Docs](https://developers.google.com/recaptcha/docs/invisible) | Use `'size' => 'invisible'` |
29+
30+
Your options should reside in the `config/services.php` file:
1831

1932
```php
20-
// ...
21-
'google' => [
22-
'recaptcha' => [
23-
'site_key' => env('GOOGLE_RECAPTCHA_SITE_KEY'),
24-
'secret_key' => env('GOOGLE_RECAPTCHA_SECRET_KEY'),
25-
]
26-
],
33+
// V3 config:
34+
'google' => [
35+
'recaptcha' => [
36+
'site_key' => env('GOOGLE_RECAPTCHA_SITE_KEY'),
37+
'secret_key' => env('GOOGLE_RECAPTCHA_SECRET_KEY'),
38+
'version' => 'v3',
39+
'score' => 0.5, // An integer between 0 and 1, that indicates the minimum score to pass the Captcha challenge.
40+
],
41+
],
42+
43+
// V2 config:
44+
'google' => [
45+
'recaptcha' => [
46+
'site_key' => env('GOOGLE_RECAPTCHA_SITE_KEY'),
47+
'secret_key' => env('GOOGLE_RECAPTCHA_SECRET_KEY'),
48+
'version' => 'v2',
49+
'size' => 'normal', // 'normal', 'compact' or 'invisible'.
50+
'theme' => 'light', // 'light' or 'dark'.
51+
],
52+
],
2753
```
54+
2855
#### Component
56+
2957
In your Livewire component, at your form submission method, add the `#[ValidatesRecaptcha]` attribute:
3058

3159
```php
@@ -46,26 +74,29 @@ class SomeComponent extends Component
4674
}
4775
}
4876
```
49-
For fine-grained control, you can pass manual site and secret keys using:
77+
78+
For fine-grained control, you can pass a custom secret key and minimum score (applies only to V3) using:
79+
5080
```php
51-
#[ValidatesRecaptcha(siteKey: 'mysitekey', secretKey: 'mysecretkey')]
81+
#[ValidatesRecaptcha(secretKey: 'mysecretkey', score: 0.9)]
5282
```
53-
If you do not pass these, by default, the values are read from the `config/services.php` file.
5483

5584
#### View
85+
5686
On the view side, you have to include the Blade directive `@livewireRecaptcha`. This adds two scripts to the page,
5787
one for the reCAPTCHA script and one for the custom Livewire directive to hook into the form submission.
5888

5989
Preferrably these scripts are only added to the page that has the Captcha-protected form (alternatively, you can add
6090
the `@livewireRecaptcha` directive on a higher level, lets say your layout).
6191

6292
Secondly, add the new directive `wire:recaptcha` to the form element that you want to protect.
93+
6394
```html
6495
<!-- some-livewire-component.blade.php -->
6596

6697
<!-- (optional) Add error handling -->
6798
@if($errors->has('gRecaptchaResponse'))
68-
<div class="alert alert-danger">{{ $errors->first('gRecaptchaResponse') }}</div>
99+
<div class="alert alert-danger">{{ $errors->first('gRecaptchaResponse') }}</div>
69100
@endif
70101

71102
<!-- Add the `wire:recaptcha` Livewire directive -->
@@ -78,16 +109,24 @@ Secondly, add the new directive `wire:recaptcha` to the form element that you wa
78109
@livewireRecaptcha
79110
```
80111

81-
Also here, you are able to set the site-key manually for the directive using:
112+
You can override any of the configuration values using:
113+
82114
```html
83-
@livewireRecaptcha('mysitekey')
115+
@livewireRecaptcha(
116+
version: 'v2',
117+
siteKey: 'abcd_efgh-hijk_LMNOP',
118+
theme: 'dark',
119+
size: 'compact',
120+
)
84121
```
85122

86123
#### Finishing up
87-
The Google reCAPTCHA protection will automatically occur before the actual form is submitted. Before the `save()` method
88-
is executed, a serverside request will be sent to Google to verify the invisible Captcha challenge. Once the reCAPTCHA response
89-
has been successful, the actual `save()` method will be executed.
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.
90128

91129
#### Error handling
130+
92131
When an error occurs with the Captcha validation, a ValidationException is thrown for the key `gRecaptchaResponse`.
93-
There is a translatable error message for `'livewire-recaptcha::recaptcha.invalid_response'`.
132+
There is a translatable error message available under `'livewire-recaptcha::recaptcha.invalid_response'`.

phpstan-baseline.neon

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +0,0 @@
1-
parameters:
2-
ignoreErrors:
3-
-
4-
message: "#^Access to an undefined property Livewire\\\\Component\\:\\:\\$gRecaptchaResponse\\.$#"
5-
count: 1
6-
path: src/ValidatesRecaptcha.php

src/LivewireRecaptcha.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace DutchCodingCompany\LivewireRecaptcha;
4+
5+
use Illuminate\Support\Facades\Blade;
6+
use RuntimeException;
7+
8+
class LivewireRecaptcha
9+
{
10+
/**
11+
* @param string|null $siteKey
12+
* @param 'v2'|'v3'|null $version
13+
* @param 'normal'|'compact'|'invisible'|null $size
14+
* @param 'light'|'dark'|null $theme
15+
* @return string
16+
*/
17+
public static function directive(
18+
string $version = null,
19+
string $siteKey = null,
20+
string $theme = null,
21+
string $size = null,
22+
): string {
23+
$version ??= config('services.google.recaptcha.version') ?? 'v3';
24+
25+
return Blade::render(file_get_contents($path = __DIR__."/directive.recaptcha.$version.blade.php") ?: throw new RuntimeException("Failed to load '$path'."),
26+
[
27+
'siteKey' => $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+
}

src/LivewireRecaptchaServiceProvider.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace DutchCodingCompany\LivewireRecaptcha;
44

5+
use Illuminate\Support\Facades\Blade;
56
use Illuminate\Support\ServiceProvider;
67

78
class LivewireRecaptchaServiceProvider extends ServiceProvider
@@ -15,6 +16,11 @@ public function boot(): void
1516
}
1617

1718
$this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'livewire-recaptcha');
19+
20+
Blade::directive(
21+
'livewireRecaptcha',
22+
static fn (string $expression): string => "<?php echo \DutchCodingCompany\LivewireRecaptcha\LivewireRecaptcha::directive($expression) ?>",
23+
);
1824
}
1925

2026
public function register(): void

src/ValidatesRecaptcha.php

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44

55
use Attribute;
66
use Closure;
7-
use DutchCodingCompany\LivewireRecaptcha\Exceptions\LivewireRecaptchaException;
8-
use Illuminate\Support\Facades\Blade;
97
use Illuminate\Support\Facades\Http;
108
use Illuminate\Validation\ValidationException;
119
use Livewire\Features\SupportAttributes\Attribute as LivewireAttribute;
@@ -16,27 +14,11 @@
1614
class ValidatesRecaptcha extends LivewireAttribute
1715
{
1816
public function __construct(
19-
public ?string $siteKey = null,
2017
public ?string $secretKey = null,
18+
public ?float $score = null,
2119
) {
22-
$this->siteKey ??= config('services.google.recaptcha.site_key');
2320
$this->secretKey ??= config('services.google.recaptcha.secret_key');
24-
}
25-
26-
public function boot(): void
27-
{
28-
Blade::directive(
29-
'livewireRecaptcha',
30-
function (string $expression) {
31-
$siteKey = ! empty($expression) ? str_replace(['"', '\''], '', $expression) : $this->siteKey;
32-
33-
return str_replace(
34-
'__SITEKEY__',
35-
e($siteKey),
36-
file_get_contents($path = __DIR__.'/directive.recaptcha.php') ?: throw new LivewireRecaptchaException("Failed to load '$path'."),
37-
);
38-
},
39-
);
21+
$this->score ??= config('services.google.recaptcha.score') ?? 0.5;
4022
}
4123

4224
/**
@@ -46,13 +28,16 @@ function (string $expression) {
4628
*/
4729
public function call(array $params, Closure $returnEarly): void
4830
{
49-
$response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
50-
'secret' => $this->secretKey,
51-
'response' => $this->component->gRecaptchaResponse,
52-
'remoteip' => request()->ip(),
53-
])->json();
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+
}
5438

55-
if ($response['success'] ?? false) {
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) {
5641
$returnEarly(
5742
wrap($this->component)->{$this->subName}(...$params)
5843
);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script>
2+
let resolver = null;
3+
window.googleRecaptchaResponse = new Promise((resolve) => resolver = resolve);
4+
5+
window.googleRecaptchaOnloadCallback = function () {
6+
grecaptcha.render('g-recaptcha-element', {
7+
sitekey: @json($siteKey),
8+
theme: @json($theme),
9+
size: @json($size),
10+
callback: resolver,
11+
});
12+
};
13+
14+
15+
document.addEventListener('livewire:init', () => {
16+
Livewire.hook('morph.updated', ({ el, component }) => {
17+
grecaptcha.reset();
18+
});
19+
20+
Livewire.directive('recaptcha', ({ el, directive, component, cleanup }) => {
21+
const submitExpression = (() => {
22+
for (const attr of el.attributes) {
23+
if (attr.name.startsWith('wire:submit')) {
24+
return attr.value;
25+
}
26+
}
27+
})();
28+
29+
const onSubmit = async (e) => {
30+
e.preventDefault();
31+
e.stopImmediatePropagation();
32+
33+
@if($size === 'invisible')
34+
await grecaptcha.execute();
35+
@endif
36+
37+
const token = await window.googleRecaptchaResponse;
38+
39+
await component.$wire.$set('gRecaptchaResponse', token);
40+
41+
Alpine.evaluate(el, "$wire." + submitExpression, { scope: { $event: e } });
42+
}
43+
44+
el.addEventListener('submit', onSubmit, { capture: true });
45+
});
46+
});
47+
</script>
48+
<script src="https://www.google.com/recaptcha/api.js?onload=googleRecaptchaOnloadCallback&render=explicit" async defer></script>
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
<script src="https://www.google.com/recaptcha/api.js?render=__SITEKEY__"></script>
21
<script>
32
document.addEventListener('livewire:init', () => {
43
Livewire.directive('recaptcha', ({ el, directive, component, cleanup }) => {
54
const submitExpression = (() => {
6-
for(const attr of el.attributes) {
7-
if(attr.name.startsWith('wire:submit')) {
5+
for (const attr of el.attributes) {
6+
if (attr.name.startsWith('wire:submit')) {
87
return attr.value;
98
}
109
}
@@ -14,11 +13,11 @@
1413
e.preventDefault();
1514
e.stopImmediatePropagation();
1615
17-
grecaptcha.ready(() => {
18-
grecaptcha.execute('__SITEKEY__', {action: 'submit'}).then((token) => {
19-
component.$wire.$set('gRecaptchaResponse', token).then(() => {
20-
Alpine.evaluate(el, "$wire." + submitExpression, { scope: { $event: e } });
21-
});
16+
grecaptcha.ready(async () => {
17+
const token = await grecaptcha.execute(@json($siteKey), { action: 'submit' });
18+
19+
component.$wire.$set('gRecaptchaResponse', token).then(() => {
20+
Alpine.evaluate(el, "$wire." + submitExpression, { scope: { $event: e } });
2221
});
2322
});
2423
}
@@ -27,3 +26,4 @@
2726
});
2827
});
2928
</script>
29+
<script src="https://www.google.com/recaptcha/api.js?render={{ $siteKey }}"></script>

tests/CaptchaTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ public function testInvalidCaptchaResponse(bool $isValid, array $captchaResponse
5151
public static function provideCaptchaData(): array
5252
{
5353
return [
54-
'valid response' => [true, ['success' => true]],
54+
'valid response' => [true, ['success' => true, 'score' => 0.9]],
55+
'valid response, low score' => [false, ['success' => true, 'score' => 0.1]],
5556
'invalid response' => [false, ['success' => false]],
5657
];
5758
}

tests/Fixtures/MyTestComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public function mount(): void
1515
//
1616
}
1717

18-
#[ValidatesRecaptcha(siteKey: 'mysitekey', secretKey: 'mysecretkey')]
18+
#[ValidatesRecaptcha]
1919
public function save(): void
2020
{
2121
//
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
<div>
22
<form wire:submit="save" wire:recaptcha>
3-
<!-- The rest of your form -->
43
<input type="text" />
54
<button type="submit">Submit</button>
65
</form>
76

87
@livewireRecaptcha
9-
</div>
8+
</div>

0 commit comments

Comments
 (0)