ARTICLE DETAIL

资讯详情

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

SpringBoot验证码实现全攻略:从生成存储到安全校验

SpringBoot验证码实现全攻略:从生成存储到安全校验 1. 验证码在SpringBoot项目中的角色不是装个样子就完事先说个我自己的经历。以前做某管理后台的登录功能产品提了个需求登录页加个验证码。我当时心想这不就是引个依赖、写个接口、前端贴张图的事吗结果上线第一天就被人吐槽验证码太难看清了第二天收到报警日志——有人在用脚本疯狂尝试登录接口。这时候我才意识到验证码这个看似人畜无害的小功能在SpringBoot项目里落地的时候坑比想象的多得多。这篇文章我就围绕SpringBoot框架下的验证码实现把图形验证码从生成、存储、校验到前端对接、生产环境踩坑、以及滑块验证码和短信验证码的集成思路都仔细捋一遍。适合正在写登录模块的后端开发也适合全栈刚入门、被验证码折磨过的同学。先说一个核心判断验证码的本质是一道人机区分的关卡。它不是用来防攻击者的因为攻击者有的是手段绕过去它是用来提高自动化脚本的调用成本。换句话说验证码做得再花哨也防不住一个愿意花时间逆向你前端逻辑的工程师但对付那种拿公开漏洞扫描器批量扫接口的脚本足够了。所以在SpringBoot项目里设计验证码模块第一件事不是急着写代码而是想清楚三件事验证码放在哪个流程节点——登录、注册、找回密码、发短信场景不同校验时序完全不同验证码的凭证存哪里——Session、本地内存、Redis决定了能不能撑起集群部署验证码失败后的处理策略——是无限重试还是锁定账号直接关系到接口安全性。这三个问题我在后面每一节里都会展开说。现在我先把最常见的图形验证码实现方案从零到尾拆一遍。2. 图形验证码的完整落地从生成接口到校验链路图形验证码在SpringBoot项目里的实现业界最省事的做法是直接用Hutool的CaptchaUtil或者Google的Kaptcha。我个人的习惯是Hutool因为它的API简洁、样式可调而且不需要额外维护xml配置。你要是让我从原生BufferedImage画验证码我会劝你别折腾——除非你想完全控制渲染效果否则写出来无非是Hutool的内部逻辑换个姿势重写一遍。2.1 生成接口还能怎么优化用户输入体验先看最核心的生成接口。用Hutool实现一个简单的算术验证码或者字符验证码其实只差这几行代码GetMapping(/captcha) public ResultCaptchaImageVO captcha(RequestParam(uuid) String uuid) { LineCaptcha captcha CaptchaUtil.createLineCaptcha(130, 48, 4, 30); String code captcha.getCode(); // 实际使用中建议统一用小写存储比较 StringRedisTemplate .opsForValue().set(captcha: uuid, code.toLowerCase(), 5, TimeUnit.MINUTES); String base64 captcha.getImageBase64Data(); // 你拿到的就是 data:image/png;base64,xxx return Result.ok(new CaptchaImageVO(uuid, base64)); }这里需要注意接口入参我放了一个uuid这个uuid由前端在页面加载时生成可以用crypto.randomUUID()传给后端作为验证码存储的key。为什么不直接让后端生成一个随机key返回给前端因为那样也完全没问题两种方式都有团队在用前端传uuid好处是前端可以先把这个uuid绑定到表单提交的隐藏字段里流程上少一次后端返回key的依赖后端生成key好处是前端不用管模块实现页面加载时直接调接口拿到{key, imageBase64}表单里带上key即可。我两种都试过结论是看你们前端框架的习惯。用Vue或者React的话前端自己生成uuid更顺手后端模板渲染比如Thymeleaf的话后端生成key更省事。没有谁绝对更优。2.2 校验接口的设计细节一次校验就作废生成只是一半真正的重点是校验。SpringBoot里我习惯这样处理登录请求PostMapping(/login) public ResultString login(Valid RequestBody LoginRequest req) { String cacheCode stringRedisTemplate.opsForValue().get(captcha: req.getUuid()); if (StrUtil.isBlank(cacheCode)) { return Result.fail(验证码已过期请刷新重试); } if (!cacheCode.equals(req.getCode().toLowerCase())) { return Result.fail(验证码错误); } // 校验成功立即删除防止同一个验证码被重复使用 stringRedisTemplate.delete(captcha: req.getUuid()); // 继续走账号密码校验逻辑 }这里有一个非常容易忽略的原则验证码只要校验过一次无论成功失败都必须立刻失效。成功之后删除好理解但为什么失败也要删因为如果不删攻击者可以拿着同一个验证码配合脚本去尝试一万个密码——验证码只会挡住第一次请求后面的所有请求都是带着同一个通过校验的验证码在打你的密码接口验证码形同虚设。实际开发里我还见过一种改法不直接删除而是记一个captcha:used:{uuid}的标记等原有缓存到期自动消失。这主要是为了做审计日志或者前端友好重试。但从安全角度判断直接删就是最好的策略。2.3 校验时的干扰线和字体要兼顾真实用户和机器识别有些团队做验证码为了极致的防识别把图片做得很复杂扭曲、重叠、渐变色、随机字体全堆上。这其实掉进了一个陷阱——验证码是给人看的不是给机器看的。以Hutool的LineCaptcha为例它默认的干扰线条数可以在构造器里调我觉得130x48的尺寸下30条线是上限。线条再多人眼识别率就直线下降用户输错三次就开始骂产品了。我实测下来线条数控制在20到30之间字体粗一点字符间距拉开识别成功率最高。另外还有一个小经验验证码字符集尽量避开0、o、O、1、l、I这些容易混淆的字符。Hutool默认的字符源是abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789已经主动去掉了i、l、o、O、1、0这个默认设计很科学我们不需要自己再改。2.4 存储选型从Session到Redis的迁移理由我把验证码存储单独拎出来说是因为这是很多SpringBoot初学者的分水岭。最省事的是放Session里毕竟HttpSession天然就是key-value结构session.setAttribute(captchaCode, code); session.getAttribute(captchaCode);单体部署、单节点访问、不搞负载均衡的时候这套方案完全够用而且最简。但只要你的应用被Nginx架在多个Tomcat后面做负载均衡麻烦就来了用户在节点A生成的验证码存在节点A的Session里下一次请求被转发到节点B节点B的Session里什么都没有于是疯狂提示验证码错误。哪怕用Spring Session解决Session同步也是多绕一圈。直接上Redis就干净多了。一个带5分钟过期时间的key搞定一切集群无状态任意节点都能校验。代价无非是你要多一个Redis依赖。如果你的项目本来就用Redis做缓存那这个方案没有任何额外负担。我还建议大家给验证码key加上业务前缀例如captcha:login:{uuid}、captcha:sms:{phone},方便排查问题时用KEYS captcha:*扫一遍看有多少验证码在被生成也方便在Redis管理端直接删掉某个异常的验证码。3. 验证码不显示的排查链路从URL、Base64到接口直接白屏这个标题下看的搜索热词里有一大堆跟验证码不显示验证码不对相关的问题可见在SpringBoot项目里这真的是高频坑。我根据实际Debug经验把排查链路整理成下面这个顺序基本按这个走90%的问题都能定位。3.1 第一排接口直接访问看看返回了什么排查这类问题我从来不先看前端代码先直接用浏览器或者Postman访问验证码接口地址。这一步能立刻区分问题出在前端还是后端。如果接口直接报401或者404先检查SpringSecurity或者拦截器是否把验证码接口路径给拦了。尤其是SpringSecurity的配置有过一次经验验证码接口应该在permitAll()名单里但登录接口在authenticated()里前端页面加载验证码时验证码接口没放行导致拿不到图片。这类问题在Swagger调试时通常不会暴露因为Swagger本身也经常被放行。如果接口返回200但imageBase64字段是null那问题基本在后端要么是Hutool的生成逻辑报错被全局异常处理器吞了要么是CaptchaUtil初始化出现问题。看一下后端日志有没有IllegalArgumentException之类的报错即可。如果是500多数情况是你的全局异常处理器没兜住底层异常日志会打得很详细。3.2 第二排后端正常但前端图裂 / 白屏后端返回了正常的Base64字符串前端还是显示不出来这时候要分别看两种情况情况一返回的是纯Base64字符串不带MIME前缀很多新手前端直接写img srcdata:image/png;base64,xxx但后端返回的imageBase64其实去掉了data:image/png;base64,前缀于是浏览器把xxx当成相对路径去请求自然404。解决办法是前端拼接前缀或者后端直接在返回值里带上完整前缀。Hutool的getImageBase64Data()方法返回的是带data:image/png;base64,前缀的完整串建议后端接口直接返回这个。情况二Base64过长或包含换行符Java的Base64编码默认会加入换行符每76个字符一个\n有些后端框架会自动去掉有些不会。如果前端处理的时候没做好图片就会裂掉或者变形。遇到这种问题用Hutool的Base64.encode或者Java内置的Base64.getEncoder()再手动去掉换行符这一步不能省。3.3 第三排图出来了但一直提示验证码错误图片能看清输入也正确后端就是报错这种最折磨人。我按可能性从高到低列几个存储key对不上前端每次刷新验证码都生成新的uuid但表单提交时带的uuid还是旧的。检查一下前端是否在刷新验证码后同步更新了隐藏字段。这个坑占了我遇到此类问题的一半以上。前端的操作逻辑很简单就是点击刷新图片时重新调用生成接口拿到新的uuid和base64同时更新隐藏input但很多同学只更新了src属性忘了更新uuid。大小写不一致验证码生成时是混合大小写但用户输入的时候可能全部输成了小写。所以我前面特别提到生成后存Redis之前统一转小写校验时也转小写这样就避开大小写不一致的人类常见失误。Redis过期太快有些团队把过期时间设置成1分钟用户刚看到验证码看了好几秒再输入刚好卡在边界过期。我一般在生产环境设置5分钟加上最多5次错误重试限制安全性和体验都能接受。分布式缓存不一致如果你用的是Spring Session Redis但Redis又做了主从写到了master然后读到了延迟高的slave也会偶发明明刚存进去刷新就没了。这种情况要用Redis Cluster或者确保读写都路由到master。3.4 部署环境下的特殊现象集群、网关、HTTPS混用还有一类验证码问题本地开发一切正常上了服务器就出现。我在生产环境踩过这几个反向代理没有透传真实IP导致验证码接口被你自己的限流过滤器误杀。比如你给验证码接口做了每IP每分钟10次限制Nginx没配置proxy_set_header X-Forwarded-For后端拿到的全是内网网关IP限流就完全失效或者误伤所有人。HTTPS混合内容页面是HTTPS但验证码接口是HTTP浏览器会拦截图片加载。部署时检查一下Nginx或网关是否统一开启了SSL证书尤其注意前后端分离项目里前端页面和后端接口用了不同的二级域名时证书是否覆盖了所有子域。CDN缓存了验证码图片如果你是纯的前端直连后端不走CDN还好但有些团队喜欢把静态图片走CDN验证码这种高频变化的图片一旦被CDN缓存就会出现所有用户看到同一张验证码。解决办法是响应头加Cache-Control: no-store这个头很多同学平时配置静态资源时不会给验证码加上容易漏。4. 滑块验证码与短信验证码绕过图形码的进阶姿势图形验证码只是人机校验的基础款。现在主流产品的登录注册页图形验证码大多退居二线取代它的是滑块验证码、点选验证码、无感验证码。以及在用户注册和找回密码场景里短信验证码更是标配。这一节我说说这两类验证码在SpringBoot项目中的集成思路和选型判断。4.1 滑块验证码为什么建议直接用现成的而不是自己造滑块验证码的原理前端生成一张带缺口的背景图用户拖动滑块补全缺口前端通过监听拖动轨迹计算滑块位移把位移值传给后端后端验证位移是否在容差范围内。听起来不难但你真去做会撞上一连串问题背景图缺口位置的随机生成算法、滑块轨迹的采样、模拟真人拖动与机器拖动的行为特征区别、如何识别selenium/playwright之类的自动化工具、容差范围如何设置不误伤真人等等。这个领域的护城河不在接口逻辑而在多年积累的恶意流量行为库。这就是为什么业界的方案是直接用第三方验证码服务或成熟组件。SpringBoot项目集成的常见路线有三条接入云厂商的验证码服务——阿里云、腾讯云、极验都有现成的验证码产品前端集成JS SDK后端调API校验token。优点是识别准确率高、不用自己维护风险库缺点是商业付费、依赖第三方网络。这个方案适合重视安全的正式项目。使用开源的无后端/轻后端滑块组件——有一些前端组件比如用Canvas模拟、后端只校验随机token安全性很弱只适合内部系统挡挡手误。完全自己实现一个简化版——只做最基础的图上画个缺口拖动校验位移配合前端把滑动过程轨迹timestamp加密传给后端做简单行为校验能挡住浏览器直接发请求的脚本挡不住懂逆向的。这个方案对于部署在内网的管理后台够了。我自己的建议很现实如果你的项目要面向公网、有真实用户和资金往来别自己造滑块直接用云厂商商业服务省下的时间远远超过那点费用成本。如果是内部系统、低价值业务才考虑自己实现一个简化版。4.2 短信验证码的时序设计与防轰炸短信验证码在SpringBoot里的实现核心逻辑其实不复杂PostMapping(/sms-code) public ResultVoid sendSmsCode(RequestBody SmsCodeRequest req) { String phone req.getPhone(); String key sms:code: phone; String countKey sms:count: phone; Long remaining stringRedisTemplate.getExpire(key, TimeUnit.SECONDS); if (remaining ! null remaining 0 remaining 110) { return Result.fail(发送太频繁请稍后再试); } // 每天最多发送10条 Long sentCount stringRedisTemplate.opsForValue().increment(countKey); if (sentCount ! null sentCount 10) { return Result.fail(今日发送次数已用完); } // 设置countKey的过期时间为当天24点 String code String.valueOf((int) ((Math.random() * 9 1) * 100000)); // 6位随机码 stringRedisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 调用短信服务商API发送 smsService.send(phone, 您的验证码是 code 5分钟内有效。); return Result.ok(); }这里有两个App开发中经常被问到的细节要不要在验证码key里同时存手机号要。我见过不少团队的短信验证码Redis key只存一个随机字符串为key、验证码为value然后把key返回给前端。这种做法能跑通但隐患是如果短信服务商回调或日志记录里只记录了手机号你很难反查到某个手机号当前的有效验证码是什么。把手机号直接作为key的一部分运维排查时方便得多。发送频率限制怎么做短信验证码最容易出问题的就是被刷接口攻击者拿一批手机号天天触发短信接口你账户的短信余量消耗极快。所以防轰炸不能只做同一个手机号60秒内只能发一次还要做同一IP每天最多发送的短信条数限制同一设备指纹如果有前端上报每天限次发送成功之后短信接口本身要设置全局限流。这些限流在SpringBoot里实现最干净的方式是用拦截器或者AOP不要写在业务Service里否则每个调用短信接口的地方都要重复写一遍限流判断代码会越来越乱。4.3 无感验证码容易被忽视的新方向还有一种不上图、不输入的验证方式叫无感验证。它的思路是收集用户浏览器指纹、点击行为、鼠标移动轨迹等判断是不是真人。这类方案在云厂商里有现成SDK前端加载时自动执行用户无感知。但这个方向门槛高不太适合中小团队自己搭。我只建议在验证码错误率过高、严重影响用户转化率的业务里考虑接入无感验证。它能显著提升用户体验但前提是你的业务量足够大、安全团队能持续分析和调优模型。5. 验证码模块的安全细节那些容易被忽略的边角验证码看起来只是登录模块里的一小环但它牵扯到的安全隐患可不少。这一节我说几个我反复在项目中强调的细节。5.1 验证码必须和账号、行为绑定而不是只证明你是人图形验证码传统思路是只要你是人就能通过。但在登录接口里验证码只证明当前有人类在操作不能证明这个人类是账号的主人。所以更安全的做法是验证码通过后只是在服务端放行一次登录尝试如果这个账号连续尝试多次密码错误仍然要锁定或强制等待。验证码不能替代密码防爆破策略两者是平行关系。打个比方验证码是超市门口的保安——确认你是人放你进店密码是柜台收银员——你拿出真金白银才让你带走东西。保安管不了你偷东西收银员才管。SpringBoot里实现连续失败锁定用Redis的INCR就很方便String failKey login:fail: username; Long failCount stringRedisTemplate.opsForValue().increment(failKey); if (failCount 1) { stringRedisTemplate.expire(failKey, 15, TimeUnit.MINUTES); } if (failCount 5) { return Result.fail(账号已锁定请15分钟后再试); }5.2 全局过滤器一个容易踩的安全坑搜索热词里有一条很有意思springboot项目全局过滤器处理上传pdf文件时xss攻击。这说明很多人在做SpringBoot安全加固时会去写全局过滤器但有坑。如果你写了一个OncePerRequestFilter用来过滤所有请求里的特殊字符请注意它同样会过滤验证码接口的请求。如果过滤器把验证码参数或者验证码图片Base64里的、/、这类字符给转义了前端传过来的验证码就和你生成时对不上用户永远提示验证码错误。这类问题排查起来特别隐蔽因为日志里看不出异常。我的排查建议是遇到验证码输入明明正确但一直报错的诡异问题除了检查正常链路把全项目的Filter、Interceptor、AOP切面全部列一遍确认有没有对请求参数做改写。我曾经碰到过一次过滤器把中文括号转成了英文括号恰好有个账号的用户名里带了中文括号导致那个人所有的登录请求在验证码环节就全部失败。5.3 验证码接口的并发问题并发情况下同一个验证码可以被同时提交N次吗如果用户开了好几个浏览器标签页每个标签页各自生成一个uuid那没问题。但如果前端代码有Bug导致多个标签页共用同一个uuid那后端校验的逻辑就是第一个请求通过后来的请求全被删掉key导致失败用户就会觉得验证码明明对的为什么总是过不去。还有一个容易被忽略的并发场景用户点击登录按钮时请求还没返回手快点又点了一次两个请求带着同一个验证码打过来。即使后端代码里做了校验成功后删除key在高并发情况下两个请求可能同时读到同一个cacheCode都进入校验成功的分支然后都去删同一个key。这样两次请求都能通过验证码虽然未必会造成严重事故但防御上来说建议在校验逻辑里加上使用SetNX或Lua脚本原子操作来保证验证码只能被成功校验一次。简单点说就是要把读取-比对-删除这个过程做成原子的不要拆成三步在并发环境下执行。6. 我习惯的SpringBoot验证码工程结构最后分享一个我在实际项目里验证过的工程组织方式方便直接抄。我用的是Maven多模块如果是单体项目在controller、service、config里分别加相关类就好。├── controller │ └── CaptchaController.java // 图形验证码生成、短信验证码发送 ├── service │ ├── CaptchaService.java // 生成验证码、校验验证码、删除验证码 │ └── SmsCodeService.java // 短信验证码发送、频控、校验 ├── config │ └── RedisConfig.java // RedisTemplate/StringRedisTemplate配置 ├── interceptor │ └── RateLimitInterceptor.java // 验证码接口限流 └── filter └── XssFilter.java // 如果做XSS过滤注意白名单排除验证码接口其中CaptchaService建议做成接口因为不同业务方可能要用不同类型的验证码——图形验证码、算数验证码、短信验证码实现类之间可以随时切换也方便测试时用Mock实现替换。我习惯把验证码的服务抽象成生成和校验两个动作而且永远只暴露业务层接口给Controller不在Controller里直接操作Redis。这样做的原因是Controller只负责参数接收和响应包装业务逻辑收敛在Service层后面就算把Redis替换成Caffeine内存缓存也只是改ServiceImplController一行不动。另外一个小的工程建议是验证码相关的常量全部集中在一个类里比如过期时间、key前缀、错误重试次数上限、验证码长度等。不要散落在各个方法里不然三个月后你自己回来改代码会发现有个地方写的是5另一个地方写的是300000毫秒根本分不清哪个是过期时间。7. 最后的经验和几个如果再让我做一遍的选择验证码模块做完我又复盘了几次有几个决策如果再让我重新选一次依然不会变验证码存储一定会选Redis而不是Session即使单体部署也一样。因为Session方案给了自己一个暂时够用的错觉一旦后面上多实例”验证码时好时坏“就会成为常态到时候再迁移是伤筋动骨的。Redis的引入成本低到可以忽略没必要赌将来不会集群化。图形验证码的复杂度一定会保持人眼看清楚优先。我见过太多团队沉迷于把验证码做得越来越难看到头来只能是用户流失率和客服投诉率上升。验证码本质是成本控制不是艺术品。短信验证码和滑块验证码一定会按安全等级预算分开决策。短信验证码自己写逻辑、接服务商API完全可以掌控滑块验证码这种涉及恶意流量行为分析的我敬而远之直接选商业产品或开源成熟方案。最后再分享一个小技巧是我在对接第三方验证码服务时常用的套路不要把第三方验证码服务的凭据硬编码在配置里而是放到配置中心或者环境变量里。很多团队在验证码模块上线初期没问题后面因为密钥泄露被刷了一大批短信。另外第三方验证码服务的校验接口调用要做好超时控制不要默认无限等待否则第三方服务抖动时你的登录接口响应时间会直接拖垮页面体验。验证码这个功能说难不难说简单也不简单。关键不是把验证码画出来而是把生成、存储、校验、限流、排查这一整条链路想清楚。希望这篇文章能把你在SpringBoot项目里遇到的大部分验证码问题一次性说透。有具体场景细节需要讨论的也欢迎留言说说你踩到的独特坑我看到了会尽量回复。提示如果你正在做的是前后端分离项目强烈建议在联调阶段就手测一遍刷新验证码后提交表单这个操作这样能把uuid未同步这个最大的坑扼杀在开发期而不是等上线后被用户一个一个踩出来。
返回列表