Deze gids is voor PHP 8.1 en nieuwer, met de curl-extensie die de meeste builds bevatten; voor Laravel, zie Laravel. Eén class dient gewone PHP en Symfony allebei: hij neemt $_SERVER, het IP-adres van de bezoeker en de _kz-cookie, zodat elk zijn eigen waarden meegeeft.
De tag
Zet de tag in de <head> van de layout die je registratie-, inlog- en checkoutpagina's gebruiken (in Symfony templates/base.html.twig):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
De call
CURLOPT_TIMEOUT_MS begrenst de hele call, de verbinding inbegrepen:
<?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 is de volgorde waarin de headers van het verzoek binnenkwamen: een script dat de waarden van een browser kopieert, kopieert zelden hun volgorde. PHP-FPM houdt die bij in de HTTP_*-sleutels van $_SERVER, van achter naar voren, dus de call draait ze daar om; php -S en symfony serve houden ze op volgorde. Achter nginx is het de volgorde waarin nginx ze doorstuurt, en dat is de volgorde waarin ze binnenkwamen (gemeten met nginx 1.31); nginx laat een naam met _ vallen. Achter een load balancer of Cloudflare is het de volgorde van die hop, niet die van de browser.
Zonder framework
In het script dat het account aanmaakt:
<?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'] is het adres van de verbinding: dat van de bezoeker als er niets voor PHP staat. Achter een proxy, zie "Het IP-adres van de bezoeker".
Symfony
Symfony laadt src/Korzen.php zelf. Vertrouw je proxy, zodat getClientIp() het adres van de bezoeker is:
# 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); } }
Het IP-adres van de bezoeker
Zonder framework is REMOTE_ADDR het adres van je proxy als je er een hebt. Achter één load balancer of nginx van jezelf neem je de laatste entry van $_SERVER['HTTP_X_FORWARDED_FOR'], die je proxy heeft toegevoegd; achter Cloudflare $_SERVER['HTTP_CF_CONNECTING_IP'], als je server alleen verkeer van Cloudflare accepteert. In Symfony doet trusted_proxies het: getClientIp() leest X-Forwarded-For van rechts en slaat de adressen over die je vertrouwt. PHP-FPM opent voor elke request die het afhandelt een nieuwe verbinding, dus elke call bevat een TLS-handshake: zit je ver van Europa, kies dan een timeout met ruimte daarvoor.
Voer de actie uit
Lees alleen action. Bij block antwoord je met dezelfde foutmelding die de actie toont als hij om een andere reden mislukt, zodat een bot niets leert. Bij challenge gebruik je de controle die je al hebt (een CAPTCHA, een code per e-mail of telefoon); heb je er geen, ga dan door. Het account begint in monitor mode, waar action altijd allow is: voor bezoekers verandert er niets tot je block mode aanzet.
Bewaar de request_id
Elk antwoord heeft een request_id. Bewaar die bij wat de actie heeft aangemaakt, in een kolom van het account of de bestelling, of in een logregel met hun id. Zo weet je welke beslissing het was als je ons ernaar vraagt, en kun je later nagaan welk antwoord een nepaccount binnenliet.
Test het op je eigen machine
Draai php -S localhost:8000 -t public (in Symfony symfony serve) en open het op localhost. Pagina's op localhost accepteren de tag altijd en staan nooit als domein in de lijst. Lokaal is het IP-adres 127.0.0.1 of ::1, en dat is prima.
- Zet
KORZEN_API_KEYin de omgeving en open de registratiepagina. In de developer tools van de browser verschijnt na een paar seconden de_kz-cookie. - Registreer je. De beslissing verschijnt op de pagina Beslissingen van het dashboard, onder het pad dat je stuurde.
- Controleer fail-open: zet een verkeerde key en registreer je opnieuw. De call antwoordt met
401, en de registratie gaat door.
Veelgemaakte fouten
- De eerste entry van
X-Forwarded-For. Die schrijft de bezoeker zelf, dus een bot kiest zijn eigen IP-adres. CURLOPT_TIMEOUT_MSzonderCURLOPT_NOSIGNAL. Waar libcurl namen opzoekt met de blokkerende resolver van het systeem, mislukt een timeout onder een seconde dan meteen, en laat elke call de bezoeker door.- Alleen
getenv()voor de key in Symfony. De Dotenv van Symfony zet.envin$_ENV, niet in de omgeving van het proces, dus de class leest eerst$_ENV. /v1/decideaanroepen vanuit de browser. De API key zou dan bij elke bezoeker terechtkomen; de call hoort op je server.- Geen timeout, of een fout die de registratie tegenhoudt. Elk ander antwoord dan
200, en geen antwoord op tijd, betekent: laat de bezoeker door.