Google reCAPTCHA in Laravel: proteggere un form senza pacchetti

Google reCAPTCHA in Laravel: proteggere un form senza pacchetti
Risposta rapida

Nessun pacchetto necessario: basta una classe che implementa ValidationRule e interroga https://www.google.com/recaptcha/api/siteverify con la facade Http. Controlla tre cose nella risposta: success, l'action attesa e lo score. E abbina sempre la regola a required: Laravel non esegue una regola personalizzata su un campo vuoto.

Un reCAPTCHA si riduce a due cose: un token prodotto dal browser e la verifica di quel token presso Google dal tuo server. Laravel offre già tutto il necessario per la seconda parte, e questo rende il pacchetto di terze parti facoltativo.

Articolo del percorso Sviluppo web. Questa guida costruisce una regola di validazione autonoma, senza dipendenze, e la mette alla prova su sei scenari. Verificato su Laravel 13.30.1 con PHP 8.4.25.

Ottenere le chiavi

Crea un sito nella console reCAPTCHA e scegli la versione v3, che assegna un punteggio da 0 a 1 senza chiedere niente al visitatore. Ottieni due chiavi: la chiave del sito, pubblica, e la chiave segreta, che non deve mai lasciare il server.

.env
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secrete

Dichiarale nella configurazione invece di chiamare env() dal codice: una volta che la configurazione è in cache, env() restituisce null ovunque tranne che dentro config/.

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

La regola di validazione

Basta una classe che implementa ValidationRule. Interroga l’API di verifica e controlla il successo, l’azione attesa e il punteggio.

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.');
        }
    }
}

Il controllo dell’azione merita una spiegazione: senza di esso, un token ottenuto su una qualsiasi pagina pubblica del tuo sito basterebbe a superare la validazione del modulo di contatto. Il punteggio da solo non è sufficiente.

Collegare la regola al form

Aggiungi required, altrimenti la protezione non serve a niente

Laravel non esegue una regola personalizzata su un valore vuoto o assente. Verificato: con la sola regola Recaptcha, un campo vuoto supera la validazione senza che parta nessuna richiesta verso Google. Basterebbe quindi non inviare il campo per aggirare il captcha. La regola required qui non è facoltativa.

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)],
    ]);

    // …
}

Il form lato browser

Il form qui sotto si appoggia alle regole di escaping di Blade. La v3 non mostra nessuna casella da spuntare: lo script genera un token che viene messo in un campo nascosto appena prima dell’invio.

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>

L’azione dichiarata qui, contact, deve essere esattamente quella attesa dalla regola lato server.

Testare senza chiamare Google

Una protezione che non si testa è una protezione di cui si ignora lo stato. Ricorda anche di disattivare la modalità debug in produzione, altrimenti rischi di esporre la chiave segreta in una pagina di errore.

Http::fake() permette di simulare ogni risposta dell’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());   // riparte da un client nuovo a ogni caso
    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());
}

Risultati ottenuti:

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() somma le simulazioni

Chiamare Http::fake() più volte nello stesso test non cancella le precedenti: risponde la prima corrispondenza, e tutti i casi successivi ricevono la risposta del primo. Da qui l’Http::swap(new Factory()) prima di ogni caso. Senza, l’intera serie passa al verde senza verificare niente.

Serve davvero un pacchetto?

anhskohbo/no-captcha, citato nella versione precedente di questo articolo, esiste ancora nella versione 3.8. Fa risparmiare qualche riga, in sostanza la visualizzazione del widget v2.

Il codice qui sopra sta in una classe e una chiamata HTTP, senza dipendenze da seguire, e ti lascia il controllo del punteggio minimo, del controllo dell’azione e dei messaggi di errore. Su un form è il compromesso migliore.

Da tenere presente se parti da zero: Cloudflare Turnstile svolge lo stesso ruolo con un’API di verifica della stessa forma. Cambiano solo l’URL di verifica e il nome del campo, la struttura della regola resta identica.

Errori frequenti

Dimenticare required accanto alla regola Laravel non esegue una regola personalizzata su un valore vuoto o assente. Senza required, basta non inviare il campo per aggirare completamente il captcha. Verificato.
Non controllare l'azione Un token ottenuto su una qualsiasi pagina del sito supererebbe la validazione del form. Il punteggio da solo non protegge.
Chiamare env() dalla regola Appena la configurazione è in cache, env() restituisce null. Passa da config('services.recaptcha.secret').
Http::fake() somma le simulazioni Una seconda chiamata non cancella la prima: risponde la prima corrispondenza. Senza Http::swap(new Factory()), l'intera serie di test passa al verde senza verificare niente.
Nessun timeout sulla chiamata Senza timeout(), un guasto dell'API blocca la richiesta dell'utente fino al limite predefinito di PHP.
Esporre la chiave segreta Nell'HTML va solo la chiave del sito. La chiave segreta resta lato server.

FormulairesLaravelPHPSécurité

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.