资讯动态

SpringBoot集成Hutool实现图形验证码的完整方案

发布时间:2026/9/10 12:24:30 来源:尧图企业网站定制
1. 项目概述与方案选型做后端开发的人应该都有体会验证码几乎是每个Web项目绕不开的“标配”。不管你是做管理系统、电商平台还是企业官网只要涉及登录、注册、短信发送或者高风险操作都需要验证码来挡一道。原因很简单——互联网上爬虫和机器脚本太多了如果不加点“人机校验”的关卡你的接口很容易被刷爆、被撞库甚至被恶意注册灌满垃圾数据。这次我分享的是一个非常实用的基础方案用 SpringBoot 做后端集成 Hutool 工具库生成图形验证码前端用原生 JavaScript 完成图片刷新和校验交互。整个方案不依赖 Redis、不引入复杂的验证码框架代码量精简到极致却能稳稳覆盖中小型项目 90% 的验证码需求。先说下这个方案适合谁。如果你是刚入门 SpringBoot 的后端新人想搞明白验证码从生成到校验的完整链路或者你手头有一个老项目想在不引入额外依赖的前提下快速加一个图形验证码功能再或者你想在前后端分离的架构下搞一套不依赖 jQuery 和第三方组件的轻量方案——这篇文章可以帮你省掉不少踩坑的时间。我在实际项目中试过几种验证码实现有自己用 BufferedImage 手绘的有集成 Google Kaptcha 的也有用 Hutool 的。最终稳定沿用下来的是 Hutool 方案原因很直接Hutool 的LineCaptcha和CircleCaptcha封装已经处理好了图形干扰、字符随机、扭曲变形这些细节而且生成的图片质量清晰、识别率适中既能防机器又不会让用户看了半天输不对。这套方案的核心链路就三步后端生成验证码图片并缓存正确答案前端展示图片并收集用户输入后端校验结果并返回成功或失败。1.1 为什么选 Hutool 而不是其他方案市面上 Java 生成图形验证码的方案其实不少我做了一个简单的对比大家看完就明白我的选型思路了方案优点缺点适用场景手写 BufferedImage无外部依赖完全可控代码量大字符扭曲、干扰线都要自己实现且很难写出花哨的样式有特殊定制需求时Google Kaptcha老牌方案样式多样可配置性强需要单独引入依赖内部走的是 Servlet 输出流前后端分离时封装稍显繁琐纯后端渲染页面的项目Hutool CaptchaAPI 极简开箱即用内置多种验证码类型样式相对固定不适合需要高度定制验证码外观的场景大多数标准 Web 项目第三方平台验证码极验、阿里云安全性高体验好收费或需要对接 SDK有外部依赖高安全需求的大型平台Hutool 属于典型的“80% 场景用起来最顺手”的方案。它的CaptchaUtil一个静态方法就能创建验证码对象然后write()方法直接输出到 HttpServletResponse 的输出流后端拆装都极其方便。而且 Hutool 在 Java 开发者中的普及率很高很多项目本来就因为它优秀的工具方法集合而引入了这个依赖顺带用一下验证码功能完全不需要新增任何成本。1.2 为什么前端选用原生 JavaScript现在前端框架百花齐放Vue、React 早已是主流为什么这里我还要专门提“原生 JavaScript”其实原因很实在首先验证码展示和刷新这个小功能不涉及复杂的状态管理和组件通信原生 JS 十几行代码就能搞定其次很多内部管理系统、老项目或者后端渲染的模板页面并没有引入 MVVM 框架你总不能为了一个验证码把整个前端工程体系搭起来再次原生 JavaScript 写的代码零依赖不管你以后项目怎么升级改造这段代码复制过去基本都能直接用。我见过不少团队把验证码刷新做成了整个前端工程里最“重”的组件引入 axios、搞一堆封装实际上一个XMLHttpRequest或者fetch就能解决。能用 20 行代码解决的事没必要让项目多背负几十个依赖。这在工程化意识强烈的团队里可能听着不够“高大上”但恰恰是这种简洁让方案的维护成本降到了最低。2. 核心原理拆解验证码的生成与校验机制2.1 验证码的本质是什么抛开各种花哨的外壳验证码的本质就是一个“挑战-响应”机制。服务端生成一个随机字符串把它画成图片发给客户端客户端用户看图识别并输入字符服务端把用户输入的内容和之前生成的内容做比对一致则放行不一致则拒绝。这里的核心问题只有一个服务端怎么记住自己生成的字符串最常见的做法有两种。一种是把验证码字符串存在 Session 里用户提交时从 Session 取出来比对另一种是存在 Redis 里并设置过期时间适合分布式部署的场景。我这篇文章里的基础方案用的是 Session 存储因为 Hutool 自带的ICaptcha接口恰好提供了getCode()方法配合 SpringBoot 的HttpSession很自然。如果你的项目已经接了 Redis把验证码存入 Redis 也只是多几行代码的事我会在后面的“扩展思路”里简单提一下。这里要画一个重点验证码的安全核心在于“一次性”。无论校验成功还是失败验证码都应该立即作废防止被同一个验证码反复尝试爆破。Session 方案下一旦校验过就session.removeAttribute()把这个机制养成肌肉记忆。2.2 Hutool 验证码的核心类与工作原理Hutool 的 captcha 模块包含几个核心接口和实现类我挑重点给大家理一下ICaptcha顶层接口定义了getCode()获取验证码文本、write()输出图片、verify()校验输入等关键方法。LineCaptcha线段干扰验证码图片背景上有随机干扰线字符识别难度适中最常用的类型。CircleCaptcha圆圈干扰验证码用圆圈来干扰识别视觉效果更柔和。ShearCaptcha扭曲干扰验证码字符形状有扭曲变形安全系数比前两者高一些但识别难度也相应增加。CaptchaUtil工厂类一个静态方法按需创建上述类型的验证码实例。LineCaptcha的构造方法支持四个参数宽、高、字符数量、干扰线数量。比如new LineCaptcha(130, 48, 4, 100)就创建了一个宽度 130 像素、高度 48 像素、包含 4 个字符和 100 条干扰线的验证码。干扰线越多识别越难这个参数要控制在合理范围太密了用户容易骂娘太疏了又起不到防机器人的作用。我实测下来130x48 的尺寸配合 80-120 条干扰线是易用性和安全性比较均衡的组合。2.3 前端展示与刷新的完整闭环前端做的事情实际上非常少页面加载时请求一次验证码图片接口把返回的图片流直接赋给img标签的src用户点击图片时给这个 URL 拼接一个新的时间戳参数强制浏览器发新请求而不是使用缓存从而实现“点击刷新”的效果。这里有一个非常经典的坑浏览器的图片缓存策略。如果你每次请求的 URL 完全一样浏览器可能直接从本地缓存加载图片导致前端看似刷新了其实显示的还是同一张验证码图。解决方式就是拼时间戳src /captcha?t new Date().getTime()。这个细节我在第三节的代码里会写进去大家直接照着用就行。3. 完整实现过程从零到可运行的详细步骤3.1 环境准备与依赖引入我的开发环境是这样的供大家参考JDK 1.8 及以上我用的是 1.8SpringBoot 2.x示例代码用的是 2.7.x3.x 用法基本一致Hutool 5.8.x注意版本差异5.x 系列 API 稳定前端无任何框架依赖纯 HTML 原生 JavaScript在pom.xml中引入 Hutool 依赖dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependency提示如果你只想引入 captcha 模块不需要 Hutool 的全部功能可以只引入hutool-captcha和hutool-core。但这里为了示例方便我直接用hutool-all实际项目里完全可以按需裁剪。3.2 后端编写验证码生成接口在 SpringBoot 工程中新建一个CaptchaController代码如下package com.example.demo.controller; import cn.hutool.captcha.CaptchaUtil; import cn.hutool.captcha.LineCaptcha; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; RestController RequestMapping(/captcha) public class CaptchaController { GetMapping(/image) public void getCaptchaImage(HttpServletResponse response, HttpSession session) throws IOException { // 设置响应头告知浏览器这是一个图片流不缓存 response.setContentType(image/png); response.setHeader(Pragma, No-cache); response.setHeader(Cache-Control, no-cache); response.setDateHeader(Expires, 0); // 创建线段干扰验证码宽130高484个字符100条干扰线 LineCaptcha lineCaptcha CaptchaUtil.createLineCaptcha(130, 48, 4, 100); // 将验证码正确答案存入 Sessionkey 自定义后面校验时取出 session.setAttribute(captchaCode, lineCaptcha.getCode()); // 同时删除旧验证码确保一个 Session 里同一时间只有一个有效的验证码 session.removeAttribute(captchaCode); session.setAttribute(captchaCode, lineCaptcha.getCode()); // 输出图片流 lineCaptcha.write(response.getOutputStream()); } GetMapping(/verify) public boolean verifyCaptcha(String code, HttpSession session) { // 从 Session 中取出正确的验证码 String captchaCode (String) session.getAttribute(captchaCode); // 如果 Session 里没有验证码可能过期或未生成直接返回 false if (captchaCode null) { return false; } // 验证输入是否正确忽略大小写比较时可以自行处理 boolean result captchaCode.equalsIgnoreCase(code); // 无论成功失败验证过后立即删除保证一次性 if (result) { session.removeAttribute(captchaCode); } return result; } }代码里我故意写了一段看起来有点冗余的逻辑先 remove 再 set。其实这是从安全角度考虑——防止同一个 Session 里堆积多个验证码对象确保任何时刻只有最新生成的那个有效。虽然简单的直接 set 也能覆盖旧值但显式 remove 一次更清晰地表达了“旧码作废”的语义。另外要注意write()方法需要传入一个OutputStream这里直接用了 HttpServletResponse 的输出流SpringBoot 会自动处理流的关闭不过在一些高版本容器中保险起见你可以在 finally 块里手动关闭避免资源泄漏。这里解释一下setContentType(image/png)的作用它告诉浏览器接下来的响应体是一个 PNG 格式的图片浏览器才能正确渲染。Cache-Control: no-cache是配合前端“时间戳刷新”的双保险双管齐下彻底杜绝缓存导致验证码不更新的问题。3.3 测试验证码图片接口写完接口后启动 SpringBoot 项目浏览器直接访问http://localhost:8080/captcha/image。如果一切正常你会看到浏览器直接渲染出一张带干扰线的 4 位字符图片每次刷新页面都会得到一张不同的图。这里我建议大家养成一个习惯写完接口先不接前端直接用浏览器验证后端是否正确。这样可以快速定位问题究竟出在后端还是前端。如果访问 URL 后报 404检查一下项目有没有正确扫描到 Controller如果返回的不是图片而是乱码或报错重点检查 Hutool 依赖是否引入成功以及response.getOutputStream()是否被之前写的返回值处理逻辑干扰。3.4 前端原生 JavaScript 实现验证码展示与刷新新建一个 HTML 页面代码非常简单!DOCTYPE html html langzh-CN head meta charsetUTF-8 title登录页面 - 验证码示例/title style .captcha-wrapper { display: flex; align-items: center; gap: 10px; margin-top: 10px; } #captchaImg { width: 130px; height: 48px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; } .captcha-tip { font-size: 12px; color: #999; } /style /head body div classlogin-box h3用户登录/h3 div label用户名/label input typetext idusername / /div div label密码/label input typepassword idpassword / /div div classcaptcha-wrapper input typetext idcaptchaInput placeholder请输入验证码 / img idcaptchaImg src/captcha/image alt验证码 title点击图片刷新 / /div div classcaptcha-tip看不清点击图片刷新验证码/div div button idloginBtn登录/button /div /div script // 获取验证码图片元素 var captchaImg document.getElementById(captchaImg); // 点击图片刷新验证码注意拼接时间戳防止浏览器缓存 captchaImg.addEventListener(click, function () { captchaImg.src /captcha/image?t new Date().getTime(); }); // 模拟登录校验 document.getElementById(loginBtn).addEventListener(click, function () { var code document.getElementById(captchaInput).value.trim(); if (!code) { alert(请输入验证码); return; } // 使用 fetch 发送验证请求 fetch(/captcha/verify?code encodeURIComponent(code)) .then(function (response) { return response.json(); }) .then(function (data) { if (data true) { alert(验证码正确继续执行登录逻辑); } else { alert(验证码错误或已过期); // 验证失败时自动刷新验证码图片 captchaImg.src /captcha/image?t new Date().getTime(); } }) .catch(function () { alert(请求失败请稍后重试); }); }); /script /body /html注意几个关键点第一我用了encodeURIComponent对用户输入的验证码做编码处理。虽然验证码本身是字母数字常规情况下不会有特殊字符但这是请求参数处理的基本素养防止意外情况导致请求报错。第二fetch(/captcha/verify?code encodeURIComponent(code))返回的是一个 Promise如果后端返回的是 JSON 布尔值这里要调用response.json()解析。有些项目里后端直接返回字符串 true/false那就要用response.text()然后手动比对两种方式都行保持前后端约定一致即可。第三验证失败后我自动刷新了验证码图片。这是一个用户体验的细节避免用户输了两次错误后还要手动点击图片才能刷新。这个小优化非常值得保留。3.5 基于 Session 存储和模拟用户登录完整的前后端串联上面的示例中验证按钮是独立存在的。在实际项目里验证码校验通常是登录接口的一部分。登录接口应该先校验验证码再校验用户名密码。伪代码如下PostMapping(/login) public Result login(RequestBody LoginRequest request, HttpSession session) { // 1. 先校验验证码 String captchaCode (String) session.getAttribute(captchaCode); if (captchaCode null || !captchaCode.equalsIgnoreCase(request.getCaptchaCode())) { return Result.error(验证码错误或已过期); } // 验证码一次性使用用完立即清除 session.removeAttribute(captchaCode); // 2. 再校验用户名密码省略具体业务逻辑 // User user userService.login(request.getUsername(), request.getPassword()); // if (user null) { // return Result.error(用户名或密码错误); // } // 3. 登录成功后生成 token 或写入 Session return Result.success(登录成功); }这里有一个经验点验证码校验必须放在用户名密码校验之前。这样做的用意是先消耗掉验证码防止攻击者通过不断提交请求来试探用户密码的同时还能保留同一个验证码反复尝试——虽然这种场景比较少但作为一个合格的后端开发者校验顺序这个细节体现了你对安全边界的考虑。另外验证失败时验证码被消耗用户必须刷新重新输入这样也限制了机器人的暴力尝试速率。4. 常见问题与排查技巧实录4.1 图片不刷新永远显示同一张验证码这是最常见的问题基本都出在浏览器缓存上。前端虽然设置了src为同一个 URL但如果后端响应头没设置Cache-Control: no-cache浏览器会在一定时间内直接使用本地缓存的图片。排查顺序建议如下第一步打开浏览器开发者工具F12切到 Network 面板。第二步多次点击验证码图片观察请求/captcha/image的状态。第三步如果第二次及以后的请求显示“from disk cache”或“from memory cache”说明缓存没有绕开。解决方案就是我在代码里写的组合拳后端加Cache-Control: no-cache响应头前端每次请求给 URL 追加时间戳参数。两个条件满足一个就能解决同时做了是双保险。4.2 验证码能显示但是校验永远失败这种情况的原因通常有两个。第一个是 Session 中的验证码值被覆盖或清除了。排查时可以在verify方法里打印日志看看session.getAttribute(captchaCode)到底返回了什么。常见的情况是你在某个 Filter 或拦截器里不小心调用了session.invalidate()或者项目开启了 Redis Session 共享但 Session 序列化出了问题。第二个是前后端校验约定不一致。比如后端生成验证码时用的是getCode()返回的字符串但校验时比对的字段名拼错了——这种低级错误反而最容易发生。我建议把 Session 中存验证码的 key 定义成常量前后端共用一套命名习惯比如统一叫captchaCode。4.3 验证码识别难度太高或太低怎么办Hutool 的验证码可调节参数就那几个图片宽高、字符个数、干扰线数量、字体。如果你发现用户老输错可以把干扰线数量调低一些或者把字符数量从 4 个调整为 4 个其实已经够少也可以把图片宽度调大让字符之间更宽松。我自己的调参心得内部管理系统的操作员字符 4 个、干扰线 60 条左右优先保证易用性因为后台用户往往每天要登录多次。公开注册页或登录页字符 4 个、干扰线 100-120 条优先保证安全性。如果是短信发送这类高风险接口建议升级成ShearCaptcha字符扭曲后识别难度明显上升。4.4 Hutool 版本导致的 API 兼容问题Hutool 从 5.x 升级到某些小版本时个别类的方法签名会有微调。如果你用的版本比较老编译时报createLineCaptcha找不到基本就是版本差异。解决办法很简单统一把 Hutool 升级到 5.8 的最新版或者如果项目里已经因为其他原因引入了特定版本就强行指定hutool-captcha模块与主版本一致。4.5 前后端分离场景下的校验逻辑注意事项如果你的项目是 Vue/React 前后端分离架构前端和后端不在同一个域名下就会涉及跨域问题。图片img标签不受同源策略限制但fetch请求受 CORS 约束你需要在后端配置跨域过滤器。处理方式两种后端添加一个 CorsFilter允许指定前端域名的跨域请求。开发阶段可以用代理转发规避跨域例如 Vue 的 devServer 代理把/captcha开头的请求转发到后端地址。另外提醒一句Session 在跨域场景下默认不会自动共享你需要在前端请求中带上 withCredentials 属性后端相应设置 allowCredentials true。跨域 Session 的组合牵涉的细节不少如果项目未来要做分布式部署更推荐用 Redis 存储验证码从根上规避 Session 带来的问题。5. 安全性与性能优化进阶指南5.1 给验证码加一个时间戳约束基础方案里验证码只存在 Session 中没有过期时间。如果用户生成了验证码但一直不输入Session 中的验证码理论上会一直有效这给攻击者留下了暴力尝试的时间窗口。虽然 Session 本身有过期时间但默认的 30 分钟仍然太长。优化方案是在 Session 中同时存一个生成时间戳。校验时判断时间差是否超过预设的阈值例如 2 分钟超过即视为过期// 生成验证码时 long timestamp System.currentTimeMillis(); session.setAttribute(captchaTime, timestamp); // 校验验证码时 Long captchaTime (Long) session.getAttribute(captchaTime); if (captchaTime null || System.currentTimeMillis() - captchaTime 120000) { return false; // 验证码超时 }去 Redis 存储也是同理利用 Redis 的SET key value EX 120命令天然实现过期逻辑不用自己管理时间戳这也是生产环境里更推荐的做法。5.2 防止验证码被逆向破解的安全措施图形验证码本质上是一种“低成本”的安全防护它防的是批量脚本防不了有耐心的真人攻击者。如果想要更高强度的防护有几个方向可以叠加增加尝试次数限制同一个 Session 或 IP 在单位时间内最多尝试 5 次验证码超过则锁定一段时间。这个逻辑可以用拦截器实现也可以用 Redis 计数器实现。整体登录接口限流验证码只是第一道关卡登录接口还需要设计全局的限流策略比如单个 IP 每分钟最多请求 10 次登录接口。升级验证码类型可以把图形验证码替换为滑块验证、点选文字等行为式验证码或者接入第三方安全验证服务。视觉效果更好安全性也更高但会引入额外的开发成本。5.3 Redis 存储改造思路考虑到很多读者所在的项目已经接了 Redis我补充一下改造的关键点。后端生成验证码时用 UUID 作为 key验证码文本作为 value以 2 分钟为过期时间写入 Redis同时把这个 UUID 返回到前端可以放到 cookie 或者其他参数里前端提交校验时带上这个 UUID后端根据 UUID 去 Redis 取文本比对完成后删除 key。这个方案的优点是验证码存储与 Session 解耦适合微服务和分布式部署。我在另一个项目里用的是这个方案整体代码量增加不到 30 行却换来了更好的扩展性。5.4 性能方面的几个小细节图形验证码生成的性能开销其实非常小单次生成大约在几毫秒级别不会成为系统瓶颈。但有几点值得留意不要每次请求都创建新的验证码对象并同时往 Session 里塞上面代码里我用了 remove 再 set目的就是保证 Session 中验证码对象不堆积。如果项目 QPS 很高验证码生成是纯 CPU 计算操作不需要加缓存因为每个用户都需要独立的验证码。如果发现验证码图片生成耗时偏长可以排查是不是用了太大的图片尺寸或者异常高的干扰线数量。130x48 的尺寸下生成耗时通常不会超过 10ms一旦远超这个量级检查 JDK 图形渲染环境是不是有问题。6. 经验总结与扩展建议这套基于 Hutool 和原生 JavaScript 的验证码方案我已经在多个管理系统中落地使用稳定运行了一年以上。最让我满意的是它的“轻”没有额外引入前端框架没有复杂的构建流程后端也只有十几行核心代码。排查问题时从生成到校验整条链路清晰可见任何一个环节出了问题都能快速定位。实际操作中有一个心得想分享给大家验证码方案要尽早嵌入到登录流程里并且从项目初期就按“一次性 过期”的规范来做。我见过不少项目先放一个验证码占位符线上被刷了才回来补安全措施结果登录模块已经被改得七七八八再往里塞验证码校验逻辑反而要动不少既有代码。宁愿开始多花半小时也别留这个隐患。如果你后续想在这个方案上继续扩展建议往这几个方向走把 Session 存储换成 Redis让方案支持多实例部署。在前端加一个倒计时或自动刷新机制验证码过期后提示用户点击刷新。生成音频验证码为无障碍访问提供支持。根据业务风险评估把不同接口的验证码强度和策略分开配置登录、注册用图形验证码提现、改密等高危操作可以升级验证方式。最后分享一个小技巧如果你在测试时不想每次都手输验证码可以临时在 Controller 里加一个 Debug 接口直接返回当前 Session 里存的验证码文本测试完记得删掉。这种临时接口虽然“不规范”但能大幅提升调试效率。当然上线前务必确认这个接口已经被移除否则你加的验证码防线等于自己给自己开了个后门。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价