reCAPTCHA no Laravel: proteger um formulário sem pacote

reCAPTCHA no Laravel: proteger um formulário sem pacote
Resposta rápida

Não é preciso pacote: basta uma classe que implemente ValidationRule e interrogue https://www.google.com/recaptcha/api/siteverify com a fachada Http. Controla três coisas na resposta: success, a action esperada e o score. E associa sempre a regra ao required: o Laravel não executa uma regra personalizada sobre um campo vazio.

Um reCAPTCHA resume-se a duas coisas: um token produzido pelo navegador e a verificação desse token junto da Google a partir do teu servidor. O Laravel traz tudo o que é preciso para a segunda parte, o que torna o pacote de terceiros facultativo.

Artigo do percurso Desenvolvimento web. Este guia monta uma regra de validação autónoma, sem dependências, e põe-na à prova em seis cenários. Verificado em Laravel 13.30.1 com PHP 8.4.25.

Obter as chaves

Cria um site na consola do reCAPTCHA e escolhe a versão v3, que atribui um score de 0 a 1 sem pedir nada ao visitante. Ficas com duas chaves: a chave de site, pública, e a chave secreta, que nunca deve sair do servidor.

.env
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secrete

Declara-as na configuração em vez de chamares env() a partir do código: assim que a configuração está em cache, o env() devolve null em todo o lado menos em config/.

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

A regra de validação

Basta uma classe que implemente ValidationRule. Ela interroga a API de verificação e controla o sucesso, a ação esperada e o 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.');
        }
    }
}

O controlo da ação merece uma explicação: sem ele, um token obtido numa página pública qualquer do teu site podia servir para passar a validação do formulário de contacto. O score sozinho não chega.

Ligar a regra ao formulário

Acrescenta o required, senão a proteção não serve de nada

O Laravel não executa uma regra personalizada sobre um valor vazio ou ausente. Verificado: só com a regra Recaptcha, um campo vazio passa a validação sem que parta um único pedido para a Google. Bastaria portanto não enviar o campo para contornar o captcha. A regra required não é opcional aqui.

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

    // …
}

O formulário do lado do navegador

O formulário abaixo assenta nas regras de escape do Blade. A v3 não mostra nenhuma caixa a marcar: o script gera um token que é colocado num campo escondido mesmo antes do envio.

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>

A ação declarada aqui, contact, tem de ser exatamente a que a regra espera do lado do servidor.

Testar sem chamar a Google

Uma proteção que não se testa é uma proteção cujo estado se desconhece. Lembra-te também de desativar o modo de depuração em produção, sob pena de expores a tua chave secreta numa página de erro.

O Http::fake() permite simular cada resposta da 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());   // recomeça com um cliente novo em cada 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());
}

Resultados obtidos:

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.
O Http::fake() acumula as simulações

Chamar o Http::fake() várias vezes no mesmo teste não apaga as anteriores: responde a primeira correspondência, e todos os casos seguintes recebem a resposta do primeiro. Daí o Http::swap(new Factory()) antes de cada caso. Sem ele, a série inteira fica verde sem verificar coisa nenhuma.

É preciso um pacote?

O anhskohbo/no-captcha, citado na versão anterior deste artigo, continua a existir na versão 3.8. Poupa umas linhas, essencialmente a apresentação do widget v2.

O código acima cabe numa classe e numa chamada HTTP, sem dependência para acompanhar, e deixa-te o controlo do score mínimo, do controlo da ação e das mensagens de erro. Num formulário, é a melhor relação.

A ter em conta se estás a partir do zero: o Cloudflare Turnstile cumpre o mesmo papel com uma API de verificação da mesma forma. Só mudam o URL de verificação e o nome do campo, a estrutura da regra fica igual.

Erros frequentes

Esquecer o required ao lado da regra O Laravel não executa uma regra personalizada sobre um valor vazio ou ausente. Sem required, basta não enviar o campo para contornar o captcha por completo. Verificado.
Não controlar a ação Um token obtido em qualquer página do site passaria a validação do formulário. O score sozinho não protege.
Chamar o env() a partir da regra Assim que a configuração está em cache, o env() devolve null. Passa por config('services.recaptcha.secret').
O Http::fake() acumula as simulações Uma segunda chamada não apaga a primeira: responde a primeira correspondência. Sem Http::swap(new Factory()), toda a série de testes fica verde sem verificar nada.
Nenhum tempo de expiração na chamada Sem timeout(), uma avaria da API bloqueia o pedido do utilizador até ao prazo predefinido do PHP.
Expor a chave secreta Só a chave de site vai para o HTML. A chave secreta fica do lado do servidor.

FormulairesLaravelPHPSécurité

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.