Google reCAPTCHA in Laravel: Formular ohne Paket schützen

Google reCAPTCHA in Laravel: Formular ohne Paket schützen
Schnelle Antwort

Du brauchst kein Paket: Eine Klasse, die ValidationRule implementiert und über die Fassade Http die Adresse https://www.google.com/recaptcha/api/siteverify abfragt, genügt. Prüfe drei Dinge in der Antwort: success, die erwartete action und den score. Und kombiniere die Regel immer mit required: Laravel führt eine eigene Regel auf einem leeren Feld nicht aus.

Ein reCAPTCHA besteht aus zwei Dingen: einem Token, das der Browser erzeugt, und der Prüfung dieses Tokens bei Google, von deinem Server aus. Für den zweiten Teil bringt Laravel alles Nötige mit, und genau deshalb ist ein Paket von außen optional.

Artikel aus dem Lernpfad Webentwicklung. Dieser Leitfaden baut eine eigenständige Validierungsregel ohne Abhängigkeit und stellt sie in sechs Szenarien auf die Probe. Geprüft mit Laravel 13.30.1 und PHP 8.4.25.

Die Schlüssel besorgen

Lege in der reCAPTCHA-Konsole eine Site an und wähle die Version v3, die einen Score von 0 bis 1 vergibt, ohne dem Besucher etwas abzuverlangen. Du bekommst zwei Schlüssel: den öffentlichen Site-Key und den geheimen Schlüssel, der den Server nie verlassen darf.

.env
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secrete

Trag sie in die Konfiguration ein, statt env() aus dem Code heraus aufzurufen: Sobald die Konfiguration im Cache liegt, gibt env() überall außerhalb von config/ nur noch null zurück.

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

Die Validierungsregel

Eine Klasse, die ValidationRule implementiert, genügt. Sie fragt die Prüf-API ab und kontrolliert den Erfolg, die erwartete Action und den 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.');
        }
    }
}

Die Prüfung der Action verdient eine Erklärung: Ohne sie könnte ein Token, das auf irgendeiner öffentlichen Seite deiner Site erzeugt wurde, die Validierung des Kontaktformulars bestehen. Der Score allein reicht nicht.

Die Regel ans Formular hängen

Ohne required ist der Schutz wertlos

Laravel führt eine eigene Regel auf einem leeren oder fehlenden Wert nicht aus. Geprüft: Mit der Regel Recaptcha allein besteht ein leeres Feld die Validierung, ohne dass eine einzige Anfrage an Google geht. Es würde also reichen, das Feld nicht mitzuschicken, um das Captcha zu umgehen. Die Regel required ist hier nicht optional.

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

    // …
}

Das Formular im Browser

Das Formular unten stützt sich auf die Escaping-Regeln von Blade. Die v3 zeigt keine Checkbox: Das Skript erzeugt ein Token, das kurz vor dem Absenden in ein verstecktes Feld geschrieben wird.

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>

Die hier angegebene Action, contact, muss exakt die sein, die die Regel auf dem Server erwartet.

Testen, ohne Google aufzurufen

Ein Schutz, den niemand testet, ist ein Schutz, dessen Zustand niemand kennt. Denk außerdem daran, den Debug-Modus in der Produktion abzuschalten, sonst landet dein geheimer Schlüssel auf einer Fehlerseite.

Mit Http::fake() simulierst du jede Antwort der 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());   // startet für jeden Fall mit einem frischen 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());
}

Die Ergebnisse:

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() sammelt die Simulationen an

Http::fake() mehrfach im selben Test aufzurufen, löscht die vorherigen Aufrufe nicht: Es antwortet die erste Übereinstimmung, und alle folgenden Fälle bekommen die Antwort des ersten. Deshalb steht vor jedem Fall ein Http::swap(new Factory()). Ohne das wird die ganze Serie grün, ohne irgendetwas zu prüfen.

Braucht es ein Paket?

anhskohbo/no-captcha, in der vorigen Fassung dieses Artikels erwähnt, gibt es weiterhin in Version 3.8. Es spart ein paar Zeilen, im Wesentlichen die Anzeige des v2-Widgets.

Der Code oben passt in eine Klasse und einen HTTP-Aufruf, ohne Abhängigkeit, die du pflegen müsstest, und lässt dir die Kontrolle über den Mindest-Score, die Prüfung der Action und die Fehlermeldungen. Für ein Formular ist das das bessere Verhältnis.

Wenn du bei null anfängst, ist das einen Blick wert: Cloudflare Turnstile erfüllt denselben Zweck mit einer Prüf-API gleicher Bauart. Nur die Prüf-URL und der Feldname ändern sich, der Aufbau der Regel bleibt identisch.

Häufige Fehler

required neben der Regel vergessen Laravel führt eine eigene Regel auf einem leeren oder fehlenden Wert nicht aus. Ohne required reicht es, das Feld nicht mitzuschicken, um das Captcha vollständig zu umgehen. Geprüft.
Die Action nicht prüfen Ein Token von irgendeiner Seite der Site würde die Validierung des Formulars bestehen. Der Score allein schützt nicht.
env() aus der Regel heraus aufrufen Sobald die Konfiguration im Cache liegt, gibt env() null zurück. Nimm config('services.recaptcha.secret').
Http::fake() sammelt die Simulationen an Ein zweiter Aufruf löscht den ersten nicht: Es antwortet die erste Übereinstimmung. Ohne Http::swap(new Factory()) wird die ganze Testreihe grün, ohne etwas zu prüfen.
Kein Timeout auf dem Aufruf Ohne timeout() blockiert ein Ausfall der API die Anfrage des Nutzers bis zum Standardwert von PHP.
Den geheimen Schlüssel preisgeben Nur der Site-Key gehört ins HTML. Der geheime Schlüssel bleibt auf dem Server.

FormulairesLaravelPHPSécurité

Damien Flandrin Webentwickler seit 2010, Gründer von Gekkode und Email Impact. Jeder Artikel wird vor der Veröffentlichung an einem echten Projekt getestet. Kontakt
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.