Ce guide vaut pour PHP 8.1 et plus récent, avec l'extension curl que la plupart des builds incluent ; pour Laravel, voir Laravel. Une seule classe sert PHP sans framework comme Symfony : elle prend $_SERVER, l'IP du visiteur et le cookie _kz, donc chacun passe les siens.
Le tag
Placez le tag dans le <head> du layout qu'utilisent vos pages d'inscription, de connexion et de checkout (dans Symfony, templates/base.html.twig) :
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
L'appel
CURLOPT_TIMEOUT_MS borne l'appel entier, connexion comprise :
<?php // src/Korzen.php namespace App; final class Korzen { /** The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. */ private const HEADERS = ['accept', 'accept-encoding', 'accept-language', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'upgrade-insecure-requests']; /** * The answer for this request, or null on any error: then go on as on allow (fail open). * $server is $_SERVER (in Symfony, $request->server->all()); $ip the visitor's IP. */ public static function decide(array $server, ?string $ip, ?string $token, string $path): ?array { $headers = []; foreach (self::HEADERS as $name) { $key = 'HTTP_' . strtoupper(strtr($name, '-', '_')); if (isset($server[$key])) { $headers[$name] = $server[$key]; } } // The order the headers came in, as $_SERVER keeps it: PHP-FPM lists them last first. $names = array_values(array_filter(array_keys($server), fn ($k) => str_starts_with((string) $k, 'HTTP_'))); if (PHP_SAPI === 'fpm-fcgi') { $names = array_reverse($names); } $order = array_map(fn ($k) => strtolower(strtr(substr($k, 5), '_', '-')), array_slice($names, 0, 64)); $ch = curl_init('https://korzen.dev/v1/decide'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode([ 'ip' => $ip, 'user_agent' => $server['HTTP_USER_AGENT'] ?? null, 'headers' => (object) $headers, // {} when empty, never [] 'header_order' => $order, 'token' => $token, 'path' => $path, 'method' => 'POST', ]), CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . ($_ENV['KORZEN_API_KEY'] ?? getenv('KORZEN_API_KEY')), // Symfony's .env fills $_ENV 'Content-Type: application/json', 'Korzen-Deadline-Ms: 200', ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => 200, // the whole call, the connection included CURLOPT_NOSIGNAL => true, // or libcurl rounds a timeout under a second to none ]); $res = curl_exec($ch); if ($res === false || curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) { return null; } $d = json_decode($res, true); return is_array($d) ? $d : null; } }
header_order est l'ordre dans lequel les headers de la requête sont arrivés : un script qui copie les valeurs d'un navigateur en copie rarement l'ordre. PHP-FPM le garde dans les clés HTTP_* de $_SERVER, du dernier au premier, donc l'appel les inverse là ; php -S et symfony serve les gardent dans l'ordre. Derrière nginx, c'est l'ordre dans lequel nginx les transmet, qui est l'ordre d'arrivée (mesuré avec nginx 1.31) ; nginx écarte un nom avec _. Derrière un load balancer ou Cloudflare, c'est l'ordre de ce saut, pas celui du navigateur.
Sans framework
Dans le script qui crée le compte :
<?php // public/signup.php require __DIR__ . '/../src/Korzen.php'; use App\Korzen; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $decision = Korzen::decide($_SERVER, $_SERVER['REMOTE_ADDR'] ?? null, $_COOKIE['_kz'] ?? null, '/signup'); if (($decision['action'] ?? 'allow') === 'block') { http_response_code(400); exit('We could not create your account.'); } // "challenge": your CAPTCHA, or an email or phone check, before going on $user = create_account($_POST); // your own code save_request_id($user, $decision['request_id'] ?? null); // see "Keep the request_id" header('Location: /welcome', true, 303); exit; }
$_SERVER['REMOTE_ADDR'] est l'adresse de la connexion : celle du visiteur quand rien ne se trouve devant PHP. Derrière un proxy, voir « L'IP du visiteur ».
Symfony
Symfony charge src/Korzen.php de lui-même. Faites confiance à votre proxy, pour que getClientIp() soit celle du visiteur :
# config/packages/framework.yaml framework: trusted_proxies: '127.0.0.1,10.0.0.0/8' # your proxies' addresses trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host']
<?php // src/Controller/SignupController.php namespace App\Controller; use App\Korzen; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; final class SignupController extends AbstractController { #[Route('/signup', methods: ['POST'])] public function signup(Request $request): Response { $decision = Korzen::decide($request->server->all(), $request->getClientIp(), $request->cookies->get('_kz'), '/signup'); if (($decision['action'] ?? 'allow') === 'block') { return new Response('We could not create your account.', 400); } // "challenge": your CAPTCHA, or an email or phone check, before going on $user = $this->accounts->create($request->request->all()); // your own code $user->setKorzenRequestId($decision['request_id'] ?? null); // see "Keep the request_id" return $this->redirect('/welcome', 303); } }
L'IP du visiteur
Sans framework, REMOTE_ADDR est l'adresse de votre proxy quand vous en avez un. Derrière un load balancer ou un nginx à vous, prenez la dernière entrée de $_SERVER['HTTP_X_FORWARDED_FOR'], celle que votre proxy a ajoutée ; derrière Cloudflare, $_SERVER['HTTP_CF_CONNECTING_IP'], si votre serveur ne reçoit du trafic que de Cloudflare. Dans Symfony, trusted_proxies s'en charge : getClientIp() lit X-Forwarded-For depuis la droite et saute les adresses auxquelles vous faites confiance. PHP-FPM ouvre une nouvelle connexion pour chaque requête qu'il sert, donc chaque appel comporte une poignée de main TLS : loin de l'Europe, choisissez un timeout qui lui laisse de la place.
Appliquer l'action
Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.
Garder le request_id
Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.
Tester sur votre machine
Lancez php -S localhost:8000 -t public (dans Symfony, symfony serve) et ouvrez-le sur localhost. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien.
- Définissez
KORZEN_API_KEYdans l'environnement et ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie_kzapparaît après quelques secondes. - Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
- Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond
401, et l'inscription passe.
Erreurs fréquentes
- La première entrée de
X-Forwarded-For. Le visiteur l'écrit, donc un bot choisit sa propre IP. CURLOPT_TIMEOUT_MSsansCURLOPT_NOSIGNAL. Là où libcurl résout les noms avec le résolveur bloquant du système, un timeout de moins d'une seconde échoue alors aussitôt, et chaque appel laisse passer le visiteur.getenv()seul pour la clé dans Symfony. Le Dotenv de Symfony met.envdans$_ENV, pas dans l'environnement du processus, donc la classe lit d'abord$_ENV.- Appeler
/v1/decidedepuis le navigateur. L'API key atteindrait chaque visiteur ; l'appel a sa place sur votre serveur. - Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que
200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.