Google reCAPTCHA toevoegen aan een Laravel-formulier

Google reCAPTCHA toevoegen aan een Laravel-formulier
Kort antwoord

Je hebt geen package nodig: een klasse die ValidationRule implementeert en met de facade Http naar https://www.google.com/recaptcha/api/siteverify gaat, volstaat. Controleer drie dingen in het antwoord: success, de verwachte action en de score. En koppel de regel altijd aan required: Laravel voert een eigen regel niet uit op een leeg veld.

Een reCAPTCHA komt neer op twee dingen: een token dat de browser aanmaakt, en een controle van dat token bij Google vanaf je server. Voor dat tweede deel levert Laravel alles wat je nodig hebt, en daarmee wordt een externe package optioneel.

Artikel uit het leertraject Webontwikkeling. Deze gids bouwt een zelfstandige validatieregel zonder dependency en zet die onder druk in zes scenario’s. Gecontroleerd op Laravel 13.30.1 met PHP 8.4.25.

De sleutels ophalen

Maak een site aan in de reCAPTCHA-console en kies versie v3, die een score van 0 tot 1 toekent zonder de bezoeker iets te vragen. Je krijgt twee sleutels: de sitesleutel, die publiek is, en de geheime sleutel, die de server nooit mag verlaten.

.env
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secrete

Zet ze in de configuratie in plaats van env() vanuit je code aan te roepen: zodra de configuratie gecachet is, geeft env() overal buiten config/ gewoon null terug.

config/services.php
'recaptcha' => [
    'key'    => env('RECAPTCHA_KEY'),
    'secret' => env('RECAPTCHA_SECRET'),
],

De validatieregel

Eén klasse die ValidationRule implementeert volstaat. Ze bevraagt de verificatie-API en controleert het succes, de verwachte actie en de score.

bash
php artisan make:rule Recaptcha
app/Rules/Recaptcha.php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;

class Recaptcha implements ValidationRule
{
    public function __construct(
        private string $action,
        private float $minScore = 0.5,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || $value === '') {
            $fail('La vérification anti-robot est absente.');
            return;
        }

        $response = Http::asForm()
            ->timeout(5)
            ->post('https://www.google.com/recaptcha/api/siteverify', [
                'secret'   => config('services.recaptcha.secret'),
                'response' => $value,
                'remoteip' => request()->ip(),
            ]);

        if ($response->failed()) {
            $fail('La vérification anti-robot est indisponible. Réessayez.');
            return;
        }

        $data = $response->json();

        if (! ($data['success'] ?? false)) {
            $fail('La vérification anti-robot a échoué.');
            return;
        }

        if (($data['action'] ?? null) !== $this->action) {
            $fail('La vérification anti-robot ne correspond pas à ce formulaire.');
            return;
        }

        if (($data['score'] ?? 0) < $this->minScore) {
            $fail('Votre requête a été considérée comme automatisée.');
        }
    }
}

De controle op de actie verdient een woordje uitleg: zonder die controle zou een token dat op een willekeurige publieke pagina van je site is opgehaald, de validatie van het contactformulier passeren. De score alleen is niet genoeg.

De regel op het formulier aansluiten

Voeg required toe, anders dient de bescherming nergens toe

Laravel voert een eigen validatieregel niet uit op een lege of ontbrekende waarde. Geverifieerd: met alleen de regel Recaptcha passeert een leeg veld de validatie zonder dat er ook maar één request naar Google vertrekt. Het veld gewoon niet meesturen volstaat dus om de captcha te omzeilen. De regel required is hier niet optioneel.

app/Http/Controllers/ContactController.php
use App\Rules\Recaptcha;

public function store(Request $request)
{
    $request->validate([
        'email'                => ['required', 'email'],
        'message'              => ['required', 'string', 'max:2000'],
        'g-recaptcha-response' => ['required', new Recaptcha('contact', 0.5)],
    ]);

    // …
}

Het formulier in de browser

Het formulier hieronder steunt op de escaperegels van Blade. Bij v3 verschijnt er geen enkel vinkje: het script genereert een token dat je vlak voor het versturen in een verborgen veld zet.

resources/views/contact.blade.php
<form method="POST" action="/contact" id="contact">
    @csrf
    <input type="email" name="email" required>
    <textarea name="message" required></textarea>
    <input type="hidden" name="g-recaptcha-response" id="recaptcha-token">
    <button type="submit">Envoyer</button>
</form>

@error('g-recaptcha-response')
    <p class="erreur">{{ $message }}</p>
