Spring Boot 验证码实战:从生成到防刷的完整设计
2026/9/8 8:32:31 网站建设 项目流程

验证码这个东西,看着不起眼,但真要在 Spring Boot 项目里把它做扎实,里面的坑比想象中多。这个标题我前后在好几个项目里落地过,从最开始的 servlet + session 画几个字母,到后来前后端分离 + Redis 存 key + Base64 输出,再到现在接滑块、行为验证码,踩了不少雷,也沉淀了一套比较稳的做法。这篇就把我常用的实现思路、关键代码、防刷设计和真实事故记录整理出来,给正在做 Spring Boot 验证码功能的同学一个直接的参考。

提示:标题里的“验证码”如果只做登录页面那一张图,那确实十分钟能写完;但如果想让它在生产环境扛住并发、刷子和分布式部署,建议按下面的思路完整过一遍。

1. 整体设计与技术选型

1.1 图形验证码在项目中的定位

先想清楚一件事:验证码到底在防什么。它防的不是“人输错”,而是“脚本批量提交”。登录、注册、找回密码、发短信、下单,这些接口一旦暴露给机器,轻则被撞库、刷短信,重则拖垮整个服务。所以验证码的本质是人机校验,它是业务接口前面的第一道闸门。

正因如此,Spring Boot 项目里的验证码不能只做“能显示、能校验”就完事。我从第二版重构开始,就要求自己把验证码当成一个独立模块来设计:生成、存储、校验、续期、防刷、审计各司其职。这样做的好处是换存储、换验证码类型、接第三方行为验证时,不会把大半个业务代码都牵动。

1.2 三个关键决策:存储、输出、校验方式

很多新手做验证码,上来就用 HttpSession 存验证码文本。单机演示没问题,但一到前后端分离、多实例部署就翻车。下面这三个决策是我在实际项目里反复权衡后总结出来的:

设计点不推荐的做法推荐的做法原因
存储位置HttpSessionRedis 或本地 Caffeine 缓存前后端分离时前端拿不到 session;多实例部署时 session 不共享
输出方式直接写文件流给前端转 Base64 字符串返回给前端前端<img>直接可用,也能放到 JSON 里,好调试
校验方式明文比对后不清除哈希比对 + 一次消费防止验证码被重复使用,避免暴力重放
过期策略永不失效或半小时一次3-5 分钟过期有效期太长容易被批量利用,太短用户根本来不及输

选 Redis 做存储还有一个隐藏好处:Redis 自带的SETEXINCREXPIRE天然适合做频率控制和过期清理,不需要额外写定时任务。如果你的项目还没有 Redis,本地用 Caffeine 也能顶住,但一旦上多实例就必须切换到共享存储。

1.3 依赖引入:手写还是用工具类

验证码图片生成有三条路:自己用 Java 2D 画、用 Hutool 的CaptchaUtil、接第三方行为验证。我的建议是:

  • 追求可控性:自己画。代码量不大,而且你能控制字体、干扰、扭曲程度,踩坑时也容易排查。
  • 快速开发:用 Hutool。它封装了算术、线段、圆圈等多种验证码,几行代码就能用,推荐新人先跑通这一版。
  • 安全性要求高:接专业行为验证。图形验证码在 OCR 面前越来越脆弱,后面第五节我会展开说。

我平时做项目,第一版先用 Hutool 快速验证流程,稳定后再把生成器替换成自定义实现。这样既不会卡在细节上,又能保证最终效果可控。下面给出的代码以自定义实现为主,因为我觉得原理比工具类更重要。

2. 核心实现:从验证码生成到校验的全流程

2.1 基础环境与依赖准备

我用的是 Spring Boot 2.7 + Redis + Hutool(只用来做 Base64 和随机数,不依赖它的验证码模块),JDK 8 以上都行。pom.xml里核心依赖如下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.25</version> </dependency>

需要注意spring-boot-starter-data-redis在 2.x 默认用的是 Lettuce,配置好spring.redis.hostport之后,直接注入StringRedisTemplate就能用。我前几个项目都用StringRedisTemplate,因为它天然和 String 类型的验证码 key/value 匹配,不用额外配置序列化器。

2.2 验证码生成器的实现细节

自定义验证码生成器的核心有四个部分:随机字符串、图片绘制、干扰元素、Base64 输出。

