Este guia é para Spring Boot 3.2 em diante (Spring MVC no Tomcat, Java 17 em diante). A chamada usa o RestClient do Spring sobre o HttpClient do próprio JDK, um para o processo, que mantém a conexão aberta; o read timeout dele limita a chamada inteira, do envio da requisição até a resposta.
A tag
Coloque a tag no <head> do layout que as páginas de cadastro, login e checkout usam (com Thymeleaf, o fragmento que elas compartilham):
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
A chamada
Leia a chave do ambiente e deixe o Tomcat ler os forwarded headers do seu 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 } } }
Um servlet container não guarda a ordem dos headers da requisição, então nenhuma é enviada. Depois, no controller que cria a conta:
// 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(); } }
O IP do visitante
request.getRemoteAddr() é o endereço da conexão, o do seu proxy se houver um. server.forward-headers-strategy=native liga o RemoteIpValve do Tomcat: vindo de um proxy numa rede privada (10.x, 172.16–31.x, 192.168.x, loopback), ele pega a última entrada do X-Forwarded-For que nenhum proxy desses acrescentou, e o getRemoteAddr() a devolve. Um proxy num endereço público não é confiável até você nomeá-lo em server.tomcat.remoteip.internal-proxies. Atrás do Cloudflare, leia request.getHeader("CF-Connecting-IP"), se o seu servidor só recebe tráfego do Cloudflare.
Siga a action
Leia só a action. Em block, responda com o mesmo erro que a ação mostra quando falha por qualquer outro motivo, para o bot não aprender nada. Em challenge, use a verificação que você já tem (um CAPTCHA, um código por email ou telefone); sem nenhuma, siga em frente. A conta começa em monitor mode, em que a action é sempre allow: nada muda para os visitantes até você ligar o block mode.
Guarde o request_id
Toda resposta tem um request_id. Guarde-o junto do que a ação criou, numa coluna da conta ou do pedido, ou numa linha de log com o id deles. Com ele, você acha a decisão quando falar com a gente e descobre depois qual resposta deixou passar uma conta falsa.
Teste na sua máquina
Rode ./mvnw spring-boot:run (ou ./gradlew bootRun) e abra em localhost. Páginas em localhost sempre aceitam a tag e nunca entram na lista de domínios. Localmente o IP é 127.0.0.1 ou 0:0:0:0:0:0:0:1, e não tem problema.
- Defina
KORZEN_API_KEYno ambiente e abra a página de cadastro. Nas ferramentas de desenvolvedor do navegador, o cookie_kzaparece depois de alguns segundos. - Cadastre-se. A decisão aparece na página Decisões do painel, com o path que você enviou.
- Confira o fail open: coloque uma chave errada e cadastre-se de novo. A chamada responde
401, e o cadastro passa.
Erros comuns
- Nenhum
server.forward-headers-strategyatrás de um load balancer. Todo visitante fica com o IP privado do balancer, que o Bot Protection toma por nenhum IP: ele vê bem menos, e a página Configuração avisa você. - Ler você mesmo a primeira entrada do
X-Forwarded-For. Quem a define é o visitante, então o bot escolhe o próprio IP. - Um
RestClientouHttpClientnovo a cada chamada. Cada um abre a própria conexão, com um handshake TLS dentro do seu timeout. - Chamar
/v1/decidedo navegador. A API key ficaria exposta para todo visitante; a chamada sai do seu servidor. - Sem timeout, ou um erro que trava o cadastro. Se a resposta não for
200, ou não chegar a tempo, deixe o visitante passar.