ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自研Java点击与滑动验证码:从生成、校验到防刷实践

自研Java点击与滑动验证码:从生成、校验到防刷实践 简介这是面向Java开发者的用户行为验证码完整实现方案覆盖点击文字验证码与拖动/滑动图片验证码两种主流形式。资源基于Spring Boot 2.1与Redis构建包含可生产使用的源码、demo、单元测试和部署说明特别适合需要自建验证码系统或深入理解图像处理与前端交互的工程师。整个资源包为ZIP压缩包约8.92MB共178个文件其中Java源码68个、PNG与JPG图片素材32个、CSS/JS/XML配置文件46个并附带HTML页面、字体文件及YML配置前后端工程结构清晰完整。已有3078人学习下载。读者可从中掌握AWT的BufferedImage与Graphics2D绘制随机中文文字、随机抠图与拼图的核心方法理解前端点击坐标换算、浏览器滚动位置计算及拖动原理并学习利用AES/DES加密坐标信息的安全实践同时获得Spring Boot项目打包、Redis接入及单元测试编写等实操经验。1. 点击与滑动验证码自研为什么放着现成 SDK 不用非要手写 Java 实现做 Java 后端这几年验证码这块我踩过不少坑。最开始偷懒前端接个第三方验证码 SDK后端调个接口校验看似省事。直到有一天线上活动被刷一看日志对方直接把验证码接口的 token 全绕过校验结果在服务端根本没落库我们只在前端弹了个“验证通过”的动画——这玩意儿防君子不防小人。后来我痛定思痛把点击文字验证码和拖动/滑动图片验证码都收回来自己做服务端校验逻辑全部重写才算把刷子挡在门外。这期笔记就把我手写的这套方案完整拆开点击文字验证码的生成与坐标校验、滑动拼图的缺口识别与轨迹模拟、服务端防重放与二次校验、单元测试怎么写得让人放心以及那些只有跑过真实流量才看得见的坑。不管你是在做登录注册、活动秒杀还是短信防刷这套思路都能直接抄作业。标题里说的源码 demo 单元测试 实现思路我尽量在一篇文章里讲透代码可以直接落到工程里跑。2. 点击文字验证码背景图生成、文字坐标回落与旋转角度校验点击文字验证码的核心不是“随机生成几个字”而是让字符融入背景、坐标可还原、点击有容错。我从需求倒推设计用户看到一张图上有若干个中文/数字字符按提示点击指定字符点击坐标传给后端后端比对坐标是否落在目标字符的真实包围盒里。2.1 为什么选择自研背景干扰与字符渲染方案常见做法是直接用 Java 的BufferedImageGraphics2D在内存里画图然后 base64 输出给前端。但纯随机画几个字太容易被 OCR 识别尤其热词里很多人搜“PHP OCR 识别验证码”“虾皮验证码识别”说明攻击者的 OCR 工具链已经非常成熟。我的应对思路是加大背景干扰强度 字符做随机旋转和扭曲 字体颜色贴近背景色系让 OCR 的准确率掉到可接受范围以下。具体来说我用三层背景叠加第一层是随机色块填充模拟噪点第二层是随机干扰线用Graphics2D.drawArc或drawLine画 58 条曲线第三层是半透明字符透明度控制在 60%80%既保证肉眼可读又让 OCR 难以干净地二值化。提示字符旋转角度不要超过 ±30 度否则人眼也看不懂用户流失比被刷更可怕。2.2 生成接口设计与坐标回落逻辑生成验证码时我先确定画布尺寸比如 300×150。然后随机选 4 个字符逐个渲染到画布。关键点每个字符的包围盒必须记录下来包括字符左上角坐标x、y字符渲染后的宽w、h旋转角度angle。这些数据不能落数据库否则攻击者可以直接查库而是加密后放在返回给前端的 token 里或者放在服务端内存缓存中带过期时间。坐标回落逻辑是用户点击时拿到的是前端页面坐标已经除以了 CSS 缩放的 scale 因子后端校验时要先判断点击点是否落在某个字符的包围盒内再判断该字符是否等于提示的目标字符。容错半径我一般给 58 像素太小用户容易点偏太大攻击者随便点都能过。public ClickCaptchaVO generate(int width, int height, int charCount) { BufferedImage image new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g2d image.createGraphics(); // 抗锯齿必开否则字符边缘锯齿明显 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 随机背景色 g2d.setColor(randomBgColor()); g2d.fillRect(0, 0, width, height); drawNoiseLines(g2d, width, height, 6); drawNoiseDots(g2d, width, height, 80); ListCharBox boxes new ArrayList(); String targetChar null; for (int i 0; i charCount; i) { char c randomChar(); int fontSize 32 random.nextInt(12); g2d.setFont(new Font(SansSerif, Font.BOLD, fontSize)); Color color randomColorCloseToTheme(bgColor); g2d.setColor(color); double angle Math.toRadians(random.nextInt(61) - 30); // -30 ~ 30 g2d.rotate(angle, x fontSize / 2.0, y fontSize / 2.0); g2d.drawString(String.valueOf(c), x, y fontSize); g2d.rotate(-angle, x fontSize / 2.0, y fontSize / 2.0); // 记录字符包围盒时用旋转后近似矩形放大 1.2 倍容错 boxes.add(new CharBox(c, x, y, (int) (fontSize * 1.2), (int) (fontSize * 1.2), angle)); if (i random.nextInt(charCount)) { targetChar String.valueOf(c); } x fontSize random.nextInt(15); } g2d.dispose(); String token encryptCaptchaData(boxes, targetChar, expireAt); return new ClickCaptchaVO(base64(image), token, targetChar); }这段代码的逻辑说明:第一步生成BufferedImage并打开Graphics2D设置抗锯齿第二步先画背景色和干扰元素注意干扰线在字符下层第三步逐个绘制字符画之前先rotate画完立刻rotate回去否则后续字符位置全部偏移第四步把字符坐标和提示目标加密进 token。参数上字符间距fontSize random.nextInt(15)是防重叠的关键太挤时 OCR 容易把两个字拆成一个人也容易看错。2.3 加密 Token 的设计防止篡改和重放token 里塞的是字符坐标列表和提示字符所以必须防篡改。我常用 AES 加密 时间戳 一次性标识。时间戳用于过期控制一次性标识用于防重放——同一个 token 只能校验成功一次。注意token 里不要存明文的字符坐标要加密后再 base64否则攻击者直接解码 token 就能精准点击。校验接口收到前端上报的x、y和token后解密 token取出字符列表遍历找点击点落在哪个字符的包围盒里然后比对字符是否为提示目标。同时检查 token 是否在 Redis 里已存在存在则拒绝并删除以及是否超过有效期。这一套下来至少能挡住 90% 的脚本攻击。3. 拖动/滑动图片验证码缺口识别、距离计算与前端轨迹采集滑动验证码的核心链路比点击多了两个环节图像上找缺口位置 模拟人类拖拽轨迹。第一个环节决定服务端能不能校验第二个环节决定校验结果会不会被“行为风控”判为机器人。3.1 缺口定位的两种主流做法找缺口有两种方案。前端 Canvas 找缺口前端加载两张图带缺口和不带缺口直接用像素差计算找缺口坐标然后让滑块移动到目标位置。好处是服务端不用做图像处理坏处是前端代码一旦被逆向攻击者可以直接调用找缺口函数距离精确到像素级。后端 Java 图像处理找缺口用BufferedImage读取图片逐像素比对缺口图和完整图的 RGB 差异找到差异区域的中心点。这种方式安全但每次生成验证码时多一步计算耗时大概 10~30ms完全能接受。我一般两者都做前端先算一次主要为了 UI 体验让滑块能对齐后端再算一次校验前端上报的缺口距离是否一致。前后端距离不一致就直接拒绝这能挡掉一部分只调前端接口的脚本。后端缺口识别的核心逻辑如下public int findGap(BufferedImage bg, BufferedImage bgWithoutGap) { int threshold 120; // 像素差异阈值 int startX 60; // 从 60px 开始扫因为左边缘有滑块初始位置干扰 for (int x startX; x bg.getWidth(); x) { for (int y 0; y bg.getHeight(); y) { int rgb1 bg.getRGB(x, y); int rgb2 bgWithoutGap.getRGB(x, y); int diff diffRGB(rgb1, rgb2); if (diff threshold) { // 找到第一个高差异像素再向上下左右扩散找出缺口中心 return findGapCenter(bg, bgWithoutGap, x, y); } } } throw new IllegalStateException(gap not found); }这段代码里startX从 60 开始是经验值——滑块初始占据的矩形区域会让比对结果全是“差异”跳过它才能扫到真正的缺口。threshold设 120 是多次实验出来的平衡点太低会把图片压缩噪声误认为缺口太高可能漏掉浅色缺口。diffRGB是对三个通道的差值取绝对值和再将和与阈值比较这样能避开纯亮度变化但色相一致的区域。3.2 用户拖拽轨迹的采集与上报图片验证码的前端流程一般是页面加载一张背景图和一个拼图滑块用户按住滑块拖动到缺口位置松开前端把最终距离distance和拖动过程中的若干个采样点[x, y, timestamp]上报给后端。真正的行为校验都在轨迹里。机器模拟的轨迹通常有两个显著特征速度曲线平滑得像直线、总时长固定在某一个区间。人的轨迹往往是“先快后慢、最后微调”——前 30% 距离快速移动中间 40% 速度稳定但有小抖动最后 30% 逐渐减速还可能回拉一点点再对准。前端采样代码let track []; let startX 0, startTime 0; slider.addEventListener(mousedown, e { startX e.clientX; startTime Date.now(); track [{ x: 0, y: 0, t: 0 }]; }); slider.addEventListener(mousemove, e { const dx e.clientX - startX; const dt Date.now() - startTime; // 每 30ms 采一个点点太密会显得像机械扫描 if (dt - track[track.length - 1].t 30) { track.push({ x: dx, y: Math.random() * 3 - 1.5, t: dt }); } }); slider.addEventListener(mouseup, e { const dx e.clientX - startX; // 上报最终距离和轨迹 axios.post(/api/captcha/slide/verify, { distance: dx, track, token }); });注意这里的y轴加了 ±1.5px 的随机抖动——人的手在水平拖动时不可能绝对保持一条直线。t字段是相对时间戳不是绝对时间避免攻击者拿抓包时间反推速度。3.3 后端轨迹校验的阈值设定后端拿到轨迹后我做了三件事距离粗校验前端上报的distance与后端图像识别出的缺口距离做差|diff| 5px通过否则直接拒绝这是最硬的一层。轨迹点数校验总点数少于 15 个或大于 200 个都有问题。少于 15 个说明用户“瞬移”到缺口脚本特征多于 200 个说明拖动时间过长可能是人为测试。加速度突变检查计算相邻采样点之间的速度v dx / dt如果某两段之间的速度突变超过 8 倍以上判定为异常。人不会突然从静止加速到 2000px/s 然后再瞬间停下——但机器模拟的匀速运动经常走一条“无曲线”的路径。实现时要注意恶意用户未必每次都用脚本有些是人工刷量。所以轨迹校验只作为“降权”维度不直接一票否决连续三次校验失败再拉黑 IP 或设备指纹。我见过很多团队的验证码被绕过就是把轨迹校验当成了唯一防线——这是典型的“一个锁守门破门即长驱直入”。4. 服务端防刷体系会话绑定、Redis 防重放、二次校验从标题看验证码只是入口真正抗住并发刷量的是服务端那套“拦截-校验-追踪”体系。这里我把自研验证码的服务端链路完整讲一遍。4.1 验证码生成接口的会话绑定生成验证码时除了返回图片和 token我还会在 Redis 里存一条记录key captcha:slide:{sessionId}value tokenexpire 120秒。校验时先从 Redis 取出 token取不到直接拒绝再解密 token 里的数据做校验校验成功后立马删除这个 key。这样能做到一个 token 只能使用一次即使被中间人截获也没有重放的价值。注意sessionId 不要从前端请求参数里直接拿要从HttpSession或你自定义的加密 Cookie 里取。前端可以伪造任何参数但 Cookie 里的会话标识至少比参数难伪造一点。4.2 校验接口要防的三种攻击第一种是暴力遍历攻击者写脚本把所有可能的缺口距离/坐标都试一遍。对付这种校验接口加一分钟内最多 N 次的限制N 我一般设 5 次超过直接拒绝并要求重新生成验证码。第二种是并发重放同一个 token 被并发请求同时打到后端Double-check 要配合 Redis 原子删除DEL返回 1 才是自己删成功或者直接用一个SETNX做幂等标识。第三种是参数篡改前端上报的坐标和轨迹被人为修改比如总是上报“标准样本”轨迹。对付这种可以把轨迹样本做一次哈希把这个哈希加到 token 里这样攻击者无法从 token 里还原出应提交的轨迹形状。4.3 二次校验的引入时机我第一次自研验证码时只在登录接口校验一次结果被刷了才知道不够。后来学的教训对重要操作加二次校验。登录校验通过后签发一个“短期权力令牌”给前端后续 5 分钟内做高危操作改密、提现、下单时带上这个令牌服务端再验一次。热词里有人搜“spring boot mybatis 的多商户商城”我猜他们就是商城场景二次校验在订单接口非常关键——绕过验证码不一定是登录口很可能是直接打业务接口。PostMapping(/api/captcha/slide/verify) public Result verify(String token, Integer distance, ListTrackPoint track) { String sessionId getSessionId(); String cachedToken redisTemplate.opsForValue().get(captcha:slide: sessionId); if (!token.equals(cachedToken)) { return Result.fail(403, 验证码已过期请刷新); } Boolean removed redisTemplate.delete(captcha:slide: sessionId); if (!Boolean.TRUE.equals(removed)) { return Result.fail(403, 验证码已被使用请刷新); } CaptchaData data decryptToken(token); if (data.getExpireAt() System.currentTimeMillis()) { return Result.fail(403, 验证码超时); } if (Math.abs(data.getGap() - distance) 5) { return Result.fail(403, 滑块位置不匹配); } if (!passTrackCheck(track)) { return Result.fail(403, 验证失败请重试); } return Result.success(issueShortTermToken()); }这段代码的逻辑顺序是先取缓存 token再 delete再校验参数。注意 delete 在参数校验之前这是故意的——如果一个 token 已经被验证过一次第二次来哪怕参数完全正确也必须拒绝。passTrackCheck里有轨迹点数、加速度突变、时长三项检查。短时权力令牌issueShortTermToken我存 Redis 5 分钟业务接口比对后删除防止在业务接口再次被重放。4.4 验证码与业务接口的关联一个容易忽略的细节验证码校验接口和业务接口必须解耦但有关联。登录接口不应该直接调用验证码校验接口而是应该把验证码校验的结果作为前置条件。我习惯在校验接口校验通过后把 sessionId 加上“已验证”标记登录接口检查这个标记存在才放行。这样做的原因是攻击者完全可以直接跳过验证码校验接口直接打登录接口——所以登录接口必须自己验证“这个会话确实过了验证码关”而不是信任前端传过来的“验证已通过”状态。5. 单元测试与排查避坑回调地址不一致、轨迹过检、并发重放代码写再快测试不兜底就是给自己埋雷。我把这套验证码的单元测试和踩坑记录都列出来每条都是真金白银的教训。5.1 单元测试怎么覆盖核心逻辑单元测试要覆盖的不只是“校验成功”路径关键是“边界和失败”路径。我用的 JUnit 5 Mockito一个核心原则图像生成逻辑不跑真实随机数用固定种子让测试可重复。Test DisplayName(滑动验证码缺口距离误差在容差范围内应通过) void testSlideVerify_Success() { CaptchaService service new CaptchaService(redisTemplate); // 1. 准备直接构造 CaptchaDatagap 100 CaptchaData data CaptchaData.builder() .gap(100).expireAt(System.currentTimeMillis() 120_000) .token(mock-token).build(); // 2. 模拟 Redis 中已存在这个 token when(redisTemplate.delete(eq(captcha:slide:session-1))) .thenReturn(Boolean.TRUE); // 3. 前端上报 distance 103误差 3应通过 Result result service.verify(token, 103, validTrack()); assertEquals(Result.success(), result); } Test DisplayName(滑动验证码距离误差超过容差应拒绝) void testSlideVerify_OutOfRange() { CaptchaService service new CaptchaService(redisTemplate); CaptchaData data CaptchaData.builder() .gap(100).expireAt(System.currentTimeMillis() 120_000) .token(mock-token).build(); when(redisTemplate.delete(anyString())).thenReturn(Boolean.TRUE); Result result service.verify(token, 108, validTrack()); assertEquals(Result.fail(403), result); }这类测试的关键在于不启动 Spring 容器直接用构造器创建 Service 实例用 Mockito mock 掉 Redis。速度快且稳定。但别只测校验逻辑图像生成也要测比如生成 100 张图断言每张图都有缺口且缺口位置不重复。这个测试会暴露随机数的“聚簇”问题如果不控制种子可能连续生成几张图缺口位置完全一样。5.2 避坑一回调地址与返回地址不一致导致前端拿不到坐标现象前端拿到验证码图片但滑完滑块后提示“内部错误”后端日志根本没有任何请求进来。原因生成验证码时接口返回的是绝对路径的图片 URL比如https://api.example.com/captcha/img?idxxx但前端页面部署在另一个域名下跨域把图片请求“吞掉”了坐标回传也带了错误的 Origin。解决生成接口返回的图片不要用完整的重定向地址而是返回一个相对路径/captcha/img/{token}前端自己拼上当前的 API 域名或者全局配置 CORS允许前端域名访问。这个坑排查了一天最后看浏览器 Network 才发现是跨域问题——排查全链路时先看浏览器控制台和 Network 再翻后端日志。5.3 避坑二轨迹校验误杀率高用户拖动稍微快点就过不了现象上线的第一周验证码通过率只有 74%用户投诉“我没输错为什么老让我刷新”。原因轨迹校验中有一条规则是“拖动总时长不能少于 800ms”但很多老用户鼠标拖动速度极快300ms 就拉完了。绳子拉得太紧正常用户也被拒了。解决把硬性时长限制从校验器里去掉改成“统计信号”——总时长过短只增加风险分不直接否决。风险分累计超过阈值才要求重新验证。同时加一个特例如果轨迹点特别少少于 8 个可能是安装了鼠标宏或硬件脚本这时也必须拒绝——极快的真人拖动也会采样到 15 个点左右因为浏览器 mousemove 是按显示器刷新率触发的。提示任何轨迹校验规则上线前先拿公司内部 10 台不同电脑的浏览器实测一遍通过率。低于 85% 验证码就没法用这是血泪经验。5.4 避坑三Redis 断连导致验证码全部失效现象某次 Redis 抖动 30 秒期间所有登录请求全部返回“验证码已过期”用户大面积反馈登录不了。原因校验接口里redisTemplate.delete在 Redis 超时或断连时抛了异常异常没有 catch导致校验接口 500。解决Redis 操作全部加 try-catch。降级策略是Redis 不可用时验证码接口返回失败并提示“请稍后刷新”而不是让请求在业务接口上碰运气。因为验证码失效会让攻击者有趁虚而入的机会宁可暂时关停也不要用降级绕过校验。事后我在校验接口加了一个 sentinel 熔断Redis 连续失败超过阈值直接走快速失败路径。6. 进阶二维码形态的点击验证码与多级安全决策最后聊点进阶玩法这也是标题里“实现思路”四个字最值钱的地方——把验证码从“一个关卡”升级成“一套安全决策链路”。6.1 点击验证码的进阶形态图片内目标识别点击文字验证码有个天然短板字符坐标范围有限攻击者可以通过 OCR 识别文字再去点。进阶形态是“点击图中的某种目标”比如点击图片里所有红绿灯、所有包含数字的道路标志。这类验证码靠的是图像语义理解OCR 只能识别出文字在哪识别不了“哪个是红绿灯”。Java 实现时不需要上深度学习模型我用的是一种取巧策略预置多张语义明确的图片每张图预先标注好目标区域掩码坐标生成验证码时随机抽图。校验时直接比对点击点是否落在预置掩码内。这样做的好处是零推理成本坏处是图片需要人工预标注素材。准备 100 张标注图就够日常防刷了换图时不更新掩码攻击者就会在“旧图识别模型”上做无效功。6.2 多级安全决策验证码不是唯一的判断依据我现在的架构是把验证码结果作为一个“风险信号”输入决策引擎而不是一个二值的通过/不通过。决策引擎综合以下信号验证码校验结果通过/失败/超时/重放设备指纹WebRTC、Canvas 指纹、UA 一致性IP 维度的频率特征1 分钟请求数、验证码失败率行为轨迹质量分刚才说的轨迹统计特征最终输出一个动作指令放行、要求重新验证、加入观察名单下次要求更难的验证码、直接封禁 15 分钟。public Decision decide(CaptchaVerifyResult captchaResult, DeviceFingerprint fp, RequestStat stat) { int riskScore 0; if (!captchaResult.isPassed()) riskScore 50; if (stat.getFailureRate() 0.8) riskScore 30; if (fp.isSuspicious()) riskScore 20; if (riskScore 80) return Decision.BLOCK_15MIN; if (riskScore 50) return Decision.REQUIRE_REVERIFY; if (captchaResult.isPassed() stat.getFailureRate() 0.1) { return Decision.ALLOW_WITH_OBSERVATION; } return Decision.REQUIRE_REVERIFY; }这段决策代码的逻辑是验证码失败一次不再直接拒绝整个会话而是结合失败率和设备指纹给风险分。一个 IP 第一次没滑对可能是误操作连续 5 次没滑对就是脚本特征。决策阈值可以从日志里回归把历史被攻击的会话拉出来反推它们的风险分分布找到能覆盖 95% 攻击会话的最低阈值。6.3 验证码接口的压测标准上线前一定要做压测。生成接口容易压死因为BufferedImage操作是 CPU 密集型的。我的经验值单台 4C8G 的云服务器生成接口 QPS 大约 100~200校验接口 QPS 能到 1000因为校验不涉及图像渲染。压测时重点看两个指标生成接口的响应时间 P99 是否超过 200msRedis 连接池是否因校验接口的高频访问被打满。如果生成接口成了瓶颈把图片尺寸从 300x150 降到 240x120耗时能改善 40%。还有一个偷懒但有效的方案预生成一批验证码图片存内存缓存比如启动时生成 1000 张随机图接口直接返回一张并标记已用。但要注意预生成的图不能让攻击者直接下载大量后离线建样本库所以每张图也要带过期时间并且生成逻辑里保留随机参数抖动。6.4 最后的经验总结从一次被刷到自研的完整复盘我最开始用第三方验证码 SDK被绕过是因为校验逻辑完全依赖前端回调服务端没有二次确认。后来自研服务端校验又被刷是因为轨迹校验太严格误杀率太高产品被迫关掉了校验。这来回的教训让我形成了三个习惯希望对你有用第一个习惯是校验结果必须“有状态”。“通过”和“未通过”之外还要记录“重放”“超时”“轨迹异常”这些中间态——日志里这些中间态才是调整阈值的数据基础。第二个习惯是所有校验规则都要可配置通过配置中心动态调整距离容差、轨迹点数阈值、风险分权重不要硬编码在代码里。第三个习惯是每一条校验规则都要有对应的监控指标——我在 Grafana 上盯着“验证码通过率”“重放率”“平均拖动时长”三个指标任何一个异常都能提前发现攻击苗头。验证码这个方向不复杂但细节极多。希望这篇笔记能让你少走我走过的弯路把点击和滑动验证码稳稳跑起来。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进