@enderror

<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.key') }}"></script>
<script>
document.getElementById('contact').addEventListener('submit', function (e) {
    e.preventDefault();
    grecaptcha.ready(() => {
        grecaptcha.execute(@json(config('services.recaptcha.key')), { action: 'contact' })
            .then(token => {
                document.getElementById('recaptcha-token').value = token;
                e.target.submit();
            });
    });
});
</script>

De actie die je hier opgeeft, contact, moet exact overeenkomen met wat de regel op de server verwacht.

Testen zonder Google aan te roepen

Een bescherming die je niet test, is een bescherming waarvan je de staat niet kent. Denk er ook aan om de debugmodus in productie uit te zetten, anders lekt je geheime sleutel via een foutpagina naar buiten.

Met Http::fake() simuleer je elk antwoord van de API:

tests/Feature/RecaptchaTest.php
use App\Rules\Recaptcha;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\{Http, Validator};

$cas = [
    'succès, score 0.9'       => [200, ['success' => true,  'action' => 'contact', 'score' => 0.9]],
    'succès, score 0.1'       => [200, ['success' => true,  'action' => 'contact', 'score' => 0.1]],
    'action différente'       => [200, ['success' => true,  'action' => 'login',   'score' => 0.9]],
    'jeton refusé par Google' => [200, ['success' => false, 'error-codes' => ['invalid-input-response']]],
    'service indisponible'    => [500, ''],
];

foreach ($cas as $label => [$code, $corps]) {
    Http::swap(new Factory());   // begint bij elk geval met een nieuwe client
    Http::fake(['*' => Http::response($corps, $code)]);

    $v = Validator::make(
        ['g-recaptcha-response' => 'un-jeton'],
        ['g-recaptcha-response' => ['required', new Recaptcha('contact', 0.5)]],
    );

    printf("%-26s %s\n", $label, $v->passes() ? 'ACCEPTÉ' : 'refusé : '.$v->errors()->first());
}

De resultaten:

code
succès, score 0.9          ACCEPTÉ
succès, score 0.1          refusé : Votre requête a été considérée comme automatisée.
action différente          refusé : La vérification anti-robot ne correspond pas à ce formulaire.
jeton refusé par Google    refusé : La vérification anti-robot a échoué.
service indisponible       refusé : La vérification anti-robot est indisponible. Réessayez.
Http::fake() stapelt de simulaties op

Http::fake() meerdere keren aanroepen in dezelfde test wist de vorige aanroepen niet: de eerste match antwoordt, en alle volgende gevallen krijgen datzelfde antwoord. Vandaar de Http::swap(new Factory()) vóór elk geval. Zonder die regel kleurt de hele reeks groen zonder iets te controleren.

Heb je een package nodig?

anhskohbo/no-captcha, genoemd in de vorige versie van dit artikel, bestaat nog steeds in versie 3.8. Het scheelt je een paar regels, in hoofdzaak het tonen van de v2-widget.

De code hierboven past in één klasse en één HTTP-aanroep, zonder dependency om op te volgen, en laat jou de minimale score, de actiecontrole en de foutmeldingen bepalen. Voor één formulier is dat de beste verhouding.

Begin je van nul, let dan op het volgende: Cloudflare Turnstile vervult dezelfde rol met een verificatie-API van dezelfde vorm. Alleen de verificatie-URL en de veldnaam verschillen, de structuur van de regel blijft identiek.

Veelgemaakte fouten

required vergeten naast de regel Laravel voert een eigen validatieregel niet uit op een lege of ontbrekende waarde. Zonder required volstaat het het veld niet mee te sturen om de captcha volledig te omzeilen. Geverifieerd.
De actie niet controleren Een token dat op om het even welke pagina van de site is opgehaald, zou de validatie van het formulier passeren. De score alleen beschermt je niet.
env() aanroepen vanuit de regel Zodra de configuratie in cache staat, geeft env() null terug. Ga via config('services.recaptcha.secret').
Http::fake() stapelt de simulaties op Een tweede aanroep wist de eerste niet: de eerste match antwoordt. Zonder Http::swap(new Factory()) kleurt de hele testreeks groen zonder iets te controleren.
Geen timeout op de aanroep Zonder timeout() blokkeert een storing van de API het request van je gebruiker tot de standaardtijd van PHP verstreken is.
De geheime sleutel blootgeven Alleen de sitesleutel hoort in de HTML. De geheime sleutel blijft op de server.

FormulairesLaravelPHPSécurité

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.