Questa guida è per Spring Boot 3.2 e successivi (Spring MVC su Tomcat, Java 17 o successivi). La chiamata usa il RestClient di Spring sull'HttpClient del JDK, uno per il processo, che tiene aperta la connessione; il suo read timeout limita l'intera chiamata, dall'invio della richiesta alla risposta.
Il tag
Metti il tag nel <head> del layout che usano le pagine di registrazione, login e checkout (con Thymeleaf, il fragment che condividono):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
La chiamata
Leggi la chiave dall'ambiente, e lascia che Tomcat legga i forwarded header del tuo proxy:
# 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 } } }
Un servlet container non tiene l'ordine degli header della richiesta, quindi non ne viene inviato nessuno. Poi, nel controller che crea l'account:
// 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(); } }
L'IP del visitatore
request.getRemoteAddr() è l'indirizzo della connessione, quello del tuo proxy se ne hai uno. server.forward-headers-strategy=native attiva il RemoteIpValve di Tomcat: da un proxy su una rete privata (10.x, 172.16–31.x, 192.168.x, loopback), prende l'ultima voce di X-Forwarded-For che nessuno di questi proxy ha aggiunto, e getRemoteAddr() la restituisce. Un proxy su un indirizzo pubblico non è fidato finché non lo indichi in server.tomcat.remoteip.internal-proxies. Dietro Cloudflare, leggi invece request.getHeader("CF-Connecting-IP"), se il tuo server accetta traffico solo da Cloudflare.
Applica l'azione
Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode.
Conserva il request_id
Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso.
Provalo sulla tua macchina
Avvia ./mvnw spring-boot:run (o ./gradlew bootRun) e aprilo su localhost. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o 0:0:0:0:0:0:0:1, e va bene così.
- Imposta
KORZEN_API_KEYnell'ambiente e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie_kzcompare dopo qualche secondo. - Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
- Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde
401, e la registrazione va a buon fine.
Errori comuni
- Nessun
server.forward-headers-strategydietro un load balancer. Ogni visitatore ha allora l'IP privato del balancer, che Bot Protection tratta come nessun IP: vede molto meno, e la pagina Configurazione ti avvisa. - Leggere tu stesso la prima voce di
X-Forwarded-For. La scrive il visitatore, quindi un bot si sceglie il proprio IP. - Un nuovo
RestClientoHttpClientper ogni chiamata. Ognuno apre la propria connessione, con un handshake TLS dentro il tuo timeout. - Chiamare
/v1/decidedal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server. - Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da
200, o nessuna risposta in tempo, significa: lascia passare il visitatore.