mj-zana / captcha
CAPTCHA providers for Zana applications: Cloudflare Turnstile, Google reCAPTCHA and hCaptcha behind #[VerifyHuman], chosen by configuration
Requires
- php: ^8.5
- mj-zana/core: ^2.21
- mj-zana/http: ^2.25
- mj-zana/security: ^2.25
- nyholm/psr7: ^1.8
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
Requires (Dev)
- mj-zana/routing: ^2.21
- mj-zana/view: ^2.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
Suggests
- mj-zana/view: For the captcha_widget() helper
- symfony/http-client: A PSR-18 client to ask the provider (Psr18Client), or any other PSR-18 client
Provides
None
Conflicts
None
Replaces
None
README
CAPTCHA providers for Zana applications: Cloudflare Turnstile, Google
reCAPTCHA (v2 and v3) and hCaptcha, behind mj-zana/security's
#[VerifyHuman]. The provider is chosen by configuration, so one can
replace another without changing code.
composer require mj-zana/captcha symfony/http-client nyholm/psr7
The providers are asked through a PSR-18 HTTP client, which the application installs and registers; no provider SDK is needed:
$services->singleton(\Psr\Http\Client\ClientInterface::class, static fn () => new \Symfony\Component\HttpClient\Psr18Client( \Symfony\Component\HttpClient\HttpClient::create(['max_connect_duration' => 2, 'timeout' => 3, 'max_duration' => 5]), ));
Bound it: a connect timeout (Symfony 8.1 or later), a read timeout and a total duration. Never wrap it in a retrying client: a retried token is a reused one, and providers refuse it.
Configuration
// config/captcha.php return [ 'provider' => 'turnstile', // or recaptcha_v2, recaptcha_v3, hcaptcha // secret and site_key from the environment: APP_CAPTCHA__SECRET, APP_CAPTCHA__SITE_KEY ];
Register CaptchaServiceProvider, protect the handler, and print the widget
in the form:
#[Route('/contact', methods: ['POST'], middleware: [BotVerificationMiddleware::class])] #[VerifyHuman(action: 'contact')] public function send(ServerRequestInterface $request): View|ResponseInterface { … }
<form method="post">
<?= $this->csrf_field('contact') ?>
…
<?= $this->captcha_widget('contact', $csp_nonce) ?>
</form>
- reCAPTCHA v3 gives a score:
#[VerifyHuman(action: 'checkout', minimumScore: 0.5)]. Its widget asks for a token when the form is sent. - Content-Security-Policy: the widget needs the provider's script and
frame.
checksays what to add tohttp.security_headers.cspwhen the policy blocks them, since a blocked widget would refuse every visitor. - When the provider cannot be asked, or says the secret is wrong, the
post is refused (
security.bot.on_unavailable: 'allow'lets it through) and logged; it is never taken for the visitor being a bot. - Tests run without the network: register mj-zana/security's
FakeBotVerifierasBotVerifierInterface, or a PSR-18 client that answers from a list.
check: captcha.config (no provider, an unknown one, a missing secret or
site key), captcha.http_client (no PSR-18 client), captcha.csp (the
policy blocks the widget).
Documentation: security.