
1. 验证码在认证体系中的真实定位1.1 为什么登录环节总绕不开验证码做后端开发的人对登录认证这块应该都不陌生Shiro 作为 Java 生态里老牌的权限框架把认证、授权、会话管理、加密这些事都封装得比较顺手。但真正把项目往线上推的时候你会发现光有用户名密码校验是不够的。登录接口是所有系统里最容易被盯上的入口没有之一。攻击者不需要知道你的业务逻辑只要拿到登录地址就可以用脚本批量尝试账号密码组合这种攻击成本极低但防守方如果没做任何限制数据库和服务器资源会被迅速消耗更严重的是弱密码账号会被直接撞开。验证码就是在这个背景下被引入的。它的本质是一道“人机区分”的门槛目的是让自动化脚本没法顺畅地完成登录流程。很多人觉得验证码只是个附属功能随便找个工具库生成一张图返回给前端就完事了但实际做过线上项目的人都知道验证码从生成、存储、校验到失效每一个环节都有坑。尤其是在 Shiro 这种有自己过滤器链和会话体系的框架里验证码放在哪个位置校验、怎么和 Shiro 的认证流程配合是需要认真设计的。这篇内容适合正在用 Shiro 做项目、准备给登录加验证码、或者已经加了但发现各种奇怪问题的开发者。我会从整体设计思路讲到具体实现再到实际踩过的坑尽量把每个决策背后的原因说清楚让你看完能直接对照自己的项目改。1.2 验证码方案选型图片、行为还是短信在动手之前先要确定用哪种验证码。市面上常见的几类方案各有适用场景选错了后面改起来很麻烦。图片验证码是最经典的一种服务端生成一张包含随机字符的图片前端展示给用户用户输入后提交校验。它的优点是实现简单、不依赖第三方服务、对用户操作成本低。缺点是如果字符太规整容易被 OCR 识别所以需要在图片上加干扰线、噪点、扭曲变形。对于大多数内部系统和中小型项目来说图片验证码已经够用了。行为验证码是近几年比较流行的方式通过滑动、点选、拼图等交互来判断是不是真人。它的用户体验更好安全性也更高但通常需要接入第三方服务或者自己实现一套前端交互加后端校验的逻辑工作量明显更大。如果项目对安全性要求很高或者面向的是公开注册的场景可以考虑这个方向。短信验证码和邮箱验证码属于另一类思路它们验证的是“用户是否拥有某个凭证”而不是“用户是不是人”。这类方案成本高、依赖外部服务一般用在注册、找回密码、支付确认等关键环节登录环节用得相对少一些。综合来看如果目标只是给 Shiro 的登录接口加一道基础的防脚本门槛图片验证码是性价比最高的选择。后面的实现也会围绕这个方案展开但设计思路对其他类型同样有参考价值。2. Shiro 过滤器链与验证码的接入位置2.1 Shiro 认证流程回顾要把验证码接进 Shiro得先搞清楚 Shiro 处理一个登录请求时到底走了哪些步骤。简单来说当一个请求到达 Shiro 的过滤器链时它会依次经过配置好的各个过滤器。对于登录接口通常会配置成anon或者自定义过滤器允许匿名访问。请求进入 Controller 后一般会调用SecurityUtils.getSubject().login(token)来触发认证这时候 Shiro 会把 token 交给配置好的 Realm 去比对用户信息比对成功则认证通过失败则抛出异常。验证码的校验应该放在哪个环节有两种常见做法。一种是在 Controller 里、调用login之前先校验验证码校验不通过就直接返回错误根本不进入 Shiro 的认证流程。另一种是自定义一个 Shiro 过滤器在过滤器层面拦截登录请求并校验验证码。两种方式都能用但适用场景不同。在 Controller 里校验的好处是逻辑直观、调试方便验证码相关的代码和登录业务代码放在一起阅读起来没有割裂感。缺点是如果项目里有多个登录入口每个入口都要重复写校验逻辑。自定义过滤器的好处是统一拦截、一处配置多处生效但过滤器的执行时机比较靠前拿不到 Controller 层的上下文而且过滤器里抛出的异常需要额外处理才能返回友好的提示信息。我的建议是如果项目只有一个登录入口直接在 Controller 里校验就够了简单直接。如果有多个登录入口或者希望验证码校验和业务逻辑解耦再考虑自定义过滤器。下面会以 Controller 校验为主线来展开因为这是大多数项目最常用的方式。2.2 验证码校验的时机与顺序校验时机的选择有一个原则越早拦截越好但要在能拿到必要信息之后。具体到登录场景验证码校验应该发生在密码比对之前。原因很简单如果密码比对本身就很耗时比如用了 BCrypt 这类慢哈希算法那么让一个错误的请求走到密码比对环节再拒绝等于白白消耗了 CPU 资源。攻击者正是利用这一点来做资源消耗型攻击的。所以正确的顺序是接收请求参数先校验验证码验证码通过后再走 Shiro 的认证流程。验证码不通过就直接返回不触碰数据库和密码哈希计算。这个顺序看起来是常识但我在实际项目中确实见过反过来的写法先查用户再校验验证码结果被脚本刷到数据库连接池告急。另外要注意的是验证码校验和 Shiro 认证之间不应该有状态耦合。也就是说验证码校验不应该依赖 Shiro 的会话状态因为此时用户还没有登录Shiro 的会话可能还没有建立或者处于匿名状态。验证码的存储应该用独立的机制比如服务端缓存或者独立的 session 属性不要和 Shiro 的认证会话混在一起。3. 验证码生成与存储的完整实现3.1 生成端字符集、干扰与图片输出生成验证码图片这一步核心是三个要素字符集、干扰元素、输出格式。字符集的选择有讲究如果只用数字组合空间太小容易被暴力枚举如果大小写字母加数字全用上虽然组合空间大但用户输入时容易混淆比如数字 0 和字母 O、数字 1 和字母 l。比较折中的做法是去掉容易混淆的字符保留 23456789 和 ABCDEFGHJKLMNPQRSTUVWXYZ 这样的集合长度取 4 到 6 位。干扰元素的目的是提高 OCR 识别的难度。常见的做法包括在图片上画随机线条、添加噪点、对字符进行旋转和扭曲、使用非标准字体。但要注意干扰不能过度否则真人用户也看不清。我见过一些验证码为了追求安全性把字符扭曲得连人都认不出来这就本末倒置了。一般来说加两三条不穿过字符主体的干扰线、少量噪点、轻微旋转就够了。图片输出格式通常用 PNG 或 JPEG。PNG 支持透明背景图片质量更好但体积稍大JPEG 体积小但可能有压缩伪影。对于验证码这种小图两者差别不大用 PNG 更常见一些。图片的宽高也要合理太窄了字符挤在一起太宽了浪费空间一般 120x40 到 150x50 之间比较合适。下面是一个基于 Java 标准库的生成示例不依赖第三方图形库public class CaptchaGenerator { private static final String CHARS 23456789ABCDEFGHJKLMNPQRSTUVWXYZ; private static final int WIDTH 130; private static final int HEIGHT 45; private static final int CODE_LENGTH 4; public static CaptchaResult generate() { StringBuilder code new StringBuilder(); Random random new Random(); for (int i 0; i CODE_LENGTH; i) { code.append(CHARS.charAt(random.nextInt(CHARS.length()))); } BufferedImage image new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics2D g image.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setColor(new Color(245, 245, 245)); g.fillRect(0, 0, WIDTH, HEIGHT); g.setFont(new Font(Arial, Font.BOLD, 28)); for (int i 0; i CODE_LENGTH; i) { g.setColor(new Color(random.nextInt(150), random.nextInt(150), random.nextInt(150))); double angle (random.nextDouble() - 0.5) * 0.5; g.rotate(angle, 20 i * 28, HEIGHT / 2); g.drawString(String.valueOf(code.charAt(i)), 15 i * 28, 32); g.rotate(-angle, 20 i * 28, HEIGHT / 2); } g.setColor(new Color(180, 180, 180)); for (int i 0; i 5; i) { g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT), random.nextInt(WIDTH), random.nextInt(HEIGHT)); } g.dispose(); return new CaptchaResult(code.toString(), image); } }这段代码里字符的旋转角度控制在正负 0.25 弧度左右干扰线用浅灰色避免盖住字符。实际项目中可以根据需要调整这些参数。3.2 存储端验证码该放在哪里验证码生成之后服务端必须把正确的答案存起来等用户提交时比对。存储位置的选择直接影响到验证码的安全性和可用性。常见的方案有几种第一种是存在 HttpSession 里。这是最传统的做法Servlet 容器会为每个会话维护一个 session 对象把验证码文本作为属性存进去校验时取出来比对。优点是实现简单不需要额外依赖。缺点是如果项目用了 ShiroShiro 有自己的会话管理机制可能会和容器 session 产生冲突而且分布式部署时 session 共享需要额外配置。第二种是存在 Redis 等缓存里。生成验证码时生成一个唯一标识比如 UUID把标识和验证码文本的映射存入 Redis同时设置过期时间。返回给前端时把标识一起返回前端提交时带上标识和用户输入的验证码服务端根据标识去 Redis 里取正确答案比对。这种方案天然支持分布式过期时间由 Redis 管理不需要手动清理是目前比较推荐的做法。第三种是存在 Shiro 的 session 里。Shiro 的 session 管理比容器 session 更灵活可以配置多种存储后端。但验证码校验发生在认证之前此时 Shiro 的 session 可能还没有和用户绑定用起来需要额外注意。综合来看如果项目已经用了 Redis优先选第二种方案。如果项目规模很小、单机部署用 HttpSession 也不是不行但要注意和 Shiro 的 session 配置协调好。下面以 Redis 方案为例来说明存储和校验的流程。3.3 过期策略与一次性使用验证码必须设置过期时间否则攻击者可以先生成一批验证码把标识和答案都记下来然后慢慢尝试。过期时间一般设 2 到 5 分钟太短了用户来不及输入太长了给攻击者留的窗口太大。Redis 的SETEX命令可以很方便地设置过期时间比如SETEX captcha:uuid 300 code表示 5 分钟后自动删除。除了过期验证码还必须是“一次性”的。也就是说无论校验成功还是失败只要用户提交了一次这个验证码就应该立即失效。如果不失效攻击者可以在验证码有效期内反复使用同一个验证码来尝试不同的密码验证码就形同虚设了。实现方式是在校验逻辑里取出验证码后立即从 Redis 删除然后再比对。这样即使比对失败验证码也已经没了用户需要重新获取。这里有一个细节需要注意删除和比对的操作顺序。如果先比对再删除在并发场景下可能出现同一个验证码被多次校验的情况。虽然概率不高但严格来说应该用 Redis 的原子操作或者 Lua 脚本来保证“取出即删除”的原子性。对于大多数项目先删除再比对已经足够因为验证码本身的有效期很短并发窗口极小。4. 与 Shiro 认证流程的整合实操4.1 Controller 层校验的完整代码结构把验证码校验放在 Controller 层代码结构会比较清晰。一个典型的登录接口大致是这样的流程接收前端传来的用户名、密码、验证码标识、验证码输入值先校验验证码通过后再构造 Shiro 的 token 并调用 login最后根据认证结果返回相应的信息。RestController RequestMapping(/api/auth) public class LoginController { Autowired private StringRedisTemplate redisTemplate; PostMapping(/login) public Result login(RequestBody LoginRequest request) { String captchaKey captcha: request.getCaptchaId(); String correctCode redisTemplate.opsForValue().get(captchaKey); redisTemplate.delete(captchaKey); if (correctCode null) { return Result.fail(验证码已过期请重新获取); } if (!correctCode.equalsIgnoreCase(request.getCaptchaCode())) { return Result.fail(验证码错误); } try { UsernamePasswordToken token new UsernamePasswordToken( request.getUsername(), request.getPassword()); SecurityUtils.getSubject().login(token); return Result.success(登录成功); } catch (AuthenticationException e) { return Result.fail(用户名或密码错误); } } }这段代码里有几个值得注意的点。第一验证码比对用了equalsIgnoreCase因为用户输入时可能大小写不一致而验证码本身不区分大小写对用户体验更友好。第二无论验证码是否正确都先执行了删除操作保证一次性使用。第三Shiro 的异常被捕获后返回统一的错误提示不暴露具体是用户名错还是密码错避免给攻击者提供信息。4.2 自定义过滤器方案的实现要点如果选择自定义 Shiro 过滤器来校验验证码需要继承AuthenticatingFilter或者更基础的AccessControlFilter重写isAccessAllowed和onAccessDenied方法。在isAccessAllowed里做验证码校验校验不通过就返回 false然后onAccessDenied里返回错误信息。public class CaptchaFilter extends AccessControlFilter { Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { HttpServletRequest req (HttpServletRequest) request; String captchaId req.getParameter(captchaId); String captchaCode req.getParameter(captchaCode); if (captchaId null || captchaCode null) { return false; } String key captcha: captchaId; String correctCode redisTemplate.opsForValue().get(key); redisTemplate.delete(key); return correctCode ! null correctCode.equalsIgnoreCase(captchaCode); } Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException { HttpServletResponse resp (HttpServletResponse) response; resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\code\:400,\msg\:\验证码错误或已过期\}); return false; } }过滤器方案需要注意的是它拦截的是所有匹配的请求所以配置时要精确指定只拦截登录接口不要误伤其他接口。另外过滤器里直接写 JSON 响应比较生硬如果项目有统一的响应封装最好复用。4.3 前后端交互的接口设计验证码功能涉及两个接口获取验证码和提交登录。获取验证码的接口返回图片和标识图片可以直接以 Base64 编码返回也可以返回一个图片 URL。Base64 的好处是前端不需要额外请求直接设置 img 标签的 src 就行URL 的好处是图片可以缓存但验证码本身不应该被缓存所以 Base64 更合适。GetMapping(/captcha) public Result getCaptcha() { CaptchaResult result CaptchaGenerator.generate(); String captchaId UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(captcha: captchaId, result.getCode(), 5, TimeUnit.MINUTES); ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(result.getImage(), png, baos); String base64 Base64.getEncoder().encodeToString(baos.toByteArray()); MapString, String data new HashMap(); data.put(captchaId, captchaId); data.put(image, data:image/png;base64, base64); return Result.success(data); }前端拿到 captchaId 后存在内存里登录时一起提交。点击图片刷新时重新调用获取接口拿到新的 captchaId 和图片。这里要注意每次刷新都应该生成新的 captchaId旧的验证码在 Redis 里会自然过期不需要手动清理。5. 常见问题与排查实录5.1 验证码校验失败的典型原因实际项目中验证码校验失败的原因五花八门我整理了一个速查表覆盖了大部分情况现象可能原因排查方向一直提示验证码错误前端提交的 captchaId 和生成时不一致检查前端是否在刷新图片后更新了 captchaId验证码刚获取就提示过期Redis 连接异常或 key 前缀不匹配检查 Redis 配置和 key 的拼接逻辑大小写输入都报错比对时没有忽略大小写确认使用了 equalsIgnoreCase同一验证码可以多次使用校验后没有删除 key检查删除操作是否在校验前执行分布式部署下时好时坏验证码存在了本地 session 而非 Redis统一改为 Redis 存储图片显示为空白Base64 编码缺少前缀或图片流未关闭检查 data URI 格式和流关闭逻辑其中最常见的是 captchaId 不一致的问题。前端在页面加载时获取一次验证码用户输入错误后点击刷新如果刷新逻辑只更新了图片的 src 而没有更新 captchaId那么提交时用的还是旧的 captchaId而旧的验证码可能已经过期或者被删除了自然校验失败。这个问题的排查方法很简单在浏览器开发者工具里看每次请求返回的 captchaId 和提交时带的 captchaId 是否一致。5.2 安全性加固的几个细节基础功能跑通之后还有一些加固措施值得考虑。首先是限制验证码的获取频率防止攻击者大量请求验证码接口导致 Redis 内存被占满。可以基于 IP 或者设备标识做限流比如同一 IP 每分钟最多获取 10 次验证码。其次是登录失败次数限制。验证码虽然能挡住纯脚本但挡不住人工打码平台。如果攻击者愿意花成本还是可以绕过验证码。所以在验证码之外还应该对同一账号的连续登录失败次数做限制超过阈值后锁定一段时间或者要求额外的验证。另外验证码的答案在日志里不要打印出来。我见过一些项目为了方便调试把生成的验证码文本打到了日志里上线时忘了去掉结果日志文件里全是验证码等于把答案公开了。调试信息可以用 debug 级别生产环境关闭。5.3 性能与体验的平衡验证码的生成涉及图片绘制虽然单次耗时不高但在高并发场景下也会成为瓶颈。如果登录接口的 QPS 很高可以考虑对验证码生成做异步化或者用更轻量的方式生成。比如用 SVG 代替位图SVG 是矢量格式生成速度快、体积小而且同样可以加干扰元素。体验方面验证码的字符要足够清晰干扰不能过度。可以在生成时随机选择几种字体和颜色方案但保证对比度足够。另外验证码输入框应该支持回车提交图片点击刷新这些细节虽然小但直接影响用户的使用感受。6. 从验证码延伸到登录安全的整体思考验证码只是登录安全的一个环节单独靠它并不能解决所有问题。一个完整的登录安全体系还应该包括密码强度校验、登录失败锁定、异常登录检测、HTTPS 传输加密、敏感操作二次验证等。验证码的定位是“提高自动化攻击的成本”而不是“彻底杜绝攻击”。在实际项目中我倾向于把验证码做成可配置的。比如通过配置项控制是否开启验证码、连续失败几次后开启验证码、验证码的有效期等。这样在安全性和用户体验之间可以灵活调整。对于内部系统可能连续失败三次后才要求验证码对于公开注册的系统可能每次登录都必须输入验证码。还有一个容易被忽略的点是验证码的无障碍访问。对于视觉障碍用户图片验证码是无法使用的。如果项目有无障碍要求需要提供语音验证码作为替代方案。语音验证码的实现思路和图片验证码类似只是把字符用语音播报出来存储和校验逻辑可以复用。我在实际项目里踩过最深的坑是 Redis 的 key 过期时间和业务逻辑的配合。有一次设置了 5 分钟过期但前端页面的验证码图片没有自动刷新用户打开登录页后去忙别的事十分钟后回来输入验证码怎么都提示过期。后来在页面上加了倒计时提示并且用户点击输入框时如果超过一定时间就自动刷新验证码体验才好起来。这种细节在文档里通常不会写但实际用起来影响很大。最后分享一个小技巧验证码的比对可以用MessageDigest.isEqual来做常量时间比较避免因为比较耗时差异泄露信息。虽然对于验证码这种短字符串来说时序攻击的实际风险很低但养成这个习惯没坏处。另外验证码的字符集里去掉 0 和 O、1 和 l 之后用户输入错误率明显下降这个改动成本极低但效果很好建议直接采用。