Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"php": "^8.2",
"barryvdh/laravel-form-bridge": "^0.7.4"
},
"suggest": {
"josiasmontag/laravel-recaptchav3": "Required to use the RecaptchaV3 field type (Laravel only)"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": ">10",
Expand Down
3 changes: 1 addition & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,16 @@ To notify a user of an error with their form submission:
Symfony Form will automatically display an error message next to the input field. Error messages should have a unique ID and be associated with the form input via the `aria-describedby` attribute.
This is provided in the accessible form theme.


## Docs

- [Using accessible forms with Laravel](laravel.md)
- [Using accessible forms with Symfony](symfony.md)
- [Form options](form-options.md)
- Form helpers to create a form
- Accessible form theme
- GOV.UK Design System form theme
- [Indicate form errors in the page title](page-title.md)
- [Error summary](error-summary.md)
- [reCAPTCHA v3 field](recaptcha.md)
- Every input field needs a label
- Indicate required fields
- Use field hints instead of placeholders
Expand Down
79 changes: 79 additions & 0 deletions docs/recaptcha.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# reCAPTCHA v3 field

The `RecaptchaV3` field type adds invisible [Google reCAPTCHA v3](https://developers.google.com/recaptcha/docs/v3) spam protection to a form.
It is currently Laravel only.

reCAPTCHA v3 does not ask the user to solve a challenge, so it adds no extra steps for keyboard, screen reader or other assistive technology users.

The token is requested when the form is submitted rather than when the page loads. reCAPTCHA tokens expire after 2 minutes, so fetching
one on page load would cause the form to fail for anyone who takes longer than that to complete it.

## Installation

Install [laravel-recaptchav3](https://github.com/josiasmontag/laravel-recaptchav3) via [Composer](https://getcomposer.org/):

```shell
composer require josiasmontag/laravel-recaptchav3
```

Add your keys to `.env`:

```
RECAPTCHAV3_SITEKEY=sitekey
RECAPTCHAV3_SECRET=secret
```

Load the reCAPTCHA script on any page that uses the field, for example in your layout template:

```twig
{{ recaptchaJs()|raw }}
```

Where `recaptchaJs()` is a Twig function that returns `RecaptchaV3::initJs()`.

## Usage

Add the field to your form class, usually just before the submit button:

```php
use Studio24\AccessibleForms\FieldTypes\RecaptchaV3;

$builder
->add('recaptcha', RecaptchaV3::class, [
'action' => 'contact',
])
->add('save', SubmitType::class, ['label' => 'Send']);
```

The token is validated when the form is submitted via `$form->isValid()`. No extra controller code is needed.

## Options

| Option | Default | Description |
|-----------|------------|-------------------------------------------------------------------------------------------------------|
| `action` | `submit` | reCAPTCHA action name, shown in the reCAPTCHA admin console. Letters, numbers, `/` and `_` only. |
| `score` | `0.5` | Minimum score (0.0 to 1.0) needed to pass validation. |
| `message` | See below | Error message shown in the error summary and above the field if verification fails. |
| `nonce` | `null` | Content Security Policy nonce added to the inline `<script>`. Only added when set. |

The default error message is "We could not confirm this form was sent by a person. Please try submitting the form again."

### Content Security Policy

The field renders an inline `<script>`. If your Content Security Policy blocks inline scripts, pass a nonce, for example with [spatie/laravel-csp](https://github.com/spatie/laravel-csp):

```php
->add('recaptcha', RecaptchaV3::class, [
'action' => 'contact',
'nonce' => app('csp-nonce'),
])
```

## Accessibility notes

- If verification fails, the error is shown in the [error summary](error-summary.md). The summary link moves focus to the error message on the page.
- If the reCAPTCHA script cannot load (for example, it is blocked), the form still submits and the user sees the error message rather than a form that does nothing.
- Google requires you to tell users that the site is protected by reCAPTCHA if you hide the badge. See [hiding the badge](https://developers.google.com/recaptcha/docs/faq#id-like-to-hide-the-recaptcha-badge.-what-is-allowed).

---
Back to [Docs index](README.md)
61 changes: 61 additions & 0 deletions src/FieldTypes/RecaptchaV3.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace Studio24\AccessibleForms\FieldTypes;

use Studio24\AccessibleForms\Validation\RecaptchaV3Rule;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* Invisible reCAPTCHA v3 field
*
* The token is requested when the form is submitted (not on page load), so it cannot
* expire while someone is still filling in the form. Requires josiasmontag/laravel-recaptchav3
*/
class RecaptchaV3 extends AbstractType
{
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'mapped' => false,
'required' => false,
'label' => false,
'error_bubbling' => false,
'action' => 'submit',
'score' => 0.5,
'message' => 'We could not confirm this form was sent by a person. Please try submitting the form again.',
'nonce' => null,
'rules' => function (Options $options) {
return [new RecaptchaV3Rule($options['action'], (float) $options['score'], $options['message'])];
},
]);

// Google only allows alphanumerics, slashes and underscores in action names
$resolver->setAllowedTypes('action', 'string');
$resolver->setAllowedValues('action', fn (string $action) => preg_match('/^[A-Za-z0-9_\/]+$/', $action) === 1);
$resolver->setAllowedTypes('score', ['int', 'float']);
$resolver->setAllowedTypes('message', 'string');
$resolver->setAllowedTypes('nonce', ['null', 'string']);
}

public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['action'] = $options['action'];
$view->vars['sitekey'] = config('recaptchav3.sitekey');
$view->vars['nonce'] = $options['nonce'];
}

public function getParent(): string
{
return HiddenType::class;
}

public function getBlockPrefix(): string
{
return 'recaptcha_v3';
}
}
52 changes: 52 additions & 0 deletions src/Resources/views/Form/accessible-forms.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,56 @@
{{ html }}
{%- endblock -%}

{# reCAPTCHA v3: requires the reCAPTCHA api.js script on the page.
The wrapper takes the field ID so error summary links move focus to the visible error message.
The token is fetched on submit so it cannot expire (after 2 minutes) while the form is being completed. #}
{%- block recaptcha_v3_row -%}
<div id="{{ id }}" tabindex="-1"{% if errors|length > 0 %} class="tbxforms-form-group tbxforms-form-group--error"{% endif %}>
{{- form_errors(form) -}}
<input type="hidden" id="{{ id }}_token" name="{{ full_name }}" value="">
</div>
<script{% if nonce %} nonce="{{ nonce }}"{% endif %}>
(function () {
var input = document.getElementById('{{ id|e('js') }}_token');
var form = input && input.form;
if (!form) {
return;
}
var verified = false;
var pending = false;

form.addEventListener('submit', function (event) {
// If reCAPTCHA failed to load, submit anyway so the server can show a validation error
if (verified || typeof grecaptcha === 'undefined') {
return;
}
event.preventDefault();
if (pending) {
return;
}
pending = true;
var submitter = event.submitter || null;

var resubmit = function () {
pending = false;
verified = true;
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(submitter);
} else {
form.submit();
}
verified = false;
};

grecaptcha.ready(function () {
grecaptcha.execute('{{ sitekey|e('js') }}', {action: '{{ action|e('js') }}'}).then(function (token) {
input.value = token;
resubmit();
}, resubmit);
});
});
})();
</script>
{%- endblock recaptcha_v3_row -%}


57 changes: 57 additions & 0 deletions src/Validation/RecaptchaV3Rule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);

namespace Studio24\AccessibleForms\Validation;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Lunaweb\RecaptchaV3\RecaptchaV3;
use Throwable;

/**
* Laravel validation rule to verify a reCAPTCHA v3 token
*
* Requires josiasmontag/laravel-recaptchav3
*/
class RecaptchaV3Rule implements ValidationRule
{
/**
* Run even when the token is empty, so a missing token fails validation
* (laravel-form-bridge adds "nullable" to non-required fields)
*/
public bool $implicit = true;

public function __construct(
protected string $action,
protected float $score,
protected string $message,
) {
}

public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) || $value === '') {
$fail($this->message);
return;
}

try {
$score = app(RecaptchaV3::class)->verify($value, $this->action);
} catch (Throwable $e) {
report($e);
$score = false;
}

if ($score === false || $score < $this->score) {
$fail($this->message);
}
}

/**
* laravel-form-bridge's RulesParser treats every rule as a string when building the view
*/
public function __toString(): string
{
return 'recaptcha_v3';
}
}
Loading