reCAPTCHA v3 w Laravelu: własna reguła walidacji formularza

reCAPTCHA v3 w Laravelu: własna reguła walidacji formularza
Szybka odpowiedź

Pakiet nie jest potrzebny: wystarczy klasa implementująca ValidationRule, która odpytuje https://www.google.com/recaptcha/api/siteverify przez fasadę Http. Sprawdź w odpowiedzi trzy rzeczy: success, oczekiwaną action i score. I zawsze łącz regułę z required: Laravel nie uruchamia własnej reguły na pustym polu.

reCAPTCHA sprowadza się do dwóch rzeczy: tokenu wygenerowanego przez przeglądarkę i weryfikacji tego tokenu u Google z twojego serwera. Laravel daje wszystko, czego potrzeba do drugiej części, więc pakiet zewnętrzny staje się opcjonalny.

Artykuł ze ścieżki Programowanie webowe. Ten przewodnik buduje samodzielną regułę walidacji, bez żadnej zależności, i sprawdza ją na sześciu scenariuszach. Zweryfikowane na Laravelu 13.30.1 z PHP 8.4.25.

Zdobądź klucze

Utwórz witrynę w konsoli reCAPTCHA i wybierz wersję v3, która przyznaje wynik od 0 do 1, o nic nie pytając odwiedzającego. Dostajesz dwa klucze: klucz witryny, publiczny, oraz klucz tajny, który nigdy nie może opuścić serwera.

.env
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secrete

Zadeklaruj je w konfiguracji, zamiast wywoływać env() z poziomu kodu: gdy konfiguracja trafi do cache’u, env() zwraca null wszędzie poza config/.

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

Reguła walidacji

Wystarczy klasa implementująca ValidationRule. Odpytuje API weryfikacji i sprawdza sukces, oczekiwaną akcję oraz wynik.

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

Kontrola akcji zasługuje na wyjaśnienie: bez niej token zdobyty na dowolnej publicznej podstronie twojej witryny mógłby przejść walidację formularza kontaktowego. Sam wynik nie wystarczy.

Podepnij regułę pod formularz

Dodaj required, inaczej ochrona jest pozorna

Laravel nie uruchamia własnej reguły na wartości pustej albo nieobecnej. Sprawdzone: z samą regułą Recaptcha puste pole przechodzi walidację i do Google nie leci żadne żądanie. Wystarczyłoby więc nie wysłać pola, żeby obejść captchę. Reguła required nie jest tu opcjonalna.

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

    // …
}

Formularz po stronie przeglądarki

Poniższy formularz opiera się na zasadach escapowania w Blade. Wersja v3 nie wyświetla żadnego pola wyboru: skrypt generuje token, który tuż przed wysyłką trafia do ukrytego pola.

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>

Zadeklarowana tutaj akcja, contact, musi być dokładnie tą, której oczekuje reguła po stronie serwera.

Testowanie bez odpytywania Google

Ochrona, której się nie testuje, to ochrona o nieznanym stanie. Pamiętaj też, żeby wyłączyć tryb debugowania na produkcji, inaczej wystawisz klucz tajny na stronie błędu.

Http::fake() pozwala zasymulować każdą odpowiedź 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());   // przy każdym przypadku startuje z nowym klientem
    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());
}

Otrzymane wyniki:

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() kumuluje symulacje

Wywołanie Http::fake() kilka razy w tym samym teście nie kasuje poprzednich: odpowiada pierwsze dopasowanie, a wszystkie kolejne przypadki dostają odpowiedź z pierwszego. Stąd Http::swap(new Factory()) przed każdym przypadkiem. Bez tego cała seria świeci na zielono, nie sprawdzając niczego.

Czy potrzebny jest pakiet?

anhskohbo/no-captcha, przywoływany w poprzedniej wersji tego artykułu, wciąż istnieje w wersji 3.8. Oszczędza kilka linii, głównie na wyświetleniu widgetu v2.

Powyższy kod mieści się w jednej klasie i jednym wywołaniu HTTP, nie dokłada zależności do pilnowania i zostawia ci kontrolę nad minimalnym wynikiem, kontrolą akcji i komunikatami błędów. Przy formularzu to najlepszy układ.

Jeśli zaczynasz od zera, warto wiedzieć: Cloudflare Turnstile pełni tę samą rolę i ma API weryfikacji o tym samym kształcie. Zmienia się tylko adres weryfikacji i nazwa pola, struktura reguły zostaje ta sama.

Częste błędy

Brak required obok reguły Laravel nie uruchamia własnej reguły na wartości pustej albo nieobecnej. Bez required wystarczy nie wysłać pola, żeby całkowicie obejść captchę. Sprawdzone.
Brak kontroli akcji Token zdobyty na dowolnej podstronie witryny przeszedłby walidację formularza. Sam wynik nie chroni.
Wywołanie env() z poziomu reguły Gdy tylko konfiguracja jest w cache'u, env() zwraca null. Korzystaj z config('services.recaptcha.secret').
Http::fake() kumuluje symulacje Drugie wywołanie nie kasuje pierwszego: odpowiada pierwsze dopasowanie. Bez Http::swap(new Factory()) cała seria testów świeci na zielono, nie sprawdzając niczego.
Brak limitu czasu na wywołanie Bez timeout() awaria API blokuje żądanie użytkownika aż do domyślnego limitu PHP.
Wystawienie klucza tajnego Do HTML-a trafia wyłącznie klucz witryny. Klucz tajny zostaje po stronie serwera.

FormulairesLaravelPHPSécurité

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.