Integrando reCAPTCHA v3 de Google de forma fácil en Laravel

Ok, lo primero que hay que hacer es crear las llaves que vamos a usar; para ello debemos ir a: https://www.google.com/recaptcha/admin/create y seleccionar reCAPTCHA v3.
Antes de seguir, un aviso que no existía cuando escribí esto: reCAPTCHA ahora vive en Google Cloud. Las llaves que se crean en esa consola de administración son lo que Google llama reCAPTCHA Classic, y para integraciones nuevas recomienda el método CreateAssessment de su API en lugar del endpoint siteverify que usamos aquí. siteverify sigue funcionando, también después de migrar las llaves a un proyecto de Google Cloud, así que este código te sirve para mantener una integración existente o para algo sencillo. Ojo con un detalle de esa migración: si el proyecto no tiene una cuenta de facturación activa y pasas las 10.000 evaluaciones gratis del mes, siteverify responde success: true sin verificar nada.
Bien, ya tenemos las llaves ahora necesitamos hacer es crear una clase que podemos llamar ReCaptcha y tendrá el siguiente namespace App\Helpers (pueden adaptarlo según sus necesidades).
Esta clase va a contener lo siguiente:
<?php
namespace App\Helpers;
use GuzzleHttp\Client;
/**
* ReCaptcha
*/
class ReCaptcha
{
const CAPTCHA_URL = 'https://www.google.com/recaptcha/api/';
const TIMEOUT = 20.0;
protected $client;
public function __construct()
{
$this->client = new Client([
'base_uri' => self::CAPTCHA_URL,
'timeout' => self::TIMEOUT,
]);
}
/**
* Verifica el token con Google y comprueba que venga de la acción esperada.
*/
public function validateCaptcha(?string $recaptcha, string $expectedAction = 'contact_form'): bool
{
if (! $recaptcha) {
return false;
}
$captchaData = [
'headers' => [
'content-type' => 'application/x-www-form-urlencoded',
'accept' => 'application/json',
'cache-control' => 'no-cache'
],
'form_params' => [
'secret' => config('google.recaptcha_secret_key'),
'response' => $recaptcha
]
];
$request = $this->client->request('POST', 'siteverify', $captchaData);
$body = $request->getBody()->getContents();
$response = json_decode($body, true);
// a failed check has no score; score 0.5 or more is treated as human,
// and the token must come from the action this form asked for
return ($response['success'] ?? false)
&& ($response['action'] ?? null) === $expectedAction
&& ($response['score'] ?? 0) >= 0.5;
}
}Luego en nuestro AppServiceProvider en el método register colocamos esto
$this->app->bind(ReCaptcha::class, function () {
return new ReCaptcha;
});Y en el método boot colocamos lo siguiente:
Blade::directive('renderRecaptchaJs', function ($key) {
return '
<script src="https://www.google.com/recaptcha/api.js?render=<?php echo e('.$key.'); ?>"></script>
<script>
document.getElementById("recaptcha").form.addEventListener("submit", function (event) {
event.preventDefault();
var form = this;
grecaptcha.ready(function () {
grecaptcha.execute("<?php echo e('.$key.'); ?>", {action: "contact_form"}).then(function (token) {
document.getElementById("recaptcha").value = token;
form.submit();
});
});
});
</script>';
});
Blade::directive('renderRecaptcha', function () {
return '<input type="hidden" name="recaptcha" id="recaptcha">
<div class="recaptcha_text_render"> This site is protected by reCAPTCHA and the Google
<a class="recaptcha_text_link_render" href="https://policies.google.com/privacy" target="_blank">Privacy Policy</a> and
<a class="recaptcha_text_link_render" href="https://policies.google.com/terms" target="_blank">Terms of Service</a> apply.
</div>';
});El token se pide al enviar el formulario y no al cargar la página. Los tokens de reCAPTCHA caducan a los dos minutos, así que si lo generas al cargar, cualquiera que tarde más en llenar el formulario recibe un rechazo. El script intercepta el envío, pide el token, lo guarda en el campo oculto y recién ahí envía; form.submit() no vuelve a disparar el evento submit, así que no hay bucle.
Blade le pasa al callback el texto de la expresión, no su valor; por eso la llave se imprime con <?php echo e(...); ?> y se resuelve al renderizar la vista. Para usarlo en las vistas, @renderRecaptcha va dentro del formulario y @renderRecaptchaJs después, porque el script necesita que el campo oculto ya exista:
<form method="POST" action="{{ route('contact') }}">
@csrf
{{-- tus campos --}}
@renderRecaptcha
<button type="submit">Enviar</button>
</form>
@renderRecaptchaJs(config('google.recaptcha_site_key'))Y ya, para finalizar hacemos lo siguiente en el controlador donde se necesite usar este recaptcha:
public $recaptcha;
public function __construct(ReCaptcha $recaptcha)
{
$this->recaptcha = $recaptcha;
}
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(ContactFormRequest $request)
{
if ($this->recaptcha->validateCaptcha($request->recaptcha)) {
//
}
//
}La validación revisa dos cosas. La primera es el score del usuario, donde un valor menor a 0.5 se considera un bot; ese 0.5 es el umbral por defecto que sugiere la documentación de reCAPTCHA v3. La segunda es la acción: la misma documentación pide comprobar que el token venga de la acción que esperas (contact_form en este caso), para que un token generado en otra parte del sitio no sirva en este formulario. Si proteges varios formularios, usa una acción distinta en cada uno y pásala como segundo argumento de validateCaptcha.
Espero que les sirva.
Otras integraciones que acaban pidiéndose en casi todos los proyectos, en Laravel en producción.
Fuentes
- reCAPTCHA v3 (documentación oficial de Google reCAPTCHA)
- Verifying the user's response (documentación oficial de Google reCAPTCHA)
- Frequently Asked Questions (documentación oficial de Google reCAPTCHA)
- Verify user response tokens with legacy SiteVerify (documentación oficial de Google Cloud)
- Migrate from reCAPTCHA Classic (documentación oficial de Google Cloud)
¿Tienes un proyecto en mente?
Trabajo con Laravel, WordPress, SEO técnico y servidores MCP. El primer paso es una llamada de descubrimiento, sin costo ni compromiso, donde me cuentas qué necesitas y te digo con honestidad si puedo ayudarte.