多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java企业级验证码实战:从图形到滑块、Redis存储与防刷限流全解析

Java企业级验证码实战:从图形到滑块、Redis存储与防刷限流全解析 验证码这东西看起来就是“一张图、一串码”但真正放到企业级Java应用里它涉及的东西远比大多数人想象得多防暴力破解、防批量注册、防短信轰炸、防OCR识别还要兼顾用户体验和分布式一致性。最近整理一个部署在云环境上的Java项目时我把登录、注册、短信发送这几条链路的验证码实现从头到尾重新过了一遍结合这几年踩过的坑写一篇尽量完整的实操总结。如果你正在负责账号体系接口安全、要设计登录注册模块或者想在Spring Boot项目里集成图形验证码和滑块验证码这篇文章应该能帮到你。不写教科书式的东西直接给结论、给代码、给排查思路。后面会涉及Redis、JWT、限流、行为验证码组件这些企业级常用组件也会把每个设计背后的理由讲清楚。1. 验证码在企业安全体系中的定位与设计思路1.1 验证码到底在防什么从暴力破解到薅羊毛验证码的核心目的不是“让用户体验变差”而是提高自动化攻击的成本。没有验证码的登录接口攻击者可以用脚本无限尝试密码这是典型的暴力枚举手里有撞库数据的话还能做批量撞库。注册接口没有验证码就会被人批量注册小号轻则污染用户数据重则直接被用于后续的营销活动、刷单甚至黑产链条。短信接口没有验证码危害更直接攻击者把你的短信通道变成“短信轰炸机”一边打爆受害者手机一边烧企业的短信费用。企业级安全方案里验证码从来不是孤立功能而是整体防线中的一道门槛。判断验证码做得好不好我习惯看一个指标过验成本。攻击者绕过一次验证需要花多少时间、多少钱、多少算力。早期图形验证码能挡住大量小白脚本但OCR识别技术成熟之后普通四位数验证码的识别成功率已经很高甚至有一些现成的识别服务连训练模型都不用自己搞。这也是为什么现在的企业级应用普遍转向滑块验证码、行为验证码或者把图形验证码做得更复杂、更难被机器识别。另外要提醒一点验证码不是做得越难越好。很多内部系统把验证码搞到人类都识别困难结果用户流失严重运维半夜接投诉电话。安全性和易用性需要平衡登录场景可以用滑块注册场景可以上图形验证码支付等高风险操作再加短信验证码。1.2 验证码类型选型怎么搭配才合理从Java实现的视角来看企业里最常见的验证码可以分成四类图形验证码后端生成图片用户识别填写。实现成本最低但用户输入成本高且容易被OCR破解。滑块/行为验证码前端收集用户拖拽轨迹后端分析行为特征。用户阻力小安全性明显优于传统图形验证码是目前主流方案。短信/邮件验证码绑定手机号或邮箱具备强身份确认能力适合支付、改密、注册等敏感场景。无感验证靠设备指纹、IP信誉、浏览行为做风险评分用户感觉不到验证存在通常作为前置风控。实际项目里很少只用一种。我的建议是分级搭配低风险操作做无感风控中风险操作放一个滑块验证码高风险操作叠加短信验证码形成“前置验证码做初步拦截后置验证码做关键确认”的结构。比如登录接口可以用滑块先验人机登录成功后签发短时效Token但如果系统检测到该账号在异地、新设备登录再强制要求短信验证码二次确认这就是“自适应验证”的思路安全性高且大部分正常用户无感知。2. 基于Java的验证码核心实现细节2.1 图形验证码经典方案与易被忽视的问题图形验证码虽然技术含量不如行为验证码但仍有大量存量系统在用而且它足够轻量适合做注册、登录的兜底人机校验。Java里实现图形验证码有几条路直接用java.awt手动画图用Hutool的CaptchaUtil快速生成接第三方图形验证码服务。手动画图的思路很直接创建一张BufferedImage在画布上画背景色、画干扰线再随机生成几个字符画上去最后转成Base64返回前端。代码量不大但有几个细节必须注意。第一答案绝不能存在前端。图片里写什么字符、后台校验什么字符、用户填什么字符这三者必须闭环。前端只能拿到图片不能拿到明文的验证码值。第二响应头要加禁止缓存的参数避免验证码图片被浏览器缓存后反复使用。第三验证码字符集要刻意排除容易混淆的字符比如数字0和字母O、数字1和字母I否则用户识别成本暴涨。Hutool虽然方便但默认生成的验证码样式很容易被识别因为干扰元素和字体过于规整。如果要做抵抗OCR的企业级图形验证码建议手动调整字符使用随机旋转、随机粘连、扭曲变形背景增加语义干扰线字体尽量使用不规则的字体库。这样识别成本会显著上升代价是代码量增加一点但对安全来说值得。2.2 滑块验证码选型与集成避坑滑块验证码是目前体验和安全性平衡得最好的方案之一国内很多开发团队会直接用开源组件比如热词里提到的tianai-captcha。这个组件目前较新的版本是1.5.5内置了二次校验逻辑使用起来比较省心。它的原理是后端先生成一张带缺口背景图和一张拼图前端把拼图拖到缺口位置后把轨迹数据回传后端由后端综合分析拖拽路径、速度、加速度、停留时间等特征判断是人工还是脚本模拟。之所以说“滑块比图形验证码更安全”是因为图形验证码本质是“识图题”机器只要OCR够强就能解滑块验证码是“行为题”模拟真实行为远比识别字符难而且每个请求产生的轨迹数据是动态的。当然滑块也不是绝对安全攻击者可以通过分析大量轨迹、甚至真人代过的方式绕过所以后端限流和风控还是不能省。集成tianai-captcha这类库时最关键的步骤是“二次校验”。底层逻辑是前端调用验证码服务的get接口拿到拼图和背景图用户完成拖拽后前端拿到一个校验凭证业务接口把凭证提交给后端验证接口由后端去校验凭证是否真实有效校验通过后业务接口还要把这凭证标记为“已使用”禁止重放。如果只做前三步不做第四步就会出现“一个凭证反复提交”的漏洞。我见过一个系统滑块验证码校验完就没做失效处理攻击者抓到一个合法凭证后每次都带着这个凭证调登录接口验证码形同虚设。这件事在面试题里也经常被拿出来问本质考的就是“验证码必须一次性使用”这个常识。2.3 短信/邮件验证码发送链路、幂等与频率控制短信验证码本质上已经不是“人机验证”而是“身份验证”它确认的是手机号持有者的本人操作。企业级短信验证码的链路一般是这样业务请求 - 频率校验 - 生成验证码 - 异步发送短信 - 记录发送日志 - 用户输入 - 校验验证码每一步都有坑。先说频率校验。对同一个手机号码我通常做成三重限制同一号码两次发送的最小间隔是60秒同一号码每日发送上限是10次同一IP每日请求发送验证码的上限是20次。为什么是60秒而不是30秒因为短信服务商的回落速度和用户的真实操作路径决定了正常人不可能在一分钟内连点三次“获取验证码”。而每日上限防止的是短信轰炸攻击者拿你的短信通道给任意手机号狂发短信产生的费用和投诉都是企业承担。再说生成规则。短信验证码不建议用纯四位数爆破空间只有10000种在频率限制失效时很容易被枚举。我一般用六位随机数有效期设为5分钟并限定最多尝试5次5次失败后无论输入是否正确都直接作废需要重新获取。有效期设5分钟的出发点是太短用户还没收到短信就过期了太长又给暴力碰撞留了时间窗口5分钟是大多数系统的折中值。邮件验证码和短信类似但要注意域名反垃圾策略。很多团队自建邮箱服务发验证码结果邮件进了垃圾箱。问题不在代码而在邮件域的SPF、DKIM、DMARC配置不完整被对方邮件服务器判为垃圾邮件。这不只是技术问题还是企业形象问题。排查时先用测试账号把原始邮件源码调出来重点看Authenticated-Received头确认发信IP和域名绑定关系是否正常。2.4 验证码存储策略Session、Redis还是JWT单体项目用Session存验证码没什么问题登录后校验时从同一个HttpSession里取就行。但一旦上了多实例部署纯Session方案就尴尬了用户在A实例生成验证码请求被负载均衡转发到B实例B实例的Session里没有这个验证码校验直接失败。虽然有粘性会话、Session复制这类对策但都有明显的副作用要么把请求绑死在单个节点上要么多实例之间同步数据浪费资源。企业级我的首选始终是Redis原因很简单验证码天然是一种带过期时间的数据和Redis的TTL机制契合度非常高。核心设计是生成时用一个UUID作为captchaId返回给前端Redis里的key就带业务前缀比如captcha:login:uuidvalue里存验证码内容、业务类型、过期时间、剩余错误次数这些字段。前端提交时把captchaId和用户输入一起传回后端用这个ID去Redis取值再比对。为什么key不用手机号或用户名因为验证码流程发生在登录之前服务端并不信任客户端提交的手机号和用户名就是用户本人的。如果直接用手机号做key攻击者可以提前给任意手机号发验证码把缓存占满这就是一种资源耗尽攻击。UUID方案好就好在未登录状态下服务端不需要信任任何用户输入唯一标识由服务端下发攻击者无法预知。至于JWT它和验证码不是替代关系而是配合关系。SPA项目里JWT只负责登录成功后的“游客态认证”验证码仍然由Redis管理。JWT的claims里可以塞一个“已通过验证码校验”的标记这样业务接口处理时不用每次去Redis查一遍验证码只要校验JWT声明即可算是无状态方案里比较优雅的做法。3. 企业级验证码的实操落地接口设计与完整流程3.1 后端接口标准设计无论用哪种验证码后端接口设计都应该遵循“一个生成接口、一个校验接口、业务接口内嵌校验”的形态。我用Spring Boot举例图形验证码的场景可以这样设计RestController RequestMapping(/api/captcha) public class CaptchaController { Resource private StringRedisTemplate stringRedisTemplate; GetMapping(/image) public CaptchaResult getImageCaptcha() { // 生成图片 验证码值 CaptchaGenerator generator new CaptchaGenerator.Builder() .width(160).height(60).length(4) .fontSize(28).noiseLevel(6).build(); String code generator.generateCode(); String imageBase64 generator.toBase64(); String captchaId UUID.randomUUID().toString().replace(-, ); // code 放入 Redis5分钟有效期限错5次 CaptchaValue value new CaptchaValue(code, 5, System.currentTimeMillis()); stringRedisTemplate.opsForValue().set( captcha:image: captchaId, JSON.toJSONString(value), 5, TimeUnit.MINUTES ); return CaptchaResult.success(captchaId, imageBase64); } PostMapping(/image/check) public ResponseResult checkImage(RequestBody ImageCaptchaCheckRequest req) { String key captcha:image: req.getCaptchaId(); String json stringRedisTemplate.opsForValue().get(key); if (json null) { return ResponseResult.fail(验证码已过期请重新获取); } CaptchaValue value JSON.parseObject(json, CaptchaValue.class); if (value.getRemainingTimes() 0) { stringRedisTemplate.delete(key); return ResponseResult.fail(错误次数过多请重新获取); } if (!value.getCode().equalsIgnoreCase(req.getCode())) { value.setRemainingTimes(value.getRemainingTimes() - 1); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(value), 5, TimeUnit.MINUTES); return ResponseResult.fail(验证码错误剩余 value.getRemainingTimes() 次机会); } stringRedisTemplate.delete(key); return ResponseResult.success(); } }这里的CaptchaGenerator可以是你自己封装的手绘类也可以是Hutool适配层。我刻意把Redis里的对象做成了JSON字符串而不是多个子key理由很简单一次读取就能拿到所有校验信息减少Redis往返次数。虽然存储上多占一点空间但在高并发场景下少一次网络往返就是实打实的延迟收益。前后端交互这里captchaId必须由后端生成并下发前端绝对不能自己拼一个ID传进来否则攻击者可以伪造一个任意ID去试探Redis虽然大概率查不到缓存但本质上是把“信任边界”交给了攻击者。正确做法是前端只保存后端下发的ID提交时原样带回。3.2 验证码与业务接口的二次校验很多团队的验证码方案只做了“前端校验”业务接口本身完全不校验验证码这是大忌。攻击者根本不需要打开你的页面直接拿脚本来POST你的注册接口你的验证码设计得再高级也没用。正确的二次校验方式应该是用户请求业务接口时先校验验证码是否通过校验通过后把验证码对应的唯一凭证标记为“已使用”业务接口继续执行后续逻辑如果业务逻辑失败回滚也尽量不要让同一个验证码再次使用宁可让用户重新验证也不要制造重放风险。这里有一个容易被忽略的细节验证码校验和业务操作之间不是原子的。比如用户提交注册A线程校验验证码通过正要去写数据库同一瞬间用户又点了一次提交B线程拿到的还是同一个验证码Redis里可能还没被删掉于是B线程也校验通过了。这就是“并发重复提交”问题。业界常见解法是把“校验验证码并删除”变成原子操作。Redis里虽然做不到像数据库那样的严格隔离但可以用Lua脚本把“比较、删除”两步合并成一个原子操作if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end在Java侧直接用DefaultRedisScript执行这段脚本只有返回值是1才代表校验成功。这样无论并发过来多少个请求只有一个线程能“消费”掉这个验证码其余全部判失败。这是一个很值得写进简历的细节。在支付、改密这类高敏感业务里我还会额外在JWT的claims中加上captchaVerifiedtrue当用户通过验证码操作后签发的临时授权令牌里就带上这个标记后续业务只验证JWT不重复查Redis。这样既保证了安全性又减少了缓存依赖。3.3 防刷与限流企业级方案的核心验证码生成接口本身也是攻击面。如果攻击者不断请求/api/captcha/image你的Redis会被无效的验证码数据打爆后端也会白白浪费CPU生成图片。所以验证码生成接口必须比普通接口更严格地限流。我的做法分三层第一层是IP维度限流。用滑动窗口固定窗口的问题在于窗口切换瞬间可以打两倍的量所以一般不推荐固定窗口。简单场景也可以用令牌桶Guava的RateLimiter虽然单机可用但集群环境不适用。企业级更推荐基于Redis实现的一个简单的滑动窗口限流用ZSET记录每个请求的时间戳每次请求过来前先剔除窗口外的旧记录再统计窗口内的数量是否超过阈值。第二层是设备指纹维度。前端采集设备特征生成一个指纹字符串连同请求一起提交给后端后端对该指纹做限流。相比IP设备指纹在移动端更稳定因为手机上设备本身是固定的而IP可能在4G/5G之间漂移。第三层是业务账号维度。如果用户已经登录那么生成验证码前检查该账号最近是否有过频繁操作有就直接拒绝或要求用更复杂的验证方式。限流阈值没有统一标准要看业务量。我一般用“单IP每分钟最多获取20次验证码图片、单IP每天最多100次”作为初始值再根据线上监控调。这里给个建议不要一上来把阈值放得太宽宁可先误伤一些异常用户再通过监控逐步放宽也不要在安全上留口子。3.4 结合JWT与SPA项目改造SPA项目最大的特点就是前后端彻底分离后端不再保存Session状态。这种架构下登录流程通常是用户请求图形验证码拿到captchaId和图片用户提交登录表单账号、密码、验证码、captchaId后端校验验证码通过后再校验账号密码登录成功后签发JWT前端把JWT存在内存或localStorage里之后所有请求都带上Authorization头。这种流程在Spring Boot MyBatis的电商、多商户商城里非常常见。用户注册接口要做更严格的风控通常注册流程还分两步先发短信验证码再提交注册信息。这里我建议把短信验证码的校验也做在注册事务之外先校验通过再开启事务写库否则长时间占用数据库连接去等待用户输入验证码数据库连接池很容易被拖垮。JWT方案还要注意一个点密钥管理和过期时间。验证码登录属于初始信任建立流程签发的JWT时效不宜过长我通常设为30分钟以内配合滑动续期机制超过这个时间就必须重新验证码登录。既保证无状态认证的灵活又降低了Token被盗后的风险窗口。4. 常见问题与排查实录4.1 收不到验证码从发送链路到运营商拦截收不到验证码是运维工单里的常客尤其邮箱验证码很多人第一反应是“代码bug”但实际排查后往往发现是反垃圾策略的问题。“ChatGPT邮箱收不到验证码”“某些平台账号注册收不到验证码”这类现象本质都指向同一个问题邮件服务商的信誉度和DNS校验配置。如果你自建邮件服务一定要把SPF、DKIM、DMARC三件套配好如果走第三方邮件服务则要检查服务商是否把发送IP加入黑名单。短信验证码收不到优先级要按这个顺序查服务商是否返回成功成功不代表用户一定收到很多短信服务商的回执是异步返回的短信签名和模板是否过审未过审的内容服务商直接过滤手机号是否被列入运营商黑名单测试号码曾经投诉过垃圾短信后面所有企业短信都可能被拦截本地日志有没有“发送超时”或“通道无返回”有的话要检查短信通道的并发和签名匹配问题。排查时一定要看全链路日志从业务层到短信服务商回调层都要记录。我给生产环境加过一条规则每次发送短信必须在日志里输出短信内容、接收号码、请求号、服务商返回码。没有日志排查就像蒙眼找针。4.2 验证码失效、过期与并发校验问题验证码明明填对了提交时却提示“验证码错误”这个问题的头号原因是用户从打开页面到提交的时间超过了有效期验证码已过期被Redis自动清理了。还有就是用户在页面停留时间太长JS里的验证码状态和Redis里的时间不同步。排查方法很简单看Redis里是否还有对应key以及TTL剩余时间。另一个高频坑是“同一个验证码被多人使用”。这种通常发生在团队对“验证码一次性”理解不到位只校验不删除。我之前处理过一个线上事故用户A分享了自己的登录链接结果任何拿到链接的人只要带入同一个验证码ID就能进入系统原因就是验证码校验成功后没有销毁。这个问题的修复只加了一行Lua脚本但排查却花了一下午。并发场景下如果还出现“校验失败但验证码已经不存在”大概率是请求并发把同一个验证码并发校验了其中一个线程成功删除了key另一个线程因为抢不到而失败。这是正常业务行为不是bug。前端要做的是用户点击提交后按钮置灰避免短时间内重复提交后端要做的是幂等接口设计让重复请求返回同一个结果而不是报错。4.3 验证码被刷、被绕过攻击视角复盘从攻击者视角看绕过验证码的手段通常有几类直接绕过前端找后端业务接口抓包提交。对策业务接口必须嵌入验证码校验。识别图形验证码利用OCR库自动打码。对策加干扰、加扭曲、增加背景语义噪声或者换滑块验证码。模拟滑块轨迹。有些滑块组件校验强度不够攻击者用脚本模拟直线轨迹也能过。对策后端做轨迹特征分析对直线、匀速、无抖动等异常轨迹直接判定失败。验证码重放。对策一次性使用 JWT授权标记。我见过最离谱的绕过案例是一个团队在验证码校验里直接返回了成功的JSON但没有在Redis里做任何标记攻击者只需设置一个固定的请求头“X-Captcha-Result: true”就能假扮校验通过。这个问题的根源在于前端控制合法性而不是后端。所以我在代码审查时对验证码相关代码的第一条铁律就是合法性的最终判断必须在后端完成且校验结果必须落库或落Redis。还有一类攻击是针对验证码本身的内容做数据挖掘比如某些OCR识别服务可以专门训练“数字扭曲字体”的模型准确率能到95%以上。所以如果业务对安全性要求很高我不建议只依赖增强图形复杂度而是直接用滑块验证码或者行为验证码。复杂的图形验证码能提高一点识别门槛但永远不如换一种验证范式来的彻底。4.4 高并发下的Redis瓶颈与降级策略大促、活动、秒杀场景下验证码接口的并发量会瞬间飙高。Redis虽然性能好但也不能无限扛。如果验证码全部存在Redis里每个验证码请求都是“一个key 一个TTL”高峰期大量键同时过期会给Redis带来明显的过期扫描压力也就是“惊群”的一种。这里有两个优化方向一是把验证码的TTL错开不要所有请求都设相同的5分钟比如在4到6分钟之间加一个随机偏移让过期时间自然分布。二是给验证码生成接口加白名单和本地缓存如果并发太高先在前端做CDN缓存图片验证码校验仍然走后端但图片资源本身能减少很多后端生成压力。问一个极端问题如果Redis挂了验证码业务要不要放行安全领域默认原则是fail-closed也就是验证码服务不可用时宁可拒绝所有验证请求也不要放行未经验证的数据。但在实际业务里这会带来“系统无法登录”的可用性事故。折中方案是Redis不可用时降级为单机内存验证码虽然有一定分布式一致性问题但至少保证业务不中断同时迅速告警让运维第一时间介入。要注意降级方案必须在代码里显式声明并记录日志否则故障排查时你根本不知道系统当时的验证码逻辑是什么样的。我踩过一次大坑就是降级逻辑被某个公共配置类意外关闭了Redis主从切换期间所有登录请求全部失败客服电话被打爆。从那之后我强制要求验证码模块必须单独有一个“降级开关”配置并且这个开关要有监控告警每次切换都留日志。5. 写在最后验证码项目的经验沉淀验证码功能做得好不好不取决于验证码本身多炫酷而取决于你的系统能不能把“生成、存储、校验、防刷、过期、审计”这个闭环跑完整。每个环节拆开看都不复杂但拼在一起就很容易出现“看似能跑、实则漏洞百出”的假安全。我自己的习惯是每做一个验证码项目都会先画一张简单的表格自己对照检查验证码生成后放哪有效期多久错误几次作废校验成功是否立即销毁校验失败是否记录日志生成接口有没有限流业务接口有没有做二次校验这七项过一遍基本能保证验证码模块达到生产级别。如果你正在做Java面试准备验证码也是一个很好的切入点。面试官问“谈谈登录安全设计”你就可以把验证码、限流、JWT、防止重放、Redis原子性这几个点串起来讲这样的回答比单纯背八股文要有血有肉得多因为每个点背后都有真实的业务逻辑支撑。最后再分享一个小经验验证码上线后一定要做一次攻击模拟测试找有安全经验的同事或者专门的测试工具对登录接口发起一轮自动化请求看验证码能不能真的拦住。如果五分钟内就被绕过了说明你的验证码还只是摆设离“企业级安全解决方案”还差得很远。
返回列表