Spring Boot 验证码实战:从生成到防刷的完整设计
验证码这个东西看着不起眼但真要在 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 自带的SETEX、INCR、EXPIRE天然适合做频率控制和过期清理不需要额外写定时任务。如果你的项目还没有 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependency需要注意spring-boot-starter-data-redis在 2.x 默认用的是 Lettuce配置好spring.redis.host、port之后直接注入StringRedisTemplate就能用。我前几个项目都用StringRedisTemplate因为它天然和 String 类型的验证码 key/value 匹配不用额外配置序列化器。2.2 验证码生成器的实现细节自定义验证码生成器的核心有四个部分随机字符串、图片绘制、干扰元素、Base64 输出。第一随机字符串。我习惯用 4 位字符并且从字符池里去掉容易混淆的字符。0/O、1/l/I、2/Z这类组合用户在手机上经常看错一次输不对就开始烦躁。我的字符池定义如下private static final String CHAR_POOL ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789;注意里面删掉了I、O、1、0。长度选 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 里的数据结构是这样keycaptcha:uuiduuid 返回给前端作为本次验证码的凭证valueSHA256(验证码文本 盐值)过期时间300 秒这里有一个很容易被忽略的点验证码校验成功之后一定要立刻删除这个 key保证一次性使用。如果不删攻击者可以反复用同一个验证码配合脚本暴力尝试其他密码。我在项目中用 Redis 的getAndDelete操作Java 里面用DefaultRedisScript写个 Lua 脚本保证「取值 删除」是原子的避免并发请求重复消费。2.4 接口设计获取与校验接口我分成两个获取验证码接口GetMapping(/api/captcha) public ResultCaptchaVO 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 ResultVoid 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 :srccaptchaImg clickrefreshCaptcha alt验证码 / input v-modelcaptchaCode placeholder请输入验证码 / button clicksubmit登录/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 的INCREXPIRE就能做到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。省一次网络往返也能避免“验证码校验通过但登录请求被中间人篡改”的割裂场景。时序大致是这样前端获取验证码拿到captchaKey和captchaBase64。用户输入用户名、密码、验证码一次性提交到/api/auth/login。后端取出captchaKeycaptchaCode先比对 Redis 里的哈希。验证码通过后再查用户、比对密码。全部成功后签发 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 序列化器不一致。有一次开发环境验证码死活校验不过后来发现是用了RedisTemplateString, String但没指定序列化器默认 JDK 序列化把字符串序列化成了带类型前缀的二进制而获取的时候又被当成普通字符串读出来导致 key 对不上。换成StringRedisTemplate之后一切正常。这个问题的本质是Redis key 和 value 的序列化方式必须全局一致。如果你在一个服务里同时用了多个 RedisTemplate一定要确认它们的 keySerializer 和 valueSerializer 一模一样不然会出现“数据明明存在但读出来是 null”的诡异现象。5. 扩展从图形验证码升级到滑块验证码5.1 滑块拼图验证码的实现要点图形验证码再怎么加干扰线也挡不住成熟的 OCR 识别。我在一个用户量比较大的项目里上线一个月就遇到了脚本识别通过的情况后来不得不升级成滑块拼图验证码。滑块验证码的原理不复杂后端选一张背景图随机生成一个缺口位置。把背景图裁出一块拼图同时生成带缺口的背景图。前端把拼图拖到缺口位置上传拖动轨迹。后端校验轨迹的合理性拖动距离是否接近缺口位置、轨迹是否有停顿、是否像真人操作。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)快速生成一张验证码图片作为原型先把整条流程跑通再替换成自定义生成器。初期别在花里胡哨的干扰线上浪费时间业务流程通畅才是第一优先级。