
1. 项目背景与整体设计思路拆解1.1 行为验证码到底解决了什么问题先说说我为什么会盯上 AJ-Captcha 这个项目。之前维护过一个面向 C 端的业务系统登录、注册、找回密码、下单这些关键节点每天都有人在尝试撞库、刷接口、薅羊毛。最开始用的是传统图形验证码就是那种让你输入四位扭曲字母的方案结果有两个问题一是用户骂声一片经常有人输错三次然后放弃操作二是机器识别率并不低市面上的 OCR 打码平台对这种简单干扰图基本是秒破。后来换成了点选汉字、拖拽拼图的商业验证码体验好了不少但每年授权费不低而且数据全部经过第三方服务要想私有化部署还得加钱。AJ-Captcha 的价值恰恰在这两点上做了平衡。它是一个开源的 Java 行为验证码组件核心思路不是让用户认字、识图而是通过用户在页面上的自然行为轨迹拖拽滑块、点击文字来区分真人和脚本后端再结合坐标偏差、轨迹拟合度、时间戳等一系列特征做可信度评分。它把“验证码”从“一道题”变成了一次交互动作用户体验大幅提升而且完全开源、可私有化没有外部依赖这才是我愿意花时间研究并写这篇文档的根本原因。1.2 为什么选 AJ-Captcha 而不是其他方案我接触过不少同类产品简单分个类方案类型代表产品优点痛点线上商业验证码极验、腾讯防水墙安全能力强、更新快收费、私有化成本高、数据出域自研行为验证团队自己写完全可控、贴合业务开发周期长、对抗样本难积累开源行为验证AJ-Captcha免费、可私有化、上手快需要自己维护素材和策略当时团队里也有声音说要自研一套滑块验证理由是业务方想要定制背景图和滑块样式。我评估了一下自研最麻烦的不是前端拖拽交互而是后端那套轨迹校验算法——怎么判断一次拖拽是真人还是 Selenium、Playwright 模拟出来的这里面的特征工程和阈值调优没有小半年做不出效果而 AJ-Captcha 已经把坐标比对、轨迹压缩、AES 加密凭证、二次校验状态机都实现了我们只需要做集成工作。另外它的后端主语言是 Java和我们的 Spring Boot 技术栈无缝衔接前端也有 Vue、React、原生 JS 等各类封装整体的接入成本很低这是我们最终选型的关键理由。注意AJ-Captcha 官方定位是一个通用型行为验证组件不要指望它能挡住所有黑产攻击。对于普通爬虫和批量脚本它足够优秀但对于有专门人机对抗团队的高级黑产任何验证码都是提高成本的手段而不是绝对壁垒。想清楚这层定位后续的配置思路才不会跑偏。2. 快速上手把后端接口跑起来2.1 后端 Maven 依赖与基础配置后端接入主要用一个 Spring Boot Starter。以当前常见版本为例在pom.xml里加上dependency groupIdcom.anji-plus/groupId artifactIdspring-boot-starter-captcha/artifactId version1.3.0/version /dependency引入依赖后在application.yml里做基础配置aj: captcha: type: blockPuzzle # 验证码类型blockPuzzle滑块 / clickWord点击文字 water-mark: myapp # 水印文字改成自己的品牌名 slip-offset: 5 # 滑块偏移量容错像素越大越容易通过 aes-status: true # 是否开启AES加密建议生产环境开启 aes-key: 0123456789abcdef # AES密钥必须16位 interfere-num: 0 # 干扰项数量滑块场景一般设为0 cache-type: local # 缓存类型local本地内存 / redis这里有一个很容易踩的坑aes-key必须是 16 位字符串否则启动时会直接抛异常。很多新手第一次配置就卡在这个地方复制了官方默认的 16 位密钥没问题一旦改成自己的短密码就启动失败所以我在团队规范里明确要求所有环境统一用 16 位随机字符串并且通过环境变量注入不要提交到 Git 仓库。配置完成后Spring Boot 启动时会自动注册一个CaptchaService的 Bean这个 Bean 已经实现了获取验证码和校验验证码的核心逻辑我们不需要动它的源码。2.2 暴露 get / check 两个核心接口接口层其实非常简单按照官方推荐的方式写两个接口就行。第一个是下发验证码素材的get接口前端通过它拿到背景图、拼图坐标密文和本次会话的 token。RestController public class CaptchaController { Autowired private CaptchaService captchaService; GetMapping(/captcha/get) public ResultVO captchaGet(RequestParam(captchaType) String captchaType) { CaptchaVO captchaVO new CaptchaVO(); captchaVO.setCaptchaType(captchaType); return captchaService.get(captchaVO); } PostMapping(/captcha/check) public ResultVO captchaCheck(RequestBody CaptchaVO captchaVO) { return captchaService.check(captchaVO); } }返回的ResultVO是组件自带的统一响应结构一般长这样{ code: 0000, msg: success, data: { originalImageBase64: 背景图base64, jigsawImageBase64: 滑块图base64, token: 本次会话凭证, secretKey: 前端加密用密钥 } }originalImageBase64和jigsawImageBase64都是 base64 编码的图片数据前端直接拿来放在img标签里就能渲染。token是后端在缓存里存的会话标识后续check时用来找到刚才那次验证请求的原始坐标。secretKey是用来加密前端回传坐标的 AES 密钥。2.3 前端 Vue 快速接入前端我用 Vue 项目来举例。AJ-Captcha 官方提供了一套面向 Vue 的组件库安装方式npm install aj-captcha/vue在页面中引入组件拿滑块验证举例template div slide-verify :captcha-typeblockPuzzle successonSuccess erroronError / /div /template script import { slideVerify } from aj-captcha/vue; export default { components: { slideVerify }, methods: { onSuccess(data) { // data.validate 就是本次验证结果的凭证 // data.captchaVerification data.token --- data.validate // 把这个拼接串提交给后端做二次校验 }, onError() { console.log(验证失败请重试); }, }, }; /script这里注意onSuccess返回的data里有一个字段叫captchaVerification它是由token和validate拼接而成的中间用三条短横线分隔。这个拼接串就是我们业务后端后续执行二次校验的关键参数必须在用户提交表单时一起传过来比如登录、注册、支付确认这类动作都要带上。如果项目用的是 React官方也有对应的组件封装思路完全一致安装组件、渲染验证码、从成功回调里取凭证、提交业务接口时带上凭证。3. 一次验证的完整流程拆解3.1 get 阶段凭证下发与素材组装很多同学以为验证码就是前端画个图、后端比对一下坐标实际流程比这复杂一些。我把一次完整的 AJ-Captcha 验证拆成四步方便你理解后面每个配置项的作用。第一步是get即验证码素材下发。前端打开页面后会向后端/captcha/get发起请求请求里带着captchaType参数用来告诉后端这次是要滑块验证还是点选验证。后端收到请求后先从本地素材库中随机选择一张背景图和一张拼图然后把拼图要放置的目标位置也就是正确答案的坐标存在缓存里最后把背景图、拼图、token、secretKey 作为响应返回。这里有个关键点正确答案坐标不在响应里直接暴露而是存到了后端缓存中响应里只有 base64 图片数据和 token。前端拿到的图片上的拼图缺口位置其实是后端通过某种方式埋进图片里的视觉信息用户拖拽拼图到缺口后前端把最终坐标加密回传后端拿这个坐标和缓存里的原始坐标做比对偏差在容错范围内就算通过。这种设计确保了即使攻击者拿到了接口响应也无法直接从 JSON 里读出正确答案。3.2 前端交互轨迹采集与 AES 加密第二步是用户交互。用户在页面上按下鼠标或触摸屏开始拖动拼图。前端在这一过程中会持续监听鼠标或触控事件记录下每一帧的 x 坐标、y 坐标和时间戳组成一条完整的轨迹数据。同时前端还会计算拼图最终停留的位置通常取的是拼图中心点的相对坐标。轨迹采集不是简单记个点AJ-Captcha 的前端逻辑里会对轨迹做抽稀和编码避免数据量过大。采集完成后前端会用secretKey对坐标和轨迹数据进行 AES 加密生成一个加密后的字符串在check请求中传给后端。这个设计很聪明因为真实人的轨迹是有加速度变化、有抖动、有停顿的而脚本模拟出来的轨迹通常是线性匀速或者生硬的折线后端拿到密文解密后就能分析这些特征。3.3 check 阶段坐标比对与轨迹校验第三步是后端校验。用户松手后前端把加密后的轨迹数据和坐标信息打包连同 token 一起发送到/captcha/check。后端根据 token 从缓存中取出本次会话的原始坐标用之前保存的 secretKey 解密前端回传的数据然后做两件事坐标比对计算前端拼图停留位置与原始正确位置在 x 轴上的绝对差值差值小于slip-offset配置的容错像素就算通过。注意滑块验证主要关心 x 轴偏移y 轴通常允许一定范围内的像素误差因为素材生成时拼图的 y 坐标是固定的。轨迹校验分析轨迹数据的连续性、时间间隔、加速度曲线、是否存在异常突变。如果轨迹只有两个点、直接匀速滑过去、或者时间跨度异常短都会被判定为机械行为直接拉低信任分。校验通过后后端会生成一个validate凭证并返回给前端。这个凭证是一次性的有效期内只能成功消费一次这是第四步二次校验的基础。同时后端的轨迹评分并不是只有通过与不通过两种结果它内部有一套动态阈值逻辑综合判断异常程度最终决定放行还是拒绝。3.4 validate 二次校验防重放的关键第四步是二次校验这一步是很多人最容易忽略的。前端拿到validate后虽然验证码组件会回调success但这只能证明“浏览器端验证动作完成”并不能证明“业务请求来自同一个用户”。举个例子攻击者完全可以绕过前端页面直接调你的登录接口。他可以通过正常流程拿到一次有效的validate然后把这个凭证批量重放到大量登录请求中这就是重放攻击。为了防住这种情况业务后端在收到用户提交的登录、注册、支付请求时必须携带captchaVerification也就是 token validate然后调用一次校验接口确认这个validate真实存在、未被使用过并且对应的 token 是当前会话下发的。在 AJ-Captcha 的后端设计中validate消费过一次之后就会从缓存移除所以第二次使用同一个validate会直接失败。实际上官方强烈建议你做一个独立的二次校验接口把“前端验证”和“业务提交”之间的链路完全打通。PostMapping(/login) public ResultVO login(RequestBody LoginRequest dto) { // 先做验证码二次校验 CaptchaVO check new CaptchaVO(); check.setCaptchaType(blockPuzzle); check.setCaptchaVerification(dto.getCaptchaVerification()); ResultVO result captchaService.check(check); if (!0000.equals(result.getCode())) { return ResultVO.error(验证码校验失败); } // 二次校验通过后再走用户名密码登录逻辑 // ... }实际项目里我还会在二次校验时把result.getCode()的结果和一些业务动作比如记录尝试次数、封禁异常 IP联动这样即使有人故意拿错误凭证做探测系统也能积累攻击者画像后续在 Nginx 或网关层直接拦截。4. 关键配置、参数调优与缓存选型4.1 配置文件核心参数对症解读AJ-Captcha 的参数不多但每一项都直接影响验证体验和安全性。我把常用的配置项列出来逐一说明。aj.captcha.type选择验证码类型可选blockPuzzle和clickWord。滑块对用户更友好点选文字的安全性更高因为除了坐标外还要判断点击顺序。如果业务面向中老年用户建议默认滑块如果业务有较强的安全诉求比如支付密码找回可以考虑点选。实际项目里我一般把类型做成可配置项不同风控等级的业务使用不同验证码强度。aj.captcha.slip-offset滑块偏移容错像素默认 5。这个值调大比如调到 10用户更容易拼准安全性下降调小比如调到 2安全性上去了但用户可能反复拼不准导致流失。我建议先用默认 5 观察线上数据如果验证失败率超过 5%再适当放宽到 7 或 8。注意这里说的失败率是三方的不要拿开发同学在自己电脑上的测试数据来参考。aj.captcha.cache-type验证码会话的缓存类型支持local和redis。单机部署、并发不高的场景用local就行零依赖多节点部署或者后面要扩展时直接切到redis保证所有实例共享同一份验证码会话缓存。我把这块单独拿出来在下一小节细讲因为它和分布式架构强相关踩坑的概率也比较高。4.2 缓存选型local 还是 rediscache-type这个参数看起来只是改一个单词实际上决定了验证码会话是否会被多个服务实例共享。先看 local 模式每个服务实例在自己的 JVM 内存里维护缓存token 和原始坐标存在一起。单实例没问题但一旦你做了负载均衡用户第一次请求打到了 A 实例拿回了 token第二次check请求被负载均衡转发到了 B 实例而 B 实例的 local 缓存里没有这个 token就会直接校验失败表现就是验证码动不动“过期”或者“校验失败”。解决思路有两个一是配置负载均衡策略为 IP Hash让同一个用户的请求固定打到同一台实例这是最快速、但上限很低的做法二是把缓存切到 Redis所有实例都从同一个 Redis 里读写验证码数据这才是正解。切换 Redis 缓存需要自己实现一个缓存 Service。AJ-Captcha 留了一个接口叫CaptchaCacheService默认的DefaultCaptchaCacheServiceImpl是基于本地内存的我们需要新建一个基于 Redis 的实现。Service public class RedisCaptchaCacheServiceImpl implements CaptchaCacheService { Autowired private StringRedisTemplate redisTemplate; Override public void set(String key, String value, long expiresInSeconds) { redisTemplate.opsForValue().set(key, value, expiresInSeconds, TimeUnit.SECONDS); } Override public boolean exists(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } Override public void delete(String key) { redisTemplate.delete(key); } Override public String get(String key) { return redisTemplate.opsForValue().get(key); } Override public String type() { return redis; } }然后配置文件里把cache-type改成redis再设置cache-redis相关连接参数。aj: captcha: cache-type: redis cache-redis: host: 127.0.0.1 port: 6379 password: xxx database: 0提示如果你用了 Spring Boot 2.x 以上版本StringRedisTemplate是自带 Bean不需要额外配置。切换完成后建议用两个不同端口各启动一个实例然后用浏览器反复刷新验证确认验证码不会再因为实例切换而失效。4.3 滑块素材与干扰项设置AJ-Captcha 默认自带一组滑块背景图和拼图素材存放在resources目录下。素材质量直接决定用户拼图的心智负担。官方那套素材走的是清晰简洁路线面向普通业务没问题但如果你想要品牌感或者想通过更换素材提高机器识别难度可以自己替换。替换素材时注意背景图建议使用 310px × 155px 左右的大图拼图建议用带透明度通道的 PNG推荐尺寸 110px × 110px其中实际滑块主体部分要留好缺口位置。素材放进去后在配置里指定资源路径重启就能生效。我见过有人把背景图直接改成纯色块结果滑块缺口完全看不出来用户根本不知道往哪儿拖这种属于改素材改出事故的典型案例。interfere-num是干扰项参数默认 0。这个参数对点击验证码clickWord有用可以为页面增加一些文字干扰项提高机器识别的难度。滑块验证码一般用不到干扰项因为拖拽本身已经算一种交互门槛加上干扰项反而影响拖拽体验。4.4 水印与 AES 加密的取舍water-mark参数会在验证码图片背景上画一层半透明文字水印这是防止验证码截图被直接复用的一种简单手段也能起到品牌露出效果。实际线上环境建议设置成自己的产品或公司名称成本几乎为零。aes-status和aes-key这对配置最好保持开启。前端回传的数据如果明文传输攻击者可以截取接口报文直接看到拼图坐标后端再怎么校验轨迹都没意义。开启 AES 加密后前端把加密数据作为参数传到check接口后端再用事先约定的密钥解密能挡住相当大比例的被动抓包。密钥记得定期轮换长度固定 16 位轮换时要保证前端和后端同时更新不要出现前后端密钥不一致的幺蛾子。5. 常见问题与排查技巧实录5.1 验证码图片一直加载不出来这个问题在接入初期出现概率最高。常规排查我先看浏览器 Network 里/captcha/get请求有没有正常返回。如果接口返回 200 但 data 为空大概率是后端缓存配置不对如果接口直接 404先确认是不是项目的权限拦截器或安全框架把/captcha/**路径给拦截了。很多团队都集成过 Spring Security、Shiro 或者 Sa-Token 这类权限框架默认会拦截所有请求。AJ-Captcha 的两个接口必须加到白名单里。我曾遇到一个项目get接口能调通check接口却一直被拦截排查半天发现是网关层把 POST 请求统一做了鉴权预处理。处理方式就是在 Nginx 或网关配置中给/captcha/check单独放行同时注意不是放行所有接口避免安全策略失效。5.2 check 接口返回失败但前端明明拖得很准这种情况通常不是用户操作问题而是后端的坐标比对或轨迹校验没通过。先从三个方向排查。第一确认slip-offset配置是否生效。有的人在application.yml里改了slip-offset: 10但实际代码里又自定义了一个CaptchaService覆盖了默认配置导致参数根本没被读取。第二确认aes-key和前端使用的密钥一致。如果前后端密钥不匹配后端解出来的坐标是乱码比对肯定失败。第三确认网络环境是否存在代理、CDN 缓存干扰。有一次线上频繁报验证失败最后发现是 CDN 把/captcha/get的响应缓存了所有用户拿到的都是同一个 token 和同一张背景图后端的缓存里根本没有这些 token自然全部校验失败。解决方式是给/captcha/**路径配置 CDN 不缓存。还有一个容易被忽略的点如果你在多级网关后面做了响应压缩或者 base64 图片传输变更可能导致前端解码出的图片不完整用户根本看不到完整的拼图缺口只能随手乱拖。这种情况后端不会报错但前端成功率会直接掉到谷底。检查方法很简单直接看图片 base64 的长度和官方默认一不一致。5.3 分布式环境下 token 时而生效时而不生效前面已经说过local 缓存会导致多实例部署时 token 不共享。如果你已经切了 redis还出现 token 丢失就有另一个可能不同环境之间的 redis database 没有隔离导致两个环境共用同一个 Redis验证码数据互相覆盖。还有一次我在排查时发现问题出在 Redis 的 Key 过期时间上。默认验证码会话过期时间是 120 秒如果前端页面加载超过 2 分钟用户才拖完滑块token 已经过期了check 就会失败。这个在设计上是合理的但对一些需要用户仔细阅读协议、填写大量表单的长流程页面可能触发“验证码突然失效”的体验问题。我的建议是把验证码尽量放在表单提交前一步让用户完成不要在页面上长期驻留。5.4 validate 二次校验一直成功不了二次校验是安全链路的兜底很多团队第一次接入时会在这里栽跟头。最容易犯的错误是把captchaVerification当成了普通字符串没有原样传递导致拼接格式错误。官方约定这个字段是token --- validate中间是三根横线。我见过有人用了一个分隔符或者只传了 token后端校验时拿不到 validate自然返回失败。另外注意二次校验的时机。validate是一次性凭证如果你在业务接口里因为参数校验失败而抛错下一次用同样的captchaVerification重试就会提示“验证码已过期”或者“校验失败”。所以业务参数校验尽量放在验证码校验之前做减少用户因表单填错而被迫重新拖滑块的概率。如果真的出现了这种场景前端要做好错误提示引导用户重新完成一次验证码而不是默默重试。5.5 常见问题速查表问题表现可能原因优先排查方向/captcha/get 返回空数据缓存配置错误或依赖未引入检查 yml 中的 cache-type 与依赖接口被拦截安全框架未放行给 /captcha/** 加白名单图片加载但拖不准AES 密钥不一致对比前后端 aes-keytoken 频繁失效多实例 local 缓存切换 redis 并检查负载均衡validate 一次性失败拼接格式错误确认 token --- validate用户成功率低slip-offset 过小适当调大容错像素6. 从接入到上线的完整检查清单6.1 上线前必须确认的五件事接入 AJ-Captcha 并不难真正考验人的是线上稳定性和安全性之间的平衡。按照我的项目经验上线前至少要做五项检查。第一验证码接口是否被 Nginx、CDN 缓存。这个问题上文反复提过因为 Varnish、CDN 默认会对 GET 请求做缓存而/captcha/get正好是 GET 请求一旦被缓存就是灾难所有用户拿到同一张图、同一个 token。我习惯在 Nginx 层面直接对/captcha/前缀配置proxy_no_cache并且响应头加上Cache-Control: no-store。第二验证码失败率是否有监控。AJ-Captcha 本身不提供监控面板但我们可以在二次校验的逻辑里打点统计 check 失败次数、失败原因分布、失败率趋势。我见过太多项目接完就扔等线上出了用户大面积反馈“验证码永远提示验证失败”才去排查那时候影响已经扩散了。建议至少把验证码的失败率接入告警平台阈值可以设在 10%。第三滑块素材是否需要定制。默认素材用久了会被打码平台收集、训练识别模型。虽然不是绝对安全但定期更换一批背景图、调整拼图样式能有效提高破解成本。我们在每个版本迭代时抽几分钟跑一批新素材替换进去成本很低收益却很实在。第四测试环境是否和生产环境隔离。如果你把测试环境的验证码缓存和生产环境的 Redis 指到同一个库那测试同学验证到一半生产用户也可能受到影响。尽量让每个环境有独立的 Redis database或者用不同的 key 前缀隔离。第五有没有做多语言适配。滑块验证码不需要文字天然适配国际化。如果你用了点选验证码注意clickWord类型的文字在非中文环境下可能显示异常最好在需要国际化的场景下直接切到滑块。6.2 后续扩展如何把验证码接入风控体系AJ-Captcha 可以作为整个风控链路的一环而不是一个孤岛。我在实际落地时把验证码的 check 结果和用户行为日志、IP 信誉库、账号风险等级做了联动。具体做法是在二次校验的接口里把用户提交时的 IP、设备指纹、UA 等信息全部记录到日志中一旦某个 IP 的验证失败次数在短时间内超过阈值就自动触发网关黑名单。同时验证码的验证结果也可以作为账号风险评分的输入项比如频繁触发验证码校验失败的账号在登录成功后暂时限制其进行高风险操作比如改绑手机、修改支付密码。这个扩展方向不复杂但价值很大它让 AJ-Captcha 从一个被动校验工具变成了主动感知异常行为的数据源。等你在生产环境跑了一段时间积累了足够的失败样本你会发现很多脚本攻击在还没碰到业务接口时就已经被验证码这关筛掉了一大半。从我在不同项目里的接入经验来看AJ-Captcha 是一款值得花半天时间集成到系统里的组件。它的设计与代码结构保留了相当多的扩展点比如缓存策略、素材管理、加密方式都可以按需替换。如果你正准备给现有系统加一道验证码防线又不想被商业版的授权费和数据出域问题绑住手脚这个项目是一个很务实的起点。在实际操作中如果卡在某个细节上多去看看 GitHub 上的 issue 区很多常见的集成问题在历史讨论里都有现成的答案。