做后端这些年我接触过太多“看起来能跑、但经不起推敲”的验证码方案。多数项目一开始只在登录页放一个四位数字图片后来被 OCR 识别、被脚本刷接口、被分布式环境下重放攻击搞到焦头烂额才回头正经研究怎么从零搭建一套企业级验证码体系。这篇文章就把我在 HoRain 云环境上落地的 Java 验证码实现完整梳理一遍从图形验证码、滑块验证码、短信防轰炸到二次校验、统一风控入口和审计链路顺带把生产环境里踩过的坑全部晒出来。内容适合正在设计或重构验证码模块的 Java 后端工程师也适合刚接手一个验证码遍地开花但没人统一管理的旧系统、想从头理顺的架构师。这次不是讲纯原理的教科书而是一份可以直接照着落地、能抄作业的工程化方案。看完之后你会清楚知道每种验证码解决什么问题、服务端该怎么存、校验逻辑写在哪儿、分布式环境下怎么保证一次性、以及如何把验证码能力封装成一个对业务无感的通用安全组件。1. 验证码的本质与企业级设计思路1.1 验证码到底在防什么很多人以为验证码只是“登录时填一下”其实是把它看小了。企业级场景下验证码承担的是第一道人机识别防线核心要防的东西有这几类撞库和暴力破解攻击者拿批量账号密码对登录接口做尝试没有验证码时单机脚本也能轻易跑出每天几十万次请求有了验证码单账号维度就能被强制限速。批量注册和羊毛党活动抽奖、新人优惠场景脚本批量生成手机号注册短信验证码如果没做频控几分钟就能把短信通道预算烧光。爬虫和数据抓取列表页、详情页被高频抓取图形验证码加接口限流能明显抬高爬取成本。短信轰炸攻击者拿着别人的手机号反复触发“获取验证码”如果接口不做前置人机校验就能借你的通道给任意号码发骚扰短信。接口被刷导致资损下单、领取权益等接口被脚本集中调用验证码一旦缺失等于把风控大门半开着。想清楚这些之后就会发现企业级验证码从来不是“一个插件”而是多层防线组合的系统设计。这也是为什么我强烈建议把验证码能力独立成一个服务模块而不是在每个业务代码里各写一套生成和校验逻辑。1.2 从单点验证码到插件式验证码网关我见过很多团队的做法是登录模块写一个图形验证码注册模块重新写一个营销活动又找了一个开源组件来用。结果就是三种验证码三种存储方式三套校验逻辑出了问题排查成本极高。真正的企业级方案应该抽象成一个验证码网关。这个网关需要具备四个基本能力签发、存储、校验、审计。业务方不关心验证码底层是图形、滑块还是短信只关心我申请一个验证码、校验一个验证码、拿到结果。底层换成什么策略对上层透明。我落地时的分层大致是这样接入层统一 REST 接口提供GET /captcha/generate和POST /captcha/verify两个核心入口业务通过scene参数区分用途比如login、register、sms、order。策略层每种验证码类型实现同一个接口图形验证码一个实现滑块验证码一个实现短信验证码复用短信通道。存储层统一走 Redis以captcha:{scene}:{uuid}为 key存储验证码答案、过期时间、尝试次数、校验状态。审计层签发、校验成功、校验失败、IP、设备指纹、场景全部落到日志或消息队列给风控和排障留足证据。这套结构的好处是后续想加一种新的验证方式比如无感行为验证只需要新写一个策略类业务代码完全不用改。对业务方来说接入成本就是加一个注解或配置的事。2. 核心细节解析与实操要点2.1 图形验证码服务端生成、一次性校验是底线图形验证码看着简单但关键的坑都在细节里。我推荐的方案是服务端生成图片把答案存 Redis前端只拿到图片 base64 和本次验证码的唯一 ID。校验时后端拿着用户输入跟 Redis 里的答案比对比对完立刻删除该 key保证一次性。这里的“一次性”容易被忽略。如果不删 key攻击者拿到一个有效验证码后可以反复尝试就算只允许一次通过攻击者也可以把同一个验证码用在多个请求上造成重放。所以校验成功和校验失败都必须处理失败时累计次数超过三次强制失效成功时直接DEL。生成图片时至少要注意这几件事字符集不要用纯数字容易被 OCR 和简单图像处理直接命中建议数字加大写字母去掉0/O、1/I这类容易混淆的字符。干扰线、噪点、字体扭曲、背景渐变色都要有且每次随机不要让生成结果的分布有明显的统计规律。字体选择要注意服务器环境很多无头 Linux 环境缺字体图片生成出来是方块字这个问题排查起来很妖。很多人会问要不要在前端加一次校验比如先把用户输入跟隐藏的答案比对。我的建议是前端可以做轻校验提升体验但绝不能作为安全依据否则抓包之后整个防线形同虚设。真正的信任边界一定在服务端。2.2 滑块验证码tianai-captcha 的集成与二次校验图形验证码对抗 OCR 的能力有限所以现在企业级用得更多的是滑块验证码。早期我踩过不少自研滑块的坑自己切图、自己算缺口位置、自己存坐标做出来又慢又容易误判。后来切到 tianai-captcha问题瞬间少了很多1.5.5 版本内置了二次校验能力省了自己造轮子。先说二次校验是什么。滑块验证码的流程是这样的前端先请求一张带缺口的背景图和一张滑块小图用户把滑块拖到缺口位置前端把轨迹数据和缺口位置一起提交给后端。后端校验时不仅要看缺口位置是否匹配还要分析轨迹是否符合人类操作习惯包括拖动耗时、加速度、轨迹是否过于笔直等。校验通过后签发一个短期有效的 token业务接口再拿这个 token 来换自己的逻辑执行资格。如果没有二次校验攻击者可以拿着“滑块校验通过”的同一个结果无限次调用后续业务接口等于只挡了第一下。有了 token 机制业务接口依赖的是 token而不是重复去校验滑块轨迹。token 我一般设置 60 到 120 秒失效并绑定场景和会话 ID防止跨场景滥用。集成时依赖就加官方 starter坐标大致是cloud.tianai.captcha:tianai-captcha-spring-boot-starter:1.5.5具体版本号以 Maven 仓库实际发布的为准。然后在配置里指定 Redis 存储、图片资源目录、缓存策略。官方默认实现已经很稳健不建议自己去改切图算法。2.3 短信验证码防轰炸是设计出来的不是配置出来的短信验证码最大的问题不是生成逻辑而是被刷。攻击者不停地请求给某个手机号发短信通道费不断消耗用户也被骚扰。防轰炸不能单靠一个 Redis 计数器需要一套完整的漏斗。我在生产环境里用的顺序是这样的第一步人机前置发短信之前必须先过图形或滑块验证码。没有这个人机前置IP 和手机号频控都只是降低速率防不住攻击者换 IP、换号。第二步接口维度限流同一 IP 每分钟最多 5 次获取验证码请求超过直接拒绝。第三步手机号维度频控同一手机号 60 秒内不能重复发送一天累计上限比如 10 次达到上限后即使图形验证码通过也不再发。第四步通道侧签名和时间窗口短信内容里带上时间戳和业务标识服务端处理时校验签名防止请求被恶意篡改发送内容。短信验证码本身的有效期我一般设 5 分钟超过自动失效。同时限制错误重试次数一个验证码最多错 3 次超过后即使用户输入的答案正确也不能通过必须重新获取。这么做是避免暴力枚举 6 位数字组合因为 5 分钟窗口内哪怕每秒试一次也只有 300 次机会远不足以穷举 100 万种组合。还有一个小但极其重要的细节验证码不能出现在接口响应体里也不能打日志。排查问题时可以打印“验证码已发送”这类业务日志但绝对不要打印验证码本身否则日志泄露比接口被刷更危险。3. 实操过程与核心环节实现3.1 环境准备与依赖引入我这次是在 HoRain 云上部署的一套小型生产环境目标是模拟真实的多实例部署所以不是单机 Redis也不是本机内存。基础环境如下两台应用服务器安装 JDK 11Maven 3.8Spring Boot 2.7一台 Redis 6.2应用服务器通过内网访问Nginx 做负载均衡WebSocket 不涉及普通 HTTP 场景够用依赖方面核心的几个写在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 groupIdcloud.tianai.captcha/groupId artifactIdtianai-captcha-spring-boot-starter/artifactId version1.5.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-captcha/artifactId version5.8.25/version /dependency这里我用了 Hutool 的图形验证码做快速落地tianai-captcha 做滑块JWT 做校验通过后的短期 token。注意 Redis 的连接配置必须用内网地址别图省事暴露公网 6379 端口这类事故在安全审计里太多了。3.2 图形验证码核心代码实现图形验证码的签发接口我封装成了下面这样。核心思路是生成答案、生成图片、写 Redis、返回给前端。Service public class GraphicCaptchaService { Resource private StringRedisTemplate stringRedisTemplate; public CaptchaVO generate(String scene, String sessionId) { // 生成4位随机码去掉易混淆字符 String answer RandomUtil.randomString(ABCDEFGHJKLMNPQRSTUVWXYZ23456789, 4); LineCaptcha captcha CaptchaUtil.createLineCaptcha(200, 60, 4, 30); captcha.setCode(answer); String key buildKey(scene, sessionId); // 答案和图片都写到Redis过期时间5分钟 stringRedisTemplate.opsForValue().set(key, answer, 5, TimeUnit.MINUTES); CaptchaVO vo new CaptchaVO(); vo.setCaptchaId(sessionId); vo.setImageBase64(captcha.getImageBase64Data()); return vo; } public boolean verify(String scene, String sessionId, String userInput) { String key buildKey(scene, sessionId); String rightAnswer stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(rightAnswer)) { return false; } boolean match rightAnswer.equalsIgnoreCase(userInput.trim()); if (match) { // 无论结果如何Redis中的记录必须删除 stringRedisTemplate.delete(key); } else { // 失败次数加一超过三次直接删除 String failKey key :fail; Long count stringRedisTemplate.opsForValue().increment(failKey); stringRedisTemplate.expire(failKey, 5, TimeUnit.MINUTES); if (count ! null count 3) { stringRedisTemplate.delete(key); } } return match; } private String buildKey(String scene, String sessionId) { return captcha:graphic: scene : sessionId; } }注意这里我把图片也写了答案到 Redis而不是前端带着答案。前端拿到的只有captchaId和图片任何形式的答案前置都会给抓包攻击留后门。另外Redis 里 key 一定要带上业务场景前缀否则多个服务共用一套验证码 Redis 时登录场景生成的验证码可能被注册场景的请求误打误撞校验通过。3.3 滑块验证码集成与二次校验实现tianai-captcha 接入后核心要处理的是生成请求、校验请求、签发 token 三件事。下面是一个简化版本生产里可以根据官方 starter 的配置继续扩展。RestController RequestMapping(/captcha/slider) public class SliderCaptchaController { Resource private CaptchaService captchaService; GetMapping(/generate) public ResponseVOMapString, Object generate(RequestParam String scene, RequestParam String sessionId) { CaptchaResponseCaptchaVO response captchaService.generateCaptcha(SLIDER, scene); MapString, Object data new HashMap(); data.put(captchaId, response.getCaptchaId()); data.put(background, response.getCaptchaVO().getBackgroundImage()); data.put(slider, response.getCaptchaVO().getSliderImage()); return ResponseVO.success(data); } PostMapping(/verify) public ResponseVOVerifyResult verify(RequestBody SliderVerifyRequest req) { CaptchaVerifyRequest request new CaptchaVerifyRequest() .setCaptchaType(SLIDER) .setCaptchaId(req.getCaptchaId()) .setCaptchaData(req.getCaptchaData()); CaptchaVerifyResponse verify captchaService.verify(request); if (verify.isSuccess()) { // 校验通过后签发短期token String verifyToken JwtUtil.createVerifyToken(req.getScene(), req.getSessionId(), 60); return ResponseVO.success(new VerifyResult(true, verifyToken)); } return ResponseVO.fail(滑块校验失败); } }这个 token 的有效期我设得很短60 秒因为它的职责只是衔接“验证码校验”和“业务操作”不是代替登录态。业务侧拿着 token 过来时过滤器里校验签名、校验过期时间、校验场景是否匹配全部通过才继续往下走。实现时还有一处容易被忽视滑块的轨迹数据。tianai-captcha 前端 SDK 收集的轨迹要完整传给后端后端会综合轨迹判断。如果前端为了省事只传最终坐标不传轨迹很多版本会直接判定失败这也是很多同学接入后“明明拖对了却一直提示失败”的常见原因。3.4 统一校验入口注解加拦截器验证码能力复用起来最优雅的方式是做一个注解和拦截器。业务方法上加个注解拦截器自动完成校验业务代码保持干净。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface CaptchaCheck { String scene() default default; CaptchaType type() default CaptchaType.SLIDER; }拦截器里拿到请求参数中的captchaId、userInput、verifyToken根据注解配置找到对应策略执行校验失败直接抛出统一异常。这样一来登录、注册、下单、短信发送都是同一套校验逻辑谁也不许绕过。统一入口还有个额外收益可以在这里做全局审计把每一次校验的 IP、设备指纹、场景、结果、耗时记下来后续风控规则能直接复用这些数据。3.5 与 MyBatis-Plus 实体类生成建表 SQL 的联动在给别人搭建这套验证码体系时我经常被问到“审计表怎么建”。如果你项目里用了 MyBatis-Plus可以直接根据审计实体类生成建表语句省得手写一堆字段。我的审计表通常长这样Data TableName(captcha_audit) public class CaptchaAudit { TableId(type IdType.ASSIGN_ID) private Long id; private String scene; private String captchaType; private String captchaId; private String verifyResult; private String clientIp; private String deviceFingerprint; private LocalDateTime createTime; }用 MyBatis-Plus 的TableInfoHelper或者让开发工具根据实体类生成 SQL 都可以关键是表字段要和实体映射一致。这种“实体即表结构”的做法在快速迭代的项目里很实用改字段时不用同步维护两份文档。4. 常见问题与排查技巧实录这套方案上线后我陆陆续续处理过不少生产问题。列一份速查表按我实际遇到过的频率排序问题现象根本原因解决方案图形验证码图片不显示服务器缺少字体图片生成异常安装字体包或改用无字体的纯干扰线方案Redis key 串场景key 没有按 scene 隔离统一按captcha:{type}:{scene}:{id}命名滑块拖到位置仍失败前端轨迹数据不完整检查前端 SDK 是否传递完整轨迹数据校验成功后重放攻击成功缺少二次校验 token校验通过后签发 60 秒短期 token 再进业务短信接口被刷只做了手机号频控没人机前置发短信前强制先过滑块验证码分布式环境下验证码失效存储用了本地 Session全部改 Redis 存储禁止内存 Session高并发下 Redis 连接异常未配置连接池和慢查询监控配置 Lettuce 连接池开启慢查询日志验证码答案被日志打印开发期调试代码未清理全链路扫描日志输出验证答案脱敏4.1 踩过的坑JWT 过期和验证码 token 的边界最后说一个我实际调试了很久的坑。当时把验证码校验通过后的 token 直接跟登录 JWT 混在一起管理结果用户停留页面的时间稍微长一点就出现“滑块明明过了提交业务时却提示 token 过期”。后来才反应过来登录态 JWT 的过期时间是 2 小时验证码 token 的过期时间只有 60 秒两者是两码事。验证码 token 的存在意义是防重放它只需要在“校验完成”到“业务请求发起”这短短几秒内有效。用户在页面停留很久后再提交正确做法是重新走一次验证码而不是把验证码 token 的有效期拉长。如果嫌用户体验差可以加“记住我”或设备指纹免验证规则但绝不能为了让用户不重新验证而牺牲安全性。4.2 排查问题时的实操顺序如果线上突然出现验证码大面积失效我会按这个顺序查先确认 Redis 是否可用key 是否存在很多“验证码失效”其实是 Redis 内存满了或连接被拒。再看校验接口的失败码是参数缺失、答案不匹配还是 token 过期。然后看审计日志同一 IP 是否高频触发失败如果是大概率是脚本在试。最后看是不是最近改过验证码配置比如过期时间、尝试次数、图片策略配置变更导致的隐性回归很常见。这套排查路径基本能覆盖 95% 的问题。普通用户遇到验证码不好使大概率是网络或缓存问题而从服务端看到的批量异常则多半是攻击试探或者配置变更。我个人在实际部署里的一个体会是验证码服务的稳定性对业务的直接影响比想象中大得多。登录、注册、下单、短信这些关键链路全挂在它后面一旦验证码服务挂了这些业务等于全部被卡死。所以我把验证码模块独立部署之后又单独给它配了降级开关万一 Redis 抖动或服务异常可以在网关层临时放开部分低风险场景保证主链路不瘫。也算是从几次夜里被叫起来处理故障之后换来的教训。
阅读完成 · 觉得有帮助?