Deze gids is voor Spring Boot 3.2 en nieuwer (Spring MVC op Tomcat, Java 17 of nieuwer). De call gebruikt de RestClient van Spring op de eigen HttpClient van de JDK, één voor het proces, die de verbinding open houdt; de read timeout begrenst de hele call, van het versturen van het request tot het antwoord.
De tag
Zet de tag in de <head> van de layout die je registratie-, inlog- en checkoutpagina's gebruiken (met Thymeleaf het fragment dat ze delen):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
De call
Lees de key uit de omgeving, en laat Tomcat de forwarded headers van je proxy lezen:
# src/main/resources/application.properties korzen.api-key=${KORZEN_API_KEY} server.forward-headers-strategy=native
// src/main/java/com/example/Korzen.java package com.example; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.net.http.HttpClient; import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.util.WebUtils; @Component public class Korzen { /** The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. */ private static final List<String> FORWARDED = List.of("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"); private static final Duration TIMEOUT = Duration.ofMillis(200); private final RestClient client; // one per process: it keeps the connection open public Korzen(@Value("${korzen.api-key}") String apiKey) { var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder().connectTimeout(TIMEOUT).build()); factory.setReadTimeout(TIMEOUT); // the whole call, from the request to its answer client = RestClient.builder() .baseUrl("https://korzen.dev") .requestFactory(factory) .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Korzen-Deadline-Ms", "200") .build(); } /** The answer for this request, or null on any error: then go on as on allow (fail open). */ public Map<String, Object> decide(HttpServletRequest request, String path) { var headers = new HashMap<String, String>(); for (var name : FORWARDED) { var value = request.getHeader(name); if (value != null) { headers.put(name, value); } } Cookie token = WebUtils.getCookie(request, "_kz"); var body = new HashMap<String, Object>(); // a HashMap, since Map.of takes no null body.put("ip", request.getRemoteAddr()); // see "The visitor's IP" body.put("user_agent", request.getHeader("User-Agent")); body.put("headers", headers); body.put("token", token == null ? null : token.getValue()); body.put("path", path); body.put("method", "POST"); try { return client.post().uri("/v1/decide").contentType(MediaType.APPLICATION_JSON).body(body) .retrieve().body(new ParameterizedTypeReference<Map<String, Object>>() {}); } catch (RuntimeException e) { return null; // a timeout, a network error or any status but 200 } } }
Een servletcontainer houdt geen volgorde van de headers van het request bij, dus er wordt er geen meegestuurd. Daarna, in de controller die het account aanmaakt:
// src/main/java/com/example/SignupController.java package com.example; import jakarta.servlet.http.HttpServletRequest; import java.net.URI; import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController public class SignupController { private final Korzen korzen; public SignupController(Korzen korzen) { this.korzen = korzen; } @PostMapping("/signup") public ResponseEntity<String> signup(HttpServletRequest request, @RequestParam String email) { Map<String, Object> d = korzen.decide(request, "/signup"); if (d != null && "block".equals(d.get("action"))) { return ResponseEntity.badRequest().body("We could not create your account."); } // "challenge": your CAPTCHA, or an email or phone check, before going on var user = accounts.create(email); // your own code user.setKorzenRequestId(d == null ? null : (String) d.get("request_id")); // see "Keep the request_id" return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/welcome")).build(); } }
Het IP-adres van de bezoeker
request.getRemoteAddr() is het adres van de verbinding, dat van je proxy als je er een hebt. server.forward-headers-strategy=native zet de RemoteIpValve van Tomcat aan: van een proxy op een privénetwerk (10.x, 172.16–31.x, 192.168.x, loopback) neemt hij de laatste entry van X-Forwarded-For die zo'n proxy niet heeft toegevoegd, en getRemoteAddr() geeft die terug. Een proxy op een publiek adres wordt pas vertrouwd als je hem noemt in server.tomcat.remoteip.internal-proxies. Achter Cloudflare lees je in plaats daarvan request.getHeader("CF-Connecting-IP"), als je server alleen verkeer van Cloudflare accepteert.
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 ./mvnw spring-boot:run (of ./gradlew bootRun) 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 0:0:0:0:0:0:0: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
- Geen
server.forward-headers-strategyachter een load balancer. Elke bezoeker heeft dan het privé-IP-adres van de balancer, dat Bot Protection als geen IP-adres behandelt: het ziet veel minder, en de pagina Set-up waarschuwt je. - Zelf de eerste entry van
X-Forwarded-Forlezen. Die schrijft de bezoeker zelf, dus een bot kiest zijn eigen IP-adres. - Een nieuwe
RestClientofHttpClientvoor elke call. Elke client opent zijn eigen verbinding, met een TLS-handshake binnen je timeout. /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.