Spring Boot 3 无感验证码集成:Cloudflare Turnstile 完整实战 本文摘要
告别滑动拼图,拥抱 “零点击” 验证体验。本文将带你集成 Cloudflare Turnstile 无感验证码,并通过策略模式预留 Google reCAPTCHA v3 的扩展能力。完全免费、无限调用、国内可用。
第一章. 验证服务商速览与选型 本章不教接入,只帮你快速决策 “用哪家”。如果你已经确定使用 Cloudflare Turnstile,可以直接跳到第二章。
1.1. 永久免费方案 市面上真正 “永久免费且额度充足” 的无感验证服务,只有两家值得考虑:
服务商 免费额度 国内可用 验证方式 核心特点 Cloudflare Turnstile 无限制 ✅ 可用 零点击 / 低风险点击确认 隐私优先、体验极佳、本文首推 Google reCAPTCHA v3 100 万次/月 ❌ 不可用 纯后台评分(0.0-1.0) 行业标准、仅限海外业务
选型建议 :
如果你的用户在国内,Cloudflare Turnstile 是唯一选择 如果你的产品纯面向海外,两者都可以,但 Turnstile 的用户体验更好 1.2. 国内厂商试用政策 如果你的业务对 “数据不出境” 有强要求,或者需要与国内云生态深度集成,可以考虑以下厂商。但请注意,它们都没有永久免费额度,仅提供短期试用:
服务商 免费/试用政策 官网 腾讯云天御 新用户 2 万次 / 7 天体验包 网易易盾 企业认证后 1-2 周试用 顶象 基础版免费试用(功能受限) 阿里云验证码 无永久免费,按量付费约 ¥0.7 / 千次
1.3. 本文技术选型 基于以上分析,本文的技术方案如下:
主讲 :Cloudflare Turnstile —— 免费、国内可用、体验最佳扩展 :Google reCAPTCHA v3 —— 通过策略模式预留接口,代码层面兼容,但不展开讲解1.3.1. 本节小结 决策点 结论 国内业务首选 Cloudflare Turnstile 海外业务备选 Google reCAPTCHA v3 国内厂商 仅试用,无永久免费
第二章. Cloudflare Turnstile 密钥获取 在开始编码之前,我们需要先在 Cloudflare 控制台创建 “小组件” 并获取密钥。这个过程大约需要 3 分钟。
2.1. 进入 Turnstile 控制台 步骤 1:登录 Cloudflare
访问 Cloudflare Dashboard ,使用你的账号登录。
步骤 2:找到 Turnstile 入口
登录后,在左侧菜单栏的 “最近访问” 区域下方找到 Turnstile (标注为 “应用程序安全”),点击进入。
如果左侧没有显示,可以点击顶部搜索框(快捷键 Ctrl + K),输入 “Turnstile” 快速定位。
2.2. 添加小组件 进入 Turnstile 页面后,点击 添加小组件 按钮,进入配置页面。
步骤 1:填写小组件名称
在 “小组件名称” 输入框中填写一个便于识别的名称,例如:
这个名称仅用于控制台管理,不会影响实际功能。
步骤 2:添加主机名
点击 + 添加主机名 按钮,在弹出的输入框中填写:
本地开发必须添加 localhost,否则验证码会报域名不匹配的错误。生产环境需要添加你的实际域名。
步骤 3:选择小组件模式
页面下方提供了三种模式,根据你的需求选择:
模式 说明 推荐场景 托管 (推荐)Cloudflare 自动决定是否需要用户交互。大多数情况下无感通过,可疑时会弹出确认框 通用场景,平衡安全与体验 非交互式 完全非交互式质询,用户只会看到一个加载条的小组件 对体验有极致要求 不可见 不需要交互的无可见质询 高级场景,需要纯 JS 调用
本文选择 托管 模式。
步骤 4:预先许可设置
页面底部有一个 “是否要为此站点选择预先许可” 的选项:
是 :Turnstile 会发布一个许可 Cookie,让已通过验证的用户在有效期内免于再次验证否 :每次都需要重新验证对于本地开发测试,选择 否 即可。
步骤 5:创建小组件
确认所有配置无误后,点击右下角的 创建 按钮。
2.3. 获取密钥 创建成功后,页面会跳转到小组件详情页,显示两个关键密钥:
密钥类型 用途 安全性 站点密钥(Site Key) 前端使用,嵌入到网页中 可以公开 密钥(Secret Key) 后端使用,调用验证 API 必须保密
请将这两个密钥复制保存,后续配置会用到。
Secret Key 一旦泄露,攻击者可以伪造验证结果。请勿将其提交到公开的代码仓库。
2.4. 测试密钥(开发神器) Cloudflare 官方提供了一组 “永远通过” 的测试密钥,让你在开发阶段无需真正完成验证:
用途 Key Site Key(前端) 1x00000000000000000000AASecret Key(后端) 1x0000000000000000000000000000000AA
使用测试密钥时,验证码组件会直接返回成功,方便你专注于业务逻辑的开发。
测试密钥仅用于本地开发调试,生产环境必须替换为真实密钥。
2.4.1. 本节小结 配置项 值 / 操作 控制台入口 Cloudflare Dashboard → Turnstile → 添加小组件 小组件名称 随意填写,便于识别即可 主机名 本地开发填 localhost,生产环境填实际域名 小组件模式 推荐选择 “托管” 预先许可 本地开发选 “否” 测试 Site Key 1x00000000000000000000AA测试 Secret Key 1x0000000000000000000000000000000AA
第三章. 后端实现:策略模式多厂商架构 在上一章中,我们已经在 Cloudflare 控制台完成了站点创建,拿到了 Site Key 和 Secret Key。但密钥只是"通行证",真正的验证逻辑需要在后端实现。
本章的核心挑战是:如何设计一套架构,让系统能够优雅地支持多个验证服务商,并且切换时不需要修改业务代码?答案是策略模式。
本章学习路径
阶段 内容 认知里程碑 阶段一 理解验证流程 掌握 Cloudflare API 的请求/响应结构 阶段二 策略模式设计 理解为什么用策略模式,而不是 if-else 阶段三 代码实现 完成可运行的后端服务
3.1. Cloudflare 验证流程深度拆解 在写代码之前,我们必须先彻底理解 Cloudflare Turnstile 的验证机制。很多开发者直接复制代码却不理解原理,遇到问题时就束手无策。
3.1.1. 整体验证流程 无感验证码的核心思想是:前端负责"人机交互",后端负责"结果校验"。两者通过一个 Token 串联起来。
这个流程中,后端的职责集中在第 8-9 步:拿着前端传来的 Token,去 Cloudflare 服务器验证真伪。
3.1.2. siteverify 接口详解 后端需要调用的核心接口是 siteverify,我们来拆解它的请求和响应结构。
接口地址
1 POST https://challenges.cloudflare.com/turnstile/v0/siteverify
请求参数
参数名 类型 必填 说明 secretString ✅ 是 后端密钥(Secret Key),在控制台获取 responseString ✅ 是 前端传来的 Token remoteipString ❌ 否 用户的真实 IP 地址,传递后可提高验证准确性
响应结构
1 2 3 4 5 6 7 8 { "success" : true , "challenge_ts" : "2025-01-15T10:30:00.000Z" , "hostname" : "localhost" , "error-codes" : [ ] , "action" : "" , "cdata" : "" }
字段 类型 说明 successBoolean 核心字段 ,true 表示验证通过challenge_tsString 验证时间戳(ISO 8601 格式) hostnameString 验证发生的域名 error-codesArray 错误码列表,验证失败时会包含具体原因
常见错误码
错误码 含义 排查方向 missing-input-secret未提供 Secret Key 检查请求参数 invalid-input-secretSecret Key 无效 检查密钥是否正确 missing-input-response未提供 Token 检查前端是否传递了 Token invalid-input-responseToken 无效或已过期 Token 只能使用一次,且有时效性 bad-request请求格式错误 检查 Content-Type 和参数格式
3.1.3. Google reCAPTCHA v3 的差异 虽然本文主讲 Cloudflare,但我们的架构需要兼容 Google。两者的核心差异在于:
对比维度 Cloudflare Turnstile Google reCAPTCHA v3 验证接口 /turnstile/v0/siteverify/recaptcha/api/siteverify判断逻辑 只看 success 字段 需要同时检查 success 和 score 评分机制 无 0.0(机器人)到 1.0(人类) 推荐阈值 无 通常设为 0.5
Google 的响应示例:
1 2 3 4 5 6 7 { "success" : true , "score" : 0.9 , "action" : "login" , "challenge_ts" : "2025-01-15T10:30:00.000Z" , "hostname" : "localhost" }
这意味着:对于 Google,即使 success: true,如果 score 低于阈值,我们仍然应该拒绝请求。
3.2. 策略模式架构设计 理解了验证流程后,我们来思考代码架构。最直观的实现方式是用 if-else:
1 2 3 4 5 6 7 8 9 10 11 public boolean verify (String token, String provider) { if ("cloudflare" .equals(provider)) { } else if ("google" .equals(provider)) { } else if ("tencent" .equals(provider)) { } }
这种写法的问题显而易见:
违反开闭原则 :新增服务商必须修改已有代码代码臃肿 :随着服务商增多,if-else 会越来越长难以测试 :所有逻辑耦合在一起,无法单独测试某个服务商3.2.1. 策略模式的解决方案 策略模式的核心思想是:将每种验证逻辑封装成独立的类,通过统一接口调用,运行时动态选择 。
各组件职责 :
现在我们看看这张图到底讲了一个什么故事:
老板(顶层 Factory): 有一个叫 CaptchaVerifierFactory 的工厂。 它肚子里有个小本本(-verifierMap),记录着都有哪些验证服务可用。 它的工作是(+getVerifier):你告诉它你要哪家服务,它就给你哪家。 合同/职位描述(中间 Interface): 有一个叫 CaptchaVerifier 的职位标准。 任何人想干这个职位,必须会做两件事:能说出自己是谁(getProvider)。 能进行验证(verify)。 具体打工人(底层 Concrete Classes): CloudflareCaptchaVerifier:我是 Cloudflare 验证器,我签了合同(虚线),我会按我的方式去验证。GoogleCaptchaVerifier:我是 Google 验证器,我也签了合同(虚线),我会按我的方式去验证。新增服务商时的变化 :
✅ 只需新增一个实现类(如 TencentCaptchaVerifier) ✅ 在配置文件中添加对应配置 ❌ 不需要修改任何已有代码 3.3. 项目结构与依赖 现在开始动手写代码。首先创建项目并配置依赖。
3.3.1. 环境要求 组件 版本 说明 JDK 17+ Spring Boot 3 最低要求 Spring Boot 3.5+ 本文使用 3.5.9 Maven 3.8+ 构建工具
3.3.2. Maven 依赖配置 创建一个新的 Spring Boot 项目,pom.xml 配置如下:
📄 文件:pom.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.5.9</version > <relativePath /> </parent > <groupId > com.example</groupId > <artifactId > turnstile</artifactId > <version > 1.0.0</version > <properties > <java.version > 17</java.version > </properties > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-validation</artifactId > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <optional > true</optional > </dependency > <dependency > <groupId > cn.hutool</groupId > <artifactId > hutool-all</artifactId > <version > 5.8.20</version > </dependency > </dependencies > </project >
3.3.3. 项目目录结构 按照标准的分层架构组织代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 src/main/java/com/example/turnstile ├── TurnstileApplication.java # 启动类 ├── common │ ├── Result.java # 统一响应封装 │ └── exception │ ├── BizException.java # 业务异常 │ └── GlobalExceptionHandler.java # 全局异常处理 ├── config │ ├── CaptchaProperties.java # 验证码配置属性 │ └── CorsConfig.java # 跨域配置 ├── controller │ └── AuthController.java # 认证接口 ├── model │ └── dto │ └── LoginRequest.java # 登录请求 DTO └── service └── captcha ├── CaptchaVerifier.java # 验证策略接口 ├── CaptchaVerifierFactory.java # 策略工厂 └── impl ├── CloudflareCaptchaVerifier.java # Cloudflare 实现 └── GoogleCaptchaVerifier.java # Google 实现 src/main/resources └── application.yml # 配置文件
3.4. 基础设施层实现 在实现核心验证逻辑之前,我们先搭建好基础设施:统一响应、异常处理、跨域配置。这些是每个项目都需要的"脚手架"。
3.4.1. 统一响应封装 为什么需要统一响应?因为前端需要一个稳定的数据结构来解析后端返回值。如果每个接口返回格式都不一样,前端代码会变得混乱不堪。
📄 文件:src/main/java/com/example/turnstile/common/Result.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 package com.example.turnstile.common;import lombok.Data;@Data public class Result <T> { private Integer code; private String message; private T data; private Result (Integer code, String message, T data) { this .code = code; this .message = message; this .data = data; } public static <T> Result<T> success (T data) { return new Result <>(200 , "操作成功" , data); } public static <T> Result<T> success () { return success(null ); } public static <T> Result<T> fail (String message) { return new Result <>(500 , message, null ); } public static <T> Result<T> fail (Integer code, String message) { return new Result <>(code, message, null ); } }
3.4.2. 业务异常与全局处理 业务异常用于在代码任意位置抛出错误,全局异常处理器负责捕获并转换为统一响应格式。
📄 文件:src/main/java/com/example/turnstile/common/exception/BizException.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package com.example.turnstile.common.exception;import lombok.Getter;@Getter public class BizException extends RuntimeException { private final Integer code; public BizException (String message) { super (message); this .code = 500 ; } public BizException (Integer code, String message) { super (message); this .code = code; } }
📄 文件:src/main/java/com/example/turnstile/common/exception/GlobalExceptionHandler.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 package com.example.turnstile.common.exception;import com.example.turnstile.common.Result;import lombok.extern.slf4j.Slf4j;import org.springframework.validation.BindException;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public Result<?> handleBizException(BizException e) { log.warn("业务异常: code={}, message={}" , e.getCode(), e.getMessage()); return Result.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public Result<?> handleValidException(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldError() != null ? e.getBindingResult().getFieldError().getDefaultMessage() : "参数校验失败" ; log.warn("参数校验失败: {}" , message); return Result.fail(400 , message); } @ExceptionHandler(BindException.class) public Result<?> handleBindException(BindException e) { String message = e.getBindingResult().getFieldError() != null ? e.getBindingResult().getFieldError().getDefaultMessage() : "参数绑定失败" ; log.warn("参数绑定失败: {}" , message); return Result.fail(400 , message); } @ExceptionHandler(Exception.class) public Result<?> handleException(Exception e) { log.error("系统异常" , e); return Result.fail("系统异常,请稍后重试" ); } }
3.4.3. 跨域配置 前端运行在 localhost:5173,后端运行在 localhost:8080,端口不同属于跨域。我们需要在后端显式允许前端的请求。
📄 文件:src/main/java/com/example/turnstile/config/CorsConfig.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 package com.example.turnstile.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.cors.CorsConfiguration;import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import org.springframework.web.filter.CorsFilter;@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter () { CorsConfiguration config = new CorsConfiguration (); config.addAllowedOrigin("http://localhost:5173" ); config.setAllowCredentials(true ); config.addAllowedMethod("*" ); config.addAllowedHeader("*" ); config.setMaxAge(3600L ); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource (); source.registerCorsConfiguration("/**" , config); return new CorsFilter (source); } }
3.5. 配置层实现 验证码相关的配置(密钥、接口地址等)应该外置到配置文件中,而不是硬编码在代码里。这样做的好处是:
环境隔离 :开发环境用测试密钥,生产环境用真实密钥安全性 :密钥不会出现在代码仓库中灵活性 :切换服务商只需改配置,不需要改代码3.5.1. 配置文件设计 📄 文件:src/main/resources/application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 server: port: 8080 spring: application: name: turnstile-demo captcha: provider: cloudflare cloudflare: site-key: 1x00000000000000000000AA secret-key: 1x0000000000000000000000000000000AA verify-url: https://challenges.cloudflare.com/turnstile/v0/siteverify google: site-key: your-google-site-key secret-key: your-google-secret-key verify-url: https://www.google.com/recaptcha/api/siteverify score-threshold: 0.5
上面的 Cloudflare 密钥是官方提供的测试密钥,验证永远通过。生产环境必须替换为真实密钥。
3.5.2. 配置属性类 使用 @ConfigurationProperties 将配置文件映射为 Java 对象,享受类型安全和 IDE 自动补全。
📄 文件:src/main/java/com/example/turnstile/config/CaptchaProperties.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 package com.example.turnstile.config;import lombok.Data;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.stereotype.Component;@Data @Component @ConfigurationProperties(prefix = "captcha") public class CaptchaProperties { private String provider = "cloudflare" ; private CloudflareConfig cloudflare = new CloudflareConfig (); private GoogleConfig google = new GoogleConfig (); @Data public static class CloudflareConfig { private String siteKey; private String secretKey; private String verifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify" ; } @Data public static class GoogleConfig { private String siteKey; private String secretKey; private String verifyUrl = "https://www.google.com/recaptcha/api/siteverify" ; private Double scoreThreshold = 0.5 ; } }
3.6. 策略模式核心实现 基础设施和配置都准备好了,现在进入本章的核心:策略模式实现。
3.6.1. 验证策略接口 首先定义统一的验证接口。除了验证方法,我们还将 getSiteKey() 也纳入接口,这样 Controller 就不需要关心"当前是哪个服务商",直接通过工厂获取即可。
📄 文件:src/main/java/com/example/turnstile/service/captcha/CaptchaVerifier.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package com.example.turnstile.service.captcha;public interface CaptchaVerifier { String getProvider () ; String getSiteKey () ; boolean verify (String token, String remoteIp) ; }
3.6.2. Cloudflare 验证实现 实现 Cloudflare Turnstile 的验证逻辑,使用 Hutool 的 HttpUtil 简化 HTTP 请求。
📄 文件:src/main/java/com/example/turnstile/service/captcha/impl/CloudflareCaptchaVerifier.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 package com.example.turnstile.service.captcha.impl;import cn.hutool.core.util.StrUtil;import cn.hutool.http.HttpUtil;import cn.hutool.json.JSONObject;import cn.hutool.json.JSONUtil;import com.example.turnstile.config.CaptchaProperties;import com.example.turnstile.service.captcha.CaptchaVerifier;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Service;import java.util.HashMap;import java.util.Map;@Slf4j @Service @RequiredArgsConstructor public class CloudflareCaptchaVerifier implements CaptchaVerifier { private final CaptchaProperties properties; @Override public String getProvider () { return "cloudflare" ; } @Override public String getSiteKey () { return properties.getCloudflare().getSiteKey(); } @Override public boolean verify (String token, String remoteIp) { CaptchaProperties.CloudflareConfig config = properties.getCloudflare(); Map<String, Object> params = new HashMap <>(); params.put("secret" , config.getSecretKey()); params.put("response" , token); if (StrUtil.isNotBlank(remoteIp)) { params.put("remoteip" , remoteIp); } try { String body = HttpUtil.post(config.getVerifyUrl(), params, 5000 ); JSONObject result = JSONUtil.parseObj(body); boolean success = result.getBool("success" , false ); if (success) { log.info("Cloudflare 验证通过, hostname={}" , result.getStr("hostname" )); } else { log.warn("Cloudflare 验证失败, error-codes={}" , result.getJSONArray("error-codes" )); } return success; } catch (Exception e) { log.error("Cloudflare 验证异常" , e); return false ; } } }
3.6.3. Google reCAPTCHA v3 验证实现 Google 的验证逻辑稍有不同:除了检查 success 字段,还需要检查 score 评分是否达到阈值。
📄 文件:src/main/java/com/example/turnstile/service/captcha/impl/GoogleCaptchaVerifier.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 package com.example.turnstile.service.captcha.impl;import cn.hutool.core.util.StrUtil;import cn.hutool.http.HttpUtil;import cn.hutool.json.JSONObject;import cn.hutool.json.JSONUtil;import com.example.turnstile.config.CaptchaProperties;import com.example.turnstile.service.captcha.CaptchaVerifier;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Service;import java.util.HashMap;import java.util.Map;@Slf4j @Service @RequiredArgsConstructor public class GoogleCaptchaVerifier implements CaptchaVerifier { private final CaptchaProperties properties; @Override public String getProvider () { return "google" ; } @Override public String getSiteKey () { return properties.getGoogle().getSiteKey(); } @Override public boolean verify (String token, String remoteIp) { CaptchaProperties.GoogleConfig config = properties.getGoogle(); Map<String, Object> params = new HashMap <>(); params.put("secret" , config.getSecretKey()); params.put("response" , token); if (StrUtil.isNotBlank(remoteIp)) { params.put("remoteip" , remoteIp); } try { String body = HttpUtil.post(config.getVerifyUrl(), params, 5000 ); JSONObject result = JSONUtil.parseObj(body); boolean success = result.getBool("success" , false ); Double score = result.getDouble("score" ); if (success && score != null && score >= config.getScoreThreshold()) { log.info("Google reCAPTCHA 验证通过, score={}, action={}" , score, result.getStr("action" )); return true ; } log.warn("Google reCAPTCHA 验证失败, success={}, score={}, threshold={}" , success, score, config.getScoreThreshold()); return false ; } catch (Exception e) { log.error("Google reCAPTCHA 验证异常" , e); return false ; } } }
3.6.4. 策略工厂 策略工厂负责根据配置文件中的 provider 值,动态选择对应的验证实现。利用 Spring 的依赖注入,将所有 CaptchaVerifier 实现类自动收集并转换为 Map。
📄 文件:src/main/java/com/example/turnstile/service/captcha/CaptchaVerifierFactory.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 package com.example.turnstile.service.captcha;import com.example.turnstile.common.exception.BizException;import com.example.turnstile.config.CaptchaProperties;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Component;import java.util.List;import java.util.Map;import java.util.function.Function;import java.util.stream.Collectors;@Slf4j @Component public class CaptchaVerifierFactory { private final Map<String, CaptchaVerifier> verifierMap; private final CaptchaProperties properties; public CaptchaVerifierFactory (List<CaptchaVerifier> verifiers, CaptchaProperties properties) { this .properties = properties; this .verifierMap = verifiers.stream() .collect(Collectors.toMap(CaptchaVerifier::getProvider, Function.identity())); log.info("验证策略工厂初始化完成, 已加载服务商: {}" , verifierMap.keySet()); } public CaptchaVerifier getVerifier () { String provider = properties.getProvider(); CaptchaVerifier verifier = verifierMap.get(provider); if (verifier == null ) { log.error("未找到验证服务商: {}, 可用: {}" , provider, verifierMap.keySet()); throw new BizException ("未知的验证服务商: " + provider); } return verifier; } }
3.7. Controller 层实现 所有核心组件都已就绪,现在实现对外暴露的 REST 接口。由于 getSiteKey() 已经收口到策略接口中,Controller 变得非常简洁。
3.7.1. 登录请求 DTO 📄 文件:src/main/java/com/example/turnstile/model/dto/LoginRequest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.turnstile.model.dto;import jakarta.validation.constraints.NotBlank;import lombok.Data;@Data public class LoginRequest { @NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") private String password; @NotBlank(message = "验证码 Token 不能为空") private String captchaToken; }
3.7.2. 认证接口 使用 Hutool 的 MapUtil 构建响应数据,使用 ServletUtil 获取客户端 IP,代码更加简洁。
📄 文件:src/main/java/com/example/turnstile/controller/AuthController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 package com.example.turnstile.controller;import cn.hutool.core.map.MapUtil;import cn.hutool.extra.servlet.JakartaServletUtil;import com.example.turnstile.common.Result;import com.example.turnstile.common.exception.BizException;import com.example.turnstile.model.dto.LoginRequest;import com.example.turnstile.service.captcha.CaptchaVerifier;import com.example.turnstile.service.captcha.CaptchaVerifierFactory;import jakarta.servlet.http.HttpServletRequest;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.*;import java.util.Map;@Slf4j @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { private final CaptchaVerifierFactory verifierFactory; @GetMapping("/captcha/config") public Result<Map<String, String>> getCaptchaConfig () { CaptchaVerifier verifier = verifierFactory.getVerifier(); Map<String, String> config = MapUtil.builder("provider" , verifier.getProvider()) .put("siteKey" , verifier.getSiteKey()) .build(); log.debug("返回验证码配置: provider={}" , verifier.getProvider()); return Result.success(config); } @PostMapping("/login") public Result<String> login (@Validated @RequestBody LoginRequest request, HttpServletRequest httpRequest) { String remoteIp = JakartaServletUtil.getClientIP(httpRequest); log.info("收到登录请求, 用户: {}, IP: {}" , request.getUsername(), remoteIp); CaptchaVerifier verifier = verifierFactory.getVerifier(); boolean verified = verifier.verify(request.getCaptchaToken(), remoteIp); if (!verified) { log.warn("人机验证失败, 用户: {}" , request.getUsername()); throw new BizException (4001 , "人机验证失败,请重试" ); } log.info("用户 {} 登录成功" , request.getUsername()); return Result.success("登录成功" ); } }
3.8. 启动验证 代码编写完成,启动项目验证是否正常工作。
启动后端服务
启动成功后,控制台会输出:
1 2 验证策略工厂初始化完成, 已加载服务商: [cloudflare, google] Started TurnstileApplication in x.xxx seconds
测试获取配置接口
1 curl http://localhost:8080/auth/captcha/config
预期响应:
1 2 3 4 5 6 7 8 { "code" : 200 , "message" : "操作成功" , "data" : { "provider" : "cloudflare" , "siteKey" : "1x00000000000000000000AA" } }
测试登录接口
1 2 3 4 5 6 7 curl -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{ "username": "admin", "password": "123456", "captchaToken": "test-token" }'
由于使用的是 Cloudflare 测试密钥,任何 Token 都会验证通过:
1 2 3 4 5 { "code" : 200 , "message" : "操作成功" , "data" : "登录成功" }
3.8.1. 本章小结 本章我们完成了后端的核心架构设计和代码实现。通过策略模式,系统具备了灵活切换验证服务商的能力。
架构回顾
graph TB
A[AuthController] --> B[CaptchaVerifierFactory]
B --> C{根据配置选择}
C -->|cloudflare| D[CloudflareCaptchaVerifier]
C -->|google| E[GoogleCaptchaVerifier]
D --> F[Cloudflare API]
E --> G[Google API]
核心组件职责
组件 职责 CaptchaVerifier统一验证接口,包含 getProvider()、getSiteKey()、verify() CloudflareCaptchaVerifierCloudflare 验证实现 GoogleCaptchaVerifierGoogle 验证实现 CaptchaVerifierFactory策略工厂,动态选择实现 AuthController对外暴露 REST 接口,零 if-else
切换服务商的方式
只需修改 application.yml 中的一行配置:
1 2 captcha: provider: google
无需修改任何 Java 代码,重启服务即可生效。
3.8. 启动验证 代码编写完成,启动项目验证是否正常工作。
启动后端服务
启动成功后,控制台会输出:
1 2 验证策略工厂初始化完成, 已加载服务商: [cloudflare, google] Started TurnstileApplication in x.xxx seconds
测试获取配置接口
1 curl http://localhost:8080/auth/captcha/config
预期响应:
1 2 3 4 5 6 7 8 { "code" : 200 , "message" : "操作成功" , "data" : { "provider" : "cloudflare" , "siteKey" : "1x00000000000000000000AA" } }
测试登录接口(使用测试 Token)
1 2 3 4 5 6 7 curl -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{ "username": "admin", "password": "123456", "captchaToken": "test-token" }'
由于使用的是 Cloudflare 测试密钥,任何 Token 都会验证通过,预期响应:
1 2 3 4 5 { "code" : 200 , "message" : "操作成功" , "data" : "登录成功" }
3.8.1. 本章小结 本章我们完成了后端的核心架构设计和代码实现。通过策略模式,系统具备了灵活切换验证服务商的能力。
架构回顾
核心组件职责
组件 职责 CaptchaVerifier统一验证接口 CloudflareCaptchaVerifierCloudflare 验证实现 GoogleCaptchaVerifierGoogle 验证实现 CaptchaVerifierFactory策略工厂,动态选择实现 CaptchaProperties配置属性映射 AuthController对外暴露 REST 接口
切换服务商的方式
只需修改 application.yml 中的一行配置:
1 2 captcha: provider: google
无需修改任何 Java 代码,重启服务即可生效。
第四章. 前端实现:Vue 3 组件封装 在上一章中,我们完成了后端的策略模式架构,系统已经能够验证 Cloudflare Turnstile 的 Token。但 Token 从哪里来?答案是前端。
本章我们将搭建 Vue 3 前端项目,封装 Turnstile 组件,并实现完整的登录流程。本章学习路径
阶段 内容 认知里程碑 阶段一 项目初始化 掌握 Vite + Vue 3 + TypeScript 项目搭建 阶段二 Turnstile 组件封装 理解 SDK 加载机制和组件生命周期管理 阶段三 登录流程实现 串联前后端,完成完整业务闭环
4.1. 项目初始化 4.1.1. 创建项目 使用 pnpm 和 Vite 创建 Vue 3 + TypeScript 项目:
1 2 3 4 5 6 7 8 9 10 11 pnpm create vite turnstile-frontend --template vue-ts cd turnstile-frontendpnpm install pnpm add axios
验证项目是否正常
浏览器访问 http://localhost:5173,看到 Vite + Vue 欢迎页面即表示成功。
4.1.2. 项目目录结构 按照功能模块组织代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 src ├── api │ ├── index.ts # Axios 实例配置 │ ├── auth.ts # 认证相关 API │ └── types.ts # 类型定义 ├── components │ └── TurnstileWidget.vue # Turnstile 验证码组件 ├── views │ └── Login.vue # 登录页面 ├── App.vue # 根组件 ├── main.ts # 入口文件 ├── style.css # 全局样式 └── vite-env.d.ts # 类型声明
4.1.3. 清理默认文件 删除 Vite 创建的示例文件:
1 2 rm src/components/HelloWorld.vuerm -rf src/assets
4.2. API 请求层封装 在对接验证码组件之前,先封装好与后端通信的 API 层。
4.2.1. 类型定义 定义后端接口的请求和响应类型,确保类型安全。
📄 文件:src/api/types.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 export interface Result <T> { code : number message : string data : T } export interface CaptchaConfig { provider : string siteKey : string } export interface LoginParams { username : string password : string captchaToken : string }
4.2.2. Axios 实例配置 创建 Axios 实例,配置基础地址和响应拦截器。拦截器的作用是统一解包 Result<T> 结构,让业务代码直接拿到 data。
📄 文件:src/api/index.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import axios from 'axios' import type { Result } from './types' const request = axios.create ({ baseURL : 'http://localhost:8080' , timeout : 10000 }) request.interceptors .response .use ( (response ) => { const result = response.data as Result <unknown > if (result.code === 200 ) { return result.data as any } return Promise .reject (new Error (result.message || '请求失败' )) }, (error ) => { const message = error.response ?.data ?.message || error.message || '网络异常' return Promise .reject (new Error (message)) } ) export default request
4.2.3. 认证 API 封装 封装与认证相关的接口调用。
📄 文件:src/api/auth.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import request from './index' import type { CaptchaConfig , LoginParams } from './types' export function getCaptchaConfig ( ): Promise <CaptchaConfig > { return request.get ('/auth/captcha/config' ) } export function login (params : LoginParams ): Promise <string > { return request.post ('/auth/login' , params) }
4.3. Turnstile 组件封装 这是本章的核心:将 Cloudflare Turnstile SDK 封装为可复用的 Vue 组件。
4.3.1. 组件设计思路 封装组件需要解决以下问题:
SDK 加载 :动态加载 Cloudflare 的 JavaScript SDK组件渲染 :在 SDK 加载完成后渲染验证码 Widget事件通信 :将验证结果(Token)通过事件传递给父组件生命周期管理 :组件销毁时清理 Widget,避免内存泄漏
4.3.2. TypeScript 类型声明 Cloudflare Turnstile SDK 会在 window 对象上挂载 turnstile 对象,我们需要为其添加类型声明。
📄 文件:src/vite-env.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 interface TurnstileOptions { sitekey : string callback ?: (token : string ) => void 'error-callback' ?: () => void 'expired-callback' ?: () => void theme ?: 'light' | 'dark' | 'auto' language ?: string } interface TurnstileInstance { render : (element : HTMLElement , options : TurnstileOptions ) => string reset : (widgetId : string ) => void remove : (widgetId : string ) => void getResponse : (widgetId : string ) => string | undefined } interface Window { turnstile ?: TurnstileInstance }
4.3.3. 组件实现 📄 文件:src/components/TurnstileWidget.vue
封装重点 :
自动加载 :组件挂载时自动检查并注入 Cloudflare 脚本。防抖动 :预留了高度,避免脚本加载时页面跳动。对外接口 :通过 defineExpose 暴露 reset 方法,方便父组件在登录失败时调用。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 <template> <div ref="widgetRef" class="turnstile-container"></div> </template> <script setup lang="ts"> import { ref, onMounted, onUnmounted } from "vue"; const props = defineProps<{ siteKey: string }>(); const emit = defineEmits(["success", "error"]); const widgetRef = ref<HTMLElement>(); const widgetId = ref<string>(); // 渲染核心逻辑 const renderWidget = () => { if (!widgetRef.value || !window.turnstile) return; // 渲染并保存 ID (用于销毁和重置) widgetId.value = window.turnstile.render(widgetRef.value, { sitekey: props.siteKey, callback: (token: string) => emit("success", token), "error-callback": () => emit("error"), }); }; // 暴露给父组件的方法 const reset = () => { if (widgetId.value && window.turnstile) { window.turnstile.reset(widgetId.value); } }; onMounted(() => { // 1. 如果脚本已存在,直接渲染 if (window.turnstile) { renderWidget(); return; } // 2. 如果脚本不存在,动态注入(防止重复注入) if (!document.getElementById("cf-turnstile")) { const script = document.createElement("script"); script.id = "cf-turnstile"; script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; script.async = true; script.onload = renderWidget; document.head.appendChild(script); } else { // 极少数情况:脚本标签有了但还没加载完,轮询等待一下即可 const timer = setInterval(() => { if (window.turnstile) { clearInterval(timer); renderWidget(); } }, 100); } }); onUnmounted(() => { if (widgetId.value && window.turnstile) { window.turnstile.remove(widgetId.value); } }) defineExpose({ reset }); </script> <style scoped> .turnstile-container { min-height: 65px; /* 占位高度,防止布局抖动 */ display: flex; justify-content: center; } </style>
4.4. 登录页面实现 📄 文件:src/views/Login.vue
实现逻辑 :
表单与验证码解耦 :验证码组件只负责给 Token,Login 页面负责拿着 Token 去请求。错误处理闭环 :如果后端返回“密码错误”,必须调用 widgetRef.value.reset() 重置验证码,强制用户重新验证。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 <template> <div class="login-container"> <div class="login-card"> <h2>系统登录</h2> <form @submit.prevent="handleLogin"> <div class="form-item"> <input v-model="form.username" type="text" placeholder="请输入用户名" required /> </div> <div class="form-item"> <input v-model="form.password" type="password" placeholder="请输入密码" required /> </div> <TurnstileWidget ref="turnstileRef" :site-key="siteKey" @success="(val) => (form.captchaToken = val)" /> <button type="submit" :disabled="!form.captchaToken || loading"> {{ loading ? "登录中..." : "立即登录" }} </button> </form> </div> </div> </template> <script setup lang="ts"> import { ref, reactive } from "vue"; import TurnstileWidget from "../components/TurnstileWidget.vue"; import { login } from "../api/auth"; const siteKey = "0x4AAAAAAAxxxxxxxxxx"; // 替换你的 SiteKey const turnstileRef = ref(); //以此调用子组件方法 const loading = ref(false); const form = reactive({ username: "", password: "", captchaToken: "", }); const handleLogin = async () => { if (!form.captchaToken) return alert("请先完成人机验证"); loading.value = true; try { await login(form); alert("登录成功!跳转首页..."); } catch (error: any) { alert(error.message || "登录失败"); // 关键点:登录失败后,Token 失效,必须重置验证码 form.captchaToken = ""; turnstileRef.value?.reset(); } finally { loading.value = false; } }; </script> <style scoped> .login-container { display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f0f2f5; } .login-card { width: 360px; padding: 30px; background: white; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); text-align: center; } .form-item { margin-bottom: 15px; } input { width: 100%; padding: 10px; border: 1px solid #d9d9d9; border-radius: 4px; box-sizing: border-box; } button { width: 100%; padding: 10px; background-color: #1890ff; color: white; border: none; border-radius: 4px; cursor: pointer; margin-top: 10px; } button:disabled { background-color: #bfbfbf; cursor: not-allowed; } </style>
4.5. 入口文件配置 保持最简配置即可,无需变动。
4.5.1. 根组件 📄 文件:src/App.vue
1 2 3 4 5 6 7 8 9 10 11 12 <template> <Login /> </template> <script setup lang="ts"> import Login from './views/Login.vue' </script> <style> body { margin: 0; font-family: sans-serif; } </style>
4.5.4. 本章小结 本章我们完成了前端项目的搭建和 Turnstile 组件的封装。
核心组件职责
组件 职责 TurnstileWidget.vue封装 Cloudflare SDK,管理加载、渲染、事件回调 Login.vue登录页面,集成表单和验证码组件 api/auth.ts封装后端接口调用
完整业务流程
第五章. 联调测试与常见问题 代码编写完成,现在启动前后端服务进行联调测试。
5.1. 启动服务 启动后端
在后端项目目录下执行:
启动成功后会看到:
1 2 验证策略工厂初始化完成, 已加载服务商: [cloudflare, google] Started TurnstileApplication in x.xxx seconds
启动前端
在前端项目目录下执行:
启动成功后访问 http://localhost:5173。
5.2. 功能验证 步骤 1:验证码加载
页面加载后,应该能看到 Cloudflare Turnstile 的验证组件。由于使用的是测试密钥,验证会自动通过(或只需简单点击)。
步骤 2:完成登录
输入任意用户名和密码,点击登录按钮,应该能看到"登录成功"的提示。
步骤 3:查看后端日志
后端控制台应该输出:
1 2 3 收到登录请求, 用户: admin, IP: 127.0.0.1 Cloudflare 验证通过, hostname=localhost 用户 admin 登录成功
5.3. 常见问题排查 联调问题排查
2025-01-15 14:00
teacher
检查浏览器控制台是否有网络错误。Cloudflare 的 SDK 地址在国内偶尔会慢,等待几秒或刷新页面。也可以检查 ‘/auth/captcha/config’ 接口是否正常返回 siteKey。
teacher
检查 application.yml 中的 secret-key 是否正确复制,注意不要有多余的空格。测试密钥是 ‘1x0000000000000000000000000000000AA’,共 32 个字符。
teacher
Token 有时效性,获取后需要尽快使用。检查前端是否在验证成功后立即提交,而不是等待太久。另外确认后端收到的 captchaToken 不是空字符串。
问题速查表
现象 可能原因 解决方案 验证码不显示 SDK 加载失败 检查网络,确认能访问 challenges.cloudflare.com 验证码不显示 siteKey 未获取 检查 /auth/captcha/config 接口是否正常返回 验证始终失败 secretKey 错误 检查后端配置文件中的密钥 登录提示验证失败 Token 过期 验证成功后尽快提交登录请求 CORS 错误 跨域未配置 检查后端 CorsConfig 是否包含 http://localhost:5173
5.4. 生产环境注意事项 上线前必须完成以下配置,否则验证码将无法正常工作。
1. 替换测试密钥
将 application.yml 中的测试密钥替换为 Cloudflare 控制台获取的真实密钥:
1 2 3 4 captcha: cloudflare: site-key: 你的真实 Site Key secret-key: 你的真实 Secret Key
2. 添加生产域名
在 Cloudflare Turnstile 控制台,将你的生产域名添加到站点配置中(如 example.com)。
3. 更新 CORS 配置
修改后端 CorsConfig,将前端生产地址加入白名单:
1 config.addAllowedOrigin("https://your-production-domain.com" );
4. 前端 API 地址配置
生产环境中,前端的 baseURL 需要改为后端的生产地址。建议使用环境变量:
1 2 3 4 const request = axios.create ({ baseURL : import .meta .env .VITE_API_BASE_URL || 'http://localhost:8080' , timeout : 10000 })
5.4.1. 本章小结 检查项 开发环境 生产环境 Site Key 测试密钥 真实密钥 Secret Key 测试密钥 真实密钥 域名配置 localhost 生产域名 CORS localhost:5173 生产前端地址 API 地址 localhost:8080 生产后端地址