ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java验证码实战:点击文字与滑动图片验证码实现及单元测试

Java验证码实战:点击文字与滑动图片验证码实现及单元测试 简介本资源面向Java后端与全栈开发者提供一套可直接用于生产环境的用户行为验证码方案涵盖点击中文文字图片验证码与拖动/滑动图片验证码两种形态适合需要为登录、注册等场景增加人机校验的开发者参考与集成。压缩包共178个文件约8.92MB以68个Java源码为核心辅以xml配置、css与js前端资源、png/jpg/gif图片素材及yml、ftl等模板文件结构完整、层次清晰。资源基于JDK1.8、Spring Boot 2.1.17与Redis构建核心利用AWT的BufferedImage、Graphics2D、Font实现随机中文文字生成、随机抠图与拼图前端结合点击坐标、图片位置与滚动位置计算相对坐标并通过AES或DES加密传输坐标信息。包内附带demo与单元测试修改Redis地址后即可分别启动点击与滑动验证码示例便于快速验证与二次开发。目前已有3079人学习下载适合希望深入理解验证码生成与校验原理的开发者。1. 从一次登录页改造说起Java 验证码为什么值得自己实现上个月接手一个后台管理系统的登录页改造产品要求把原来的图形验证码换成「点击文字 拖动滑块」双形态理由是用户反馈看不清扭曲字母而业务方又担心纯滑块被脚本刷。这类需求在 Java 技术栈里其实很常见Spring Boot 提供接口前端拿图、拿提示、回传坐标后端做二次校验。真正让人头疼的不是画图而是「怎么让机器难、让人顺手」——点击文字验证码考的是语义定位滑动图片验证码考的是轨迹与缺口匹配两者共用一套会话与校验骨架。这篇笔记面向正在做 Java 验证码、滑动图片验证码、单元测试落地的后端和全栈同学。我会把源码结构、核心算法、参数怎么调、单元测试怎么写、哪些坑我踩过按能复现的顺序讲清楚。不依赖某个具体仓库你照着思路就能在自己的 Spring Boot 工程里搭出来。热词里常出现的「java 面试题」「单元测试」「源码」这些关注点我也会在对应章节落到具体代码和断言上而不是停在概念。2. 验证码的两种形态点击文字与滑动图片的选型与原理2.1 点击文字验证码到底在验证什么点击文字验证码的交互是服务端给一张带若干汉字的底图再给一段提示比如「请依次点击 春、风、得、意」用户按顺序点中对应文字区域前端把点击坐标序列回传后端比对坐标是否落在正确文字的外接框内、顺序是否正确、时间是否合理。它的安全边界不在「认字」而在「定位」。OCR 能识别文字内容但要把「春」这个字映射到图上具体像素区域需要检测框回归成本比识别高一个量级。所以点击文字验证码对普通脚本的拦截效果往往比四位扭曲字母更好。常见做法是底图随机从字库渲染文字位置、旋转角度、字号、颜色都带随机扰动提示顺序也随机服务端只存「正确答案的坐标集合 顺序」不存明文答案。选型上如果你的场景是登录、注册、短信发送这类低频但敏感的入口点击文字验证码的体验和安全性平衡得不错。它的缺点是移动端小屏点击精度要求高字号不能太小一般建议单字渲染尺寸不低于 28px点击容差半径给到 1216px。2.2 滑动图片验证码的缺口匹配与轨迹校验滑动图片验证码分两步第一步是「拼图对齐」服务端生成一张带缺口的背景图和一张滑块小图用户拖动滑块让缺口吻合第二步是「轨迹校验」前端记录拖动过程中的时间戳和坐标后端判断这条轨迹像不像人。缺口匹配的核心是坐标比对。服务端在生成时就知道缺口左上角的真实 x 坐标y 通常固定或小幅随机用户提交的滑动距离如果落在真实 x 的容差范围内常见 ±5px就算对齐成功。这里有个容易翻车的点前端拿到的滑块图如果是带透明通道的 PNG浏览器渲染和 Canvas 取色会有细微差异容差给太小会误杀真人。轨迹校验是防脚本的关键。机器拖动往往是匀速直线或瞬间到位真人会有加速、减速、轻微回拉。常见做法是采集[{x, y, t}, ...]序列计算速度方差、加速度变化、是否有停顿。参数不能太严否则触屏用户和鼠标用户都会被误伤。我一般把轨迹校验做成「加分项」而非「一票否决」轨迹可疑时要求二次验证而不是直接拒绝。2.3 两种形态共用的会话与校验骨架不管哪种形态后端骨架是同一套环节点击文字滑动图片共用点生成渲染底图 文字框渲染背景 缺口生成唯一 captchaId存储坐标集合 顺序缺口 x 轨迹基线Redis 存答案设 TTL校验坐标命中 顺序距离容差 轨迹一次性消费校验后删除失效超时或错误次数超限同左返回新图旧 id 作废关键设计是「一次性消费」校验通过或失败后立即删除 Redis 里的答案防止重放。TTL 一般给 60120 秒太短用户来不及操作太长给脚本留窗口。错误次数建议限制在 35 次超过就强制换图。3. 用 Spring Boot 搭出可运行的验证码服务3.1 工程结构与依赖最小可跑骨架我一般把验证码做成独立模块方便复用。目录结构大致如下captcha-demo ├── src/main/java/com/example/captcha │ ├── config/RedisConfig.java │ ├── controller/CaptchaController.java │ ├── service/CaptchaService.java │ ├── service/impl/ClickCaptchaServiceImpl.java │ ├── service/impl/SliderCaptchaServiceImpl.java │ ├── model/CaptchaAnswer.java │ └── util/ImageUtil.java ├── src/main/resources/application.yml └── src/test/java/com/example/captcha ├── ClickCaptchaServiceTest.java └── SliderCaptchaServiceTest.java依赖只需要 Spring Boot Web、Redis 和图形处理。图形处理用 JDK 自带的java.awt就够不必引入重型图像库减少打包体积。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency逻辑说明Web 提供接口Redis 存答案。参数上spring-boot-starter-data-redis默认用 Lettuce 连接池本地开发连不上 Redis 时可以在application.yml里配spring.redis.host和port或者临时用内存 Map 替代但生产必须用 Redis否则多实例部署时答案对不上。3.2 生成点击文字验证码字库、坐标与提示生成逻辑分三步选字、渲染、存答案。选字时从字库里随机取 4 个不重复汉字再随机打乱提示顺序。渲染时把每个字画到随机位置记录外接框。public ClickCaptchaVO generate() { // 1. 选字从字库随机取 4 个不重复汉字 ListString words wordPool.randomPick(4); // 2. 提示顺序与渲染顺序解耦增加脚本难度 ListString tipOrder new ArrayList(words); Collections.shuffle(tipOrder); BufferedImage image new BufferedImage(300, 150, BufferedImage.TYPE_INT_RGB); Graphics2D g image.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); ListWordBox boxes new ArrayList(); Random random new Random(); for (String word : words) { int x 20 random.nextInt(220); int y 30 random.nextInt(90); int fontSize 28 random.nextInt(8); // 28~35px保证移动端可点 g.setFont(new Font(SimHei, Font.BOLD, fontSize)); g.setColor(new Color(random.nextInt(120), random.nextInt(120), random.nextInt(120))); g.drawString(word, x, y); // 外接框留出点击容差容差半径 14px boxes.add(new WordBox(word, x - 14, y - fontSize - 14, fontSize 28, fontSize 28)); } g.dispose(); String captchaId UUID.randomUUID().toString(); CaptchaAnswer answer new CaptchaAnswer(); answer.setType(click); answer.setTipOrder(tipOrder); answer.setBoxes(boxes); redisTemplate.opsForValue().set(captcha: captchaId, answer, 90, TimeUnit.SECONDS); return new ClickCaptchaVO(captchaId, toBase64(image), tipOrder); }逻辑说明wordPool.randomPick保证不重复避免提示里出现两个相同字导致歧义。tipOrder打乱后单独存前端只拿到提示顺序不知道渲染顺序。boxes存的是外接框比文字实际像素范围大一圈这就是点击容差。参数上字号 2835px 是移动端可点下限容差 14px 是经验值太小误杀、太大给脚本留空子。TTL 设 90 秒覆盖大多数用户的思考加操作时间。3.3 生成滑动图片验证码缺口位置与干扰滑动验证码的生成重点是缺口位置随机且不贴边滑块图从背景图对应位置裁出。public SliderCaptchaVO generate() { BufferedImage bg ImageUtil.loadRandomBackground(300, 150); int gapWidth 50, gapHeight 50; // 缺口 x 限制在 [60, 240]避免贴边导致滑块拖不到 int gapX 60 new Random().nextInt(180); int gapY 20 new Random().nextInt(80); // 在背景图上挖出缺口用半透明遮罩表示 Graphics2D g bg.createGraphics(); g.setColor(new Color(0, 0, 0, 120)); g.fillRect(gapX, gapY, gapWidth, gapHeight); g.dispose(); // 从原图裁出滑块小图 BufferedImage slider bg.getSubimage(gapX, gapY, gapWidth, gapHeight); String captchaId UUID.randomUUID().toString(); CaptchaAnswer answer new CaptchaAnswer(); answer.setType(slider); answer.setGapX(gapX); answer.setGapY(gapY); redisTemplate.opsForValue().set(captcha: captchaId, answer, 90, TimeUnit.SECONDS); return new SliderCaptchaVO(captchaId, toBase64(bg), toBase64(slider), gapY); }逻辑说明gapX限制在 60240是因为滑块初始位置在左侧太靠左用户一拖就过太靠右拖不到。gapY随机但范围小前端滑块只能水平拖y 由服务端下发。注意裁滑块要在挖缺口之前做否则裁到的是带遮罩的图。参数上缺口 50×50 是常见尺寸太小难对齐太大容易蒙对。3.4 校验接口坐标比对、轨迹判断与一次性消费校验接口接收 captchaId 和用户提交数据从 Redis 取答案比对后立即删除。public boolean verify(String captchaId, VerifyRequest req) { String key captcha: captchaId; CaptchaAnswer answer (CaptchaAnswer) redisTemplate.opsForValue().get(key); if (answer null) { return false; // 已过期或已消费 } // 一次性消费无论成败都删除防重放 redisTemplate.delete(key); if (click.equals(answer.getType())) { return verifyClick(answer, req.getPoints()); } else { return verifySlider(answer, req.getSlideX(), req.getTrack()); } } private boolean verifyClick(CaptchaAnswer answer, ListPoint points) { if (points null || points.size() ! answer.getTipOrder().size()) { return false; } for (int i 0; i points.size(); i) { String expectedWord answer.getTipOrder().get(i); WordBox box answer.getBoxes().stream() .filter(b - b.getWord().equals(expectedWord)) .findFirst().orElse(null); if (box null || !box.contains(points.get(i))) { return false; } } return true; } private boolean verifySlider(CaptchaAnswer answer, int slideX, ListTrackPoint track) { // 距离容差 ±5px if (Math.abs(slideX - answer.getGapX()) 5) { return false; } // 轨迹校验作为加分项可疑时记录不直接拒绝 if (track ! null track.size() 2) { double variance calcSpeedVariance(track); if (variance 0.01) { log.warn(captcha {} track too smooth, possible bot, answer.getCaptchaId()); } } return true; }逻辑说明redisTemplate.delete放在比对之前保证无论结果如何答案都失效这是防重放的关键。点击校验按提示顺序逐个比对坐标是否落在对应文字的外接框内。滑动校验先比距离容差 ±5px轨迹只做日志记录不直接拒绝避免误伤触屏用户。参数上容差 5px 是鼠标和触屏都能接受的折中值速度方差阈值 0.01 是经验值低于它说明轨迹过于匀速。4. 单元测试怎么写把验证码逻辑钉死在断言里4.1 点击文字校验的单元测试验证码逻辑最容易出错的地方是坐标边界和顺序单元测试要覆盖这两点。SpringBootTest class ClickCaptchaServiceTest { Autowired private ClickCaptchaServiceImpl service; Test void testVerifySuccess() { ClickCaptchaVO vo service.generate(); // 从 Redis 取答案模拟用户点中每个字的中心 CaptchaAnswer answer service.getAnswer(vo.getCaptchaId()); ListPoint points answer.getTipOrder().stream() .map(word - answer.getBoxes().stream() .filter(b - b.getWord().equals(word)) .findFirst().get()) .map(box - new Point(box.getX() box.getWidth() / 2, box.getY() box.getHeight() / 2)) .collect(Collectors.toList()); assertTrue(service.verify(vo.getCaptchaId(), new VerifyRequest(points, null))); } Test void testVerifyWrongOrder() { ClickCaptchaVO vo service.generate(); CaptchaAnswer answer service.getAnswer(vo.getCaptchaId()); ListPoint points new ArrayList(); // 故意逆序点击 ListString reversed new ArrayList(answer.getTipOrder()); Collections.reverse(reversed); for (String word : reversed) { WordBox box answer.getBoxes().stream() .filter(b - b.getWord().equals(word)) .findFirst().get(); points.add(new Point(box.getX() 5, box.getY() 5)); } assertFalse(service.verify(vo.getCaptchaId(), new VerifyRequest(points, null))); } }逻辑说明第一个用例模拟正确点击取每个字外接框中心点断言校验通过。第二个用例故意逆序断言失败覆盖顺序校验。参数上box.getX() 5是故意取偏左上角验证容差范围内也能命中如果实现里容差算错这个用例会暴露。注意测试要能拿到 Redis 里的答案我一般给 Service 加一个包级可见的getAnswer方法仅测试用。4.2 滑动距离与轨迹的边界测试滑动校验的边界在容差边缘和轨迹异常。SpringBootTest class SliderCaptchaServiceTest { Autowired private SliderCaptchaServiceImpl service; Test void testVerifyAtToleranceEdge() { SliderCaptchaVO vo service.generate(); CaptchaAnswer answer service.getAnswer(vo.getCaptchaId()); // 正好在容差边缘 5px应通过 assertTrue(service.verify(vo.getCaptchaId(), new VerifyRequest(null, answer.getGapX() 5, null))); } Test void testVerifyOutOfTolerance() { SliderCaptchaVO vo service.generate(); CaptchaAnswer answer service.getAnswer(vo.getCaptchaId()); // 超出容差 6px应失败 assertFalse(service.verify(vo.getCaptchaId(), new VerifyRequest(null, answer.getGapX() 6, null))); } Test void testVerifyConsumedOnce() { SliderCaptchaVO vo service.generate(); CaptchaAnswer answer service.getAnswer(vo.getCaptchaId()); service.verify(vo.getCaptchaId(), new VerifyRequest(null, answer.getGapX(), null)); // 第二次用同一个 id 应失败 assertFalse(service.verify(vo.getCaptchaId(), new VerifyRequest(null, answer.getGapX(), null))); } }逻辑说明前两个用例钉死容差边界5 通过、6 失败防止后续有人改容差时无意放宽。第三个用例验证一次性消费这是安全底线。参数上容差 5px 是代码里的常量测试直接引用同一个常量更好避免硬编码两处不一致。轨迹测试可以单独构造匀速轨迹断言日志被触发但不断言拒绝因为轨迹是加分项。4.3 用 Mock 隔离 Redis 的测试技巧如果 CI 环境没有 Redis可以用 Mock 替代但要注意 Mock 的行为要和真实 Redis 一致尤其是 TTL 和删除。ExtendWith(MockitoExtension.class) class CaptchaServiceMockTest { Mock private RedisTemplateString, Object redisTemplate; Mock private ValueOperationsString, Object valueOps; InjectMocks private SliderCaptchaServiceImpl service; Test void testVerifyWithMockRedis() { when(redisTemplate.opsForValue()).thenReturn(valueOps); CaptchaAnswer answer new CaptchaAnswer(); answer.setType(slider); answer.setGapX(120); when(valueOps.get(captcha:test-id)).thenReturn(answer); assertTrue(service.verify(test-id, new VerifyRequest(null, 120, null))); verify(redisTemplate).delete(captcha:test-id); } }逻辑说明Mock 掉RedisTemplate和ValueOperations让get返回预设答案断言校验通过并验证delete被调用。参数上verify(redisTemplate).delete(...)是 Mockito 的验证语法确保一次性消费逻辑被执行。注意 Mock 测试不能替代集成测试TTL 和并发行为还得靠真实 Redis 验证。5. 避坑与排查验证码上线后最容易翻车的 5 个点5.1 现象真人拖动总是差几像素反复失败原因前端 Canvas 渲染的图片尺寸和后端生成尺寸不一致或者浏览器对 PNG 透明通道的处理有差异导致用户看到的缺口位置和实际坐标有偏移。解决后端下发图片时同时下发原始宽高前端按原始尺寸等比缩放提交坐标前换算回原始坐标系。容差不要低于 5px触屏设备建议给到 8px。上线前用真机各测一遍。5.2 现象脚本用固定坐标反复请求偶尔能过原因缺口 x 随机范围太窄或者答案在 Redis 里 TTL 太长脚本可以枚举。解决缺口 x 范围至少覆盖 180px每次生成都重新随机。TTL 压到 90 秒以内错误 3 次强制换图。校验接口加频率限制同一 IP 或同一账号每分钟不超过 10 次。5.3 现象点击文字验证码在移动端点不中原因字号太小或容差太小手指点击精度远低于鼠标。解决移动端单独渲染更大字号单字不低于 32px容差半径给到 16px。提示文字和底图文字用同一字体避免用户认错。如果底图有干扰线干扰线不能穿过文字外接框。5.4 现象Redis 里答案堆积内存上涨原因生成接口被刷大量 captchaId 写入但没人校验TTL 到期前堆积。解决生成接口也要限流同一 IP 每分钟不超过 20 次。TTL 统一设 90 秒不要有的 5 分钟有的 10 分钟。监控 Redis 里captcha:*的 key 数量超过阈值告警。5.5 现象单元测试在 CI 上随机失败原因测试依赖真实 RedisCI 环境网络抖动或 Redis 未启动或者测试之间共享了同一个 captchaId。解决单元测试用 Mock 隔离 Redis集成测试单独跑并确保 Redis 可用。每个测试用例生成独立的 captchaId不要复用。测试里不要依赖系统时间TTL 相关的断言用 Mock 控制。6. 进阶把验证码做成可配置、可观测的组件做到能跑只是第一步真正上线后你会发现「可配置」和「可观测」比算法本身更影响维护成本。我一般会把验证码的形态、难度、TTL、容差、限流阈值全部抽到配置里用ConfigurationProperties绑定改参数不用重新打包。ConfigurationProperties(prefix captcha) Data public class CaptchaProperties { private int ttlSeconds 90; private int clickTolerance 14; private int sliderTolerance 5; private int maxRetry 3; private int rateLimitPerMinute 10; private String defaultType slider; }逻辑说明ttlSeconds控制答案有效期clickTolerance和sliderTolerance分别控制两种形态的容差maxRetry控制错误次数上限rateLimitPerMinute控制生成接口频率。参数上这些默认值是我在几个项目里调出来的折中值你可以按业务敏感度调整金融类场景把 TTL 压到 60 秒、容差收紧到 4px内部系统可以放宽。可观测性方面至少埋三个指标生成次数、校验成功次数、校验失败次数按形态和失败原因打标签。失败原因要区分「过期」「坐标不匹配」「轨迹可疑」「超频」这样线上出问题时能一眼看出是用户操作问题还是被刷。我习惯用 Micrometer 直接对接现有监控不额外引入依赖。Autowired private MeterRegistry meterRegistry; public boolean verify(String captchaId, VerifyRequest req) { CaptchaAnswer answer getAnswer(captchaId); if (answer null) { meterRegistry.counter(captcha.verify, result, expired).increment(); return false; } boolean ok doVerify(answer, req); meterRegistry.counter(captcha.verify, result, ok ? success : fail, type, answer.getType()).increment(); return ok; }逻辑说明每次校验都打点标签区分结果和形态。参数上result标签的取值要固定枚举不要动态拼字符串否则监控基数会爆炸。上线后观察失败率如果某个形态失败率突然升高优先查前端渲染和容差而不是怀疑被刷。最后一个技巧验证码的答案不要存明文坐标存一个哈希或者偏移量校验时用同样的算法还原比对。这样即使 Redis 被读攻击者也拿不到直接可用的答案。我吃过一次亏早期版本答案明文存 Redis被内部人员导出后写脚本刷后来改成坐标加随机盐哈希才堵住。这个习惯我一直保留到现在凡是能直接用来通过校验的数据落存储前都过一层变换。希望帮到你。本文还有配套的精品资源点击获取
返回列表