Diese Anleitung gilt für Spring Boot 3.2 und neuer (Spring MVC auf Tomcat, Java 17 oder neuer). Der Aufruf verwendet den RestClient von Spring auf dem eigenen HttpClient des JDK, einer für den Prozess, der die Verbindung offen hält; sein Read-Timeout begrenzt den ganzen Aufruf, vom Senden der Anfrage bis zu ihrer Antwort.
Der Tag
Setzen Sie den Tag in den <head> des Layouts, das Ihre Registrierungs-, Anmelde- und Checkout-Seiten verwenden (mit Thymeleaf das Fragment, das sie teilen):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
Der Aufruf
Lesen Sie den Schlüssel aus der Umgebung und lassen Sie Tomcat die Forwarded-Header Ihres Proxys lesen:
# 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 } } }
Ein Servlet-Container hält keine Reihenfolge der Header der Anfrage, also wird keine gesendet. Dann, im Controller, der das Konto anlegt:
// 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(); } }
Die IP des Besuchers
request.getRemoteAddr() ist die Adresse der Verbindung, die Ihres Proxys, wenn Sie einen haben. server.forward-headers-strategy=native schaltet das RemoteIpValve von Tomcat ein: Von einem Proxy in einem privaten Netz (10.x, 172.16–31.x, 192.168.x, Loopback) nimmt es den letzten Eintrag von X-Forwarded-For, den kein solcher Proxy angehängt hat, und getRemoteAddr() liefert ihn. Einem Proxy mit öffentlicher Adresse wird erst vertraut, wenn Sie ihn in server.tomcat.remoteip.internal-proxies nennen. Hinter Cloudflare lesen Sie stattdessen request.getHeader("CF-Connecting-IP"), wenn Ihr Server nur Traffic von Cloudflare annimmt.
Die Aktion anwenden
Lesen Sie nur action. Bei block antworten Sie mit demselben Fehler, den die Aktion zeigt, wenn sie aus einem anderen Grund scheitert, damit ein Bot nichts lernt. Bei challenge nutzen Sie die Prüfung, die Sie schon haben (ein CAPTCHA, ein Code per E-Mail oder Telefon); ohne eine machen Sie weiter. Das Konto beginnt im Monitor-Modus, in dem action immer allow ist: Für Besucher ändert sich nichts, bis Sie den Block-Modus einschalten.
Die request_id aufbewahren
Jede Antwort hat eine request_id. Bewahren Sie sie bei dem auf, was die Aktion erstellt hat, in einer Spalte des Kontos oder der Bestellung, oder in einer Logzeile mit deren ID. Sie benennt diese Entscheidung, wenn Sie uns dazu etwas fragen, und lässt Sie später herausfinden, welche Antwort ein Fake-Konto durchgelassen hat.
Auf Ihrem Rechner testen
Starten Sie ./mvnw spring-boot:run (oder ./gradlew bootRun) und öffnen Sie es auf localhost. Seiten auf localhost akzeptieren den Tag immer und erscheinen nie als Domain. Lokal ist die IP 127.0.0.1 oder 0:0:0:0:0:0:0:1, und das ist in Ordnung.
- Setzen Sie
KORZEN_API_KEYin der Umgebung und öffnen Sie die Registrierungsseite. In den Entwicklertools des Browsers erscheint nach ein paar Sekunden das_kz-Cookie. - Registrieren Sie sich. Die Entscheidung erscheint auf der Seite Entscheidungen des Dashboards, unter dem Pfad, den Sie gesendet haben.
- Prüfen Sie fail-open: Setzen Sie einen falschen Schlüssel und registrieren Sie sich erneut. Der Aufruf antwortet
401, und die Registrierung geht durch.
Häufige Fehler
- Kein
server.forward-headers-strategyhinter einem Load Balancer. Jeder Besucher hat dann die private IP des Balancers, die Bot Protection wie keine IP behandelt: Es sieht viel weniger, und die Seite Einrichtung weist Sie darauf hin. - Den ersten Eintrag von
X-Forwarded-Forselbst lesen. Der Besucher schreibt ihn, also wählt ein Bot seine eigene IP. - Ein neuer
RestClientoderHttpClientfür jeden Aufruf. Jeder öffnet seine eigene Verbindung, mit einem TLS-Handshake innerhalb Ihres Timeouts. /v1/decideaus dem Browser aufrufen. Der API key würde jeden Besucher erreichen; der Aufruf gehört auf Ihren Server.- Kein Timeout, oder ein Fehler, der die Registrierung stoppt. Jede andere Antwort als
200, und keine Antwort rechtzeitig, bedeutet: den Besucher durchlassen.