第一,随机字符串。我习惯用 4 位字符,并且从字符池里去掉容易混淆的字符。0/O1/l/I2/Z这类组合,用户在手机上经常看错,一次输不对就开始烦躁。我的字符池定义如下:

private static final String CHAR_POOL = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";

注意里面删掉了IO10。长度选 4 位,是因为 4 位字符组合已经能挡住大多数脚本,又不会给用户造成记忆负担。** 4 位不是拍脑袋定的,我做过对比测试:6 位纯数字的识别难度和 4 位数字字母混合其实差不多,但输入时间长了近一倍,用户流失率明显上升。**

第二,图片绘制。我用BufferedImage画一张宽 120、高 40 的图片,背景色用浅色,文字用深色,保证对比度足够。每个字符随机偏移一点点高度和旋转角度,这样脚本截图后做模板匹配的难度会大不少。核心代码如下:

private BufferedImage createImage(String text) { int width = 120; int height = 40; BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g2d = image.createGraphics(); // 背景 g2d.setColor(Color.WHITE); g2d.fillRect(0, 0, width, height); // 文字 int fontSize = 28; g2d.setFont(new Font("Arial", Font.BOLD, fontSize)); Random random = new Random(); for (int i = 0; i < text.length(); i++) { g2d.setColor(randomColor()); double angle = (random.nextInt(60) - 30) * Math.PI / 180; g2d.rotate(angle, 20 + i * 24, height / 2 + 6); g2d.drawString(String.valueOf(text.charAt(i)), 15 + i * 24, height / 2 + 8); g2d.rotate(-angle, 20 + i * 24, height / 2 + 6); } // 干扰线 + 噪点 for (int i = 0; i < 5; i++) { g2d.setColor(randomColor()); g2d.drawLine(random.nextInt(width), random.nextInt(height), random.nextInt(width), random.nextInt(height)); } for (int i = 0; i < 40; i++) { g2d.setColor(randomColor()); g2d.fillRect(random.nextInt(width), random.nextInt(height), 2, 2); } g2d.dispose(); return image; }

这段代码看起来简单,但有几个细节值得解释:

  • 旋转角度控制在 ±30 度。角度太大用户看不清,角度太小又起不到防 OCR 的作用。
  • 每个字符的颜色单独随机。全用一种颜色,程序写起来简单,但识别器处理起来也简单。
  • 干扰线数量 5 条、噪点 40 个。这个数字我调过很多次,太少没效果,太多图片容易糊成一团。

第三,转 Base64。前端拿图片最方便的方式就是 Base64。我会在服务端把BufferedImage编码成 PNG 格式的字节数组,再转成 Base64 字符串,返回时加上data:image/png;base64,前缀:

public static String toBase64(BufferedImage image) throws IOException { ByteArrayOutputStream os = new ByteArrayOutputStream(); ImageIO.write(image, "png", os); return Base64.getEncoder().encodeToString(os.toByteArray()); }

为什么用 PNG 不用 JPEG?因为验证码是线条和文字组成的图形,PNG 无损压缩,字迹边缘清晰,JPEG 会有压缩伪影,反而影响用户识别。

2.3 存储层设计:为什么要把明文转成哈希

拿到验证码明文之后,我从来不直接存 Redis,而是存加盐哈希。原因很简单:如果 Redis 被拖库或者运维同学在排查数据时不小心把 key 打出来了,明文验证码就是一批可以被批量利用的数据。转成SHA-256之后,即使数据泄露,也没人能从哈希值反推出验证码。代码如下:

public static String encrypt(String text, String salt) { return DigestUtils.sha256Hex(text + salt); }

盐值我用的是 UUID 随机数,和验证码 key 一起存。Redis 里的数据结构是这样:

  • key:captcha:uuid(uuid 返回给前端,作为本次验证码的凭证)
  • value:SHA256(验证码文本 + 盐值)
  • 过期时间:300 秒

这里有一个很容易被忽略的点:验证码校验成功之后,一定要立刻删除这个 key,保证一次性使用。如果不删,攻击者可以反复用同一个验证码,配合脚本暴力尝试其他密码。我在项目中用 Redis 的getAndDelete操作,Java 里面用DefaultRedisScript写个 Lua 脚本,保证「取值 + 删除」是原子的,避免并发请求重复消费。

2.4 接口设计:获取与校验

接口我分成两个:

获取验证码接口:

@GetMapping("/api/captcha") public Result<CaptchaVO> getCaptcha() { String code = generator.generateText(); String salt = UUID.randomUUID().toString(); String hash = encrypt(code, salt); String key = "captcha:" + UUID.randomUUID(); redisTemplate.opsForValue().set(key, hash + ":" + salt, 5, TimeUnit.MINUTES); BufferedImage image = generator.generateImage(code); String base64 = generator.toBase64(image); CaptchaVO vo = new CaptchaVO(); vo.setCaptchaKey(key); vo.setCaptchaBase64(base64); return Result.success(vo); }

校验验证码接口:

@PostMapping("/api/captcha/verify") public Result<Void> verify(@RequestBody CaptchaVerifyRequest request) { String redisValue = redisTemplate.opsForValue().get(request.getCaptchaKey()); if (StringUtils.isBlank(redisValue)) { return Result.fail("验证码已过期,请重新获取"); } String[] parts = redisValue.split(":"); String storedHash = parts[0]; String salt = parts[1]; String inputHash = encrypt(request.getCaptchaCode(), salt); // 删除 key,保证一次性使用 redisTemplate.delete(request.getCaptchaKey()); if (!storedHash.equals(inputHash)) { return Result.fail("验证码错误"); } return Result.success(); }

注意这里我对比的是哈希值,不是明文,所以用equals是安全的。另外,校验失败的场景也应该删除 key 吗?我的做法是:不删。如果用户只是输错一次就要求重新获取,体验太差;但我会加一个「连续失败次数」的计数逻辑,下面第三节展开。

2.5 前端接入与联调要点

前端接入极其简单,核心就是img标签显示 Base64,提交时把captchaKey和用户输入的captchaCode一起传给后端。

<template> <div> <img :src="captchaImg" @click="refreshCaptcha" alt="验证码" /> <input v-model="captchaCode" placeholder="请输入验证码" /> <button @click="submit">登录</button> </div> </template> <script setup> import { ref, onMounted } from 'axios'; import axios from 'axios'; const captchaImg = ref(''); const captchaKey = ref(''); const captchaCode = ref(''); const refreshCaptcha = async () => { const res = await axios.get('/api/captcha'); captchaImg.value = res.data.captchaBase64; captchaKey.value = res.data.captchaKey; }; const submit = async () => { await axios.post('/api/captcha/verify', { captchaKey: captchaKey.value, captchaCode: captchaCode.value, }); // 校验通过后再调登录接口 }; onMounted(refreshCaptcha); </script>

前端有一个经常被忽略的点:点击图片刷新验证码后,旧的 captchaKey 就失效了,但 Redis 里的旧 key 不会立刻消失。如果你在刷新时顺便删掉旧 key,可以省一点存储;不删问题也不大,等 5 分钟过期即可。我为了省心,前端刷新时只请求新验证码,后端靠过期时间兜底。

3. 安全加固与防刷设计

3.1 验证码本身的安全细节

生成器做得再花哨,安全细节不到位也是白搭。我罗列几个容易踩的坑:

第一,验证码接口也要防刷。获取验证码这个接口虽然不涉及业务数据,但可以被脚本拿来疯狂请求,耗 CPU、耗带宽,甚至把 Redis 塞满。我在获取验证码的接口上加了基于 IP 的频控:同一个 IP 一分钟最多请求 10 次,超过就返回友好提示。实现不复杂,用 Redis 的INCR+EXPIRE就能做到:

String countKey = "captcha:limit:" + ip; Long count = redisTemplate.opsForValue().increment(countKey); if (count != null && count == 1) { redisTemplate.expire(countKey, 1, TimeUnit.MINUTES); } if (count != null && count > 10) { return Result.fail("操作过于频繁,请稍后再试"); }

第二,不要把验证码明文放在任何日志里。我有一次排查问题时,顺手在 Controller 里log.info("captcha={}", code),上线后用户投诉验证码总能被“猜到”,后来发现是日志采集系统把信息同步到了 ELK,等于把验证码明文送给了所有能看日志的人。从那次之后我就定了规矩:验证码日志最多记录 key 和校验结果,明文一概不打印。

第三,校验失败要递增计数。同一 captchaKey 如果连续输错 3 次,我直接删除 Redis key,强迫用户重新获取。这一步是防暴力尝试的底线,否则攻击者可以用一个验证码配合字典无限试。

3.2 业务接口的防刷:验证码只是第一道闸

把验证码校验放在登录接口里,还有一个容易被忽略的问题:验证码通过之后,登录接口本身也该做频控。我见过很多项目,验证码做得漂亮,但登录接口没有任何限制,攻击者先拿一个验证码通过校验,然后立刻对密码字段做暴力穷举,因为密码对不对验证码管不着。

我的方案是两级防刷:

  • 第一级:登录接口同一个账号 5 分钟内最多失败 5 次,第 6 次开始锁定账号 15 分钟。这个用 Redis 的INCR实现,key 是login:fail:username
  • 第二级:登录接口同一个 IP 每分钟最多 20 次请求。超过就要求重新过验证码,甚至返回滑块验证码。

这两级防刷配合验证码的一次性消费,基本能挡住绝大多数脚本攻击。

3.3 分布式部署下的验证码共享

前面提到过,验证码不要放 session,要用 Redis。这里再补充一个分布式场景的细节:多实例部署时,获取验证码和校验验证码可能落在不同的实例上。如果验证码存在本地内存里,第二个请求到了另一台机器就查不到了。我第三次重构时就是因为在 Nginx 负载均衡下忘了这一层,测试人员频繁反馈“图片和校验对不上”。

解决办法就一句话:所有验证码状态全部放到 Redis,业务机器保持无状态。这样一来,水平扩容、重启服务都不会影响正在输入验证码的用户。

3.4 与登录流程、JWT 的整合方式

如果你的登录流程用了 JWT,验证码校验建议放在登录接口内部,而不是单独暴露一个校验接口。这样前端只需要调一次登录接口,后端先校验验证码,通过后再校验用户名密码,最后签发 JWT。省一次网络往返,也能避免“验证码校验通过,但登录请求被中间人篡改”的割裂场景。

时序大致是这样:

  1. 前端获取验证码,拿到captchaKeycaptchaBase64
  2. 用户输入用户名、密码、验证码,一次性提交到/api/auth/login
  3. 后端取出captchaKey+captchaCode,先比对 Redis 里的哈希。
  4. 验证码通过后,再查用户、比对密码。
  5. 全部成功后签发 JWT,返回给前端。

我见过一些项目把验证码校验写在网关层,理论上也能做,但网关层拿不到业务的用户数据,做不了“账号锁定”和“失败次数统计”,最后还是要回源到业务服务,不如直接放在登录接口里干净。

4. 真实项目中的常见问题与排查实录

4.1 常见问题速查表

现象大概率原因解决办法
前端图片不显示Base64 字符串里被加入了换行符,或缺少data:image/png;base64,前缀去掉换行,严格按标准格式返回
Linux 服务器上图片空白/方块系统没有 Arial 字体改用系统自带字体或打包字体文件
本地正常,线上校验总失败分布式 session 不共享,或 Redis key 环境隔离没做好统一走 Redis,确认 key 前缀一致
验证码一直提示过期服务器时间不准,或 Redis 过期时间设置过短校准 NTP,过期时间根据业务流程设 3-5 分钟
并发重复提交成功校验和删除不是原子操作用 Lua 脚本保证「取值 + 比对 + 删除」原子性
验证码图片一大片噪点干扰元素数量过多把干扰元素数量调低,控制在 40-80 之间

4.2 印象深刻的三个事故

第一个事故:Base64 换行。有一次前端同事反馈验证码图片偶尔不显示,我查了很久,发现是 Java 的Base64.getEncoder().encodeToString()在某些网关环境下返回的字符串被插入了换行符。标准 Base64 有时会被格式化为多行,但前端<img>src属性不允许有换行。解决办法是在后端统一做一次base64.replaceAll("\\n", ""),把所有换行清掉。

这个坑特别容易出现在自己拼 HTML、自己拼 JSON 的架构里,如果用了成熟的 JSON 序列化框架,换行问题基本不会暴露,但一旦出现,排查成本极高。我的建议是无论用不用框架,都在返回给前端之前清理一遍换行,成本几乎为零。

第二个事故:Linux 字体缺失。本机开发时图片显示一切正常,部署到 CentOS 服务器之后,验证码图片上只有一排方框,字全没了。排查发现是服务器没有安装 Arial 字体,Java 的new Font("Arial", Font.BOLD, 28)在找不到字体时,会 fallback 到一个不存在的字体,最终画出来就是空方块。

解决办法有两个:

  • 在服务器上安装字体包:yum install fontconfig,然后把字体文件放到/usr/share/fonts/下。
  • 更推荐的做法:在项目 resources 里放一个开源字体文件,运行时加载。这样换服务器、上容器都不依赖系统环境。

我用的是第二种,特别适合 Docker 部署。字体文件放在src/main/resources/fonts/下,代码里用Font.createFont加载,还能顺便解决中文字体不统一的问题。

第三个事故:Redis 序列化器不一致。有一次开发环境验证码死活校验不过,后来发现是用了RedisTemplate<String, String>但没指定序列化器,默认 JDK 序列化把字符串序列化成了带类型前缀的二进制,而获取的时候又被当成普通字符串读出来,导致 key 对不上。换成StringRedisTemplate之后一切正常。

这个问题的本质是:Redis key 和 value 的序列化方式必须全局一致。如果你在一个服务里同时用了多个 RedisTemplate,一定要确认它们的 keySerializer 和 valueSerializer 一模一样,不然会出现“数据明明存在,但读出来是 null”的诡异现象。

5. 扩展:从图形验证码升级到滑块验证码

5.1 滑块拼图验证码的实现要点

图形验证码再怎么加干扰线,也挡不住成熟的 OCR 识别。我在一个用户量比较大的项目里,上线一个月就遇到了脚本识别通过的情况,后来不得不升级成滑块拼图验证码。滑块验证码的原理不复杂:

  1. 后端选一张背景图,随机生成一个缺口位置。
  2. 把背景图裁出一块拼图,同时生成带缺口的背景图。
  3. 前端把拼图拖到缺口位置,上传拖动轨迹。
  4. 后端校验轨迹的合理性:拖动距离是否接近缺口位置、轨迹是否有停顿、是否像真人操作。

Spring Boot 里实现这个,核心代码其实不多,但有两个难点:

  • 缺口位置必须是后端生成的随机坐标。不能由前端传过来,否则直接伪造坐标就能绕过。
  • 轨迹校验不能只看终点。真人拖动会有起步加速、中间停顿、末尾校准,机器拖动是匀速直线。我当时的简单策略是计算整条轨迹的加速度方差,低于阈值就判为机器。

如果不想自己实现滑块,国内主流的做法是接第三方行为验证服务,服务端只做二次校验接口。但接第三方也有成本,需要申请 key、配置回调、买授权,对内部管理系统来说有点过度。我的建议是:C 端高并发场景直接接专业的,B 端后台管理自己用图形验证码 + 频控就够了。

5.2 短信验证码的正确落地方式

短信验证码和图形验证码不同,它直接和钱挂钩(每条短信都要向运营商付费),所以防刷的压力更大。我的落地方案里有几条硬性要求:

第一,发送前必须先过图形验证码或滑块验证码。很多项目图省事,短信接口直接暴露,结果被脚本刷到运营商封号。正确顺序是:前端先过滑块/图形验证,后端拿到通过凭证后再请求发送短信。

第二,同一个手机号 60 秒内不能重复发送。这个用 Redis 天然支持:

String key = "sms:limit:" + phone; Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", 60, TimeUnit.SECONDS); if (Boolean.FALSE.equals(success)) { return Result.fail("发送太频繁,请稍后再试"); }

第三,验证码有效期 5 分钟,每天每个手机号最多 10 次。我习惯把次数计数单独存一个 key,过期时间设为 24 小时,发送前先INCR

这里想提醒一句:短信验证码涉及资费,务必在接口层面做好频率控制和人机校验,不要做短信轰炸的帮凶。同时,不要把验证码明文放在日志里,更不要和“接码”这类灰产沾边。合规和安全的底线,比功能本身更重要。

收尾:一点个人经验

验证码这个功能,入门容易,做扎实很难。我前后踩过 Base64 换行、字体缺失、Redis 序列化不一致、并发重复消费这些坑,最后总结下来,最核心的三条经验是:验证码状态只放 Redis,不放 session;校验之后立刻删除,保证一次性;获取验证码的接口也要做频控,不能裸奔。另外一个小技巧,如果你用的是 Hutool,可以用cn.hutool.captcha.CaptchaUtil.createLineCaptcha(120, 40, 4, 40)快速生成一张验证码图片作为原型,先把整条流程跑通,再替换成自定义生成器。初期别在花里胡哨的干扰线上浪费时间,业务流程通畅才是第一优先级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询