1. 登录页验证码到底在防什么:从一次被脚本刷穿的经历说起
很多人做JavaWeb项目,登录页的验证码模块都是最后才补的,甚至直接抄一段网上的工具类就完事。我最早做后台管理系统的时候也是这个心态,觉得验证码就是个"装饰品",前端画个图、后端存个Session,能跑通就行。直到有一次上线没多久,运营那边反馈说后台出现了大量异常登录记录,同一个账号在几分钟内被尝试了几百次密码,日志里User-Agent全是清一色的脚本特征。那次之后我才真正意识到,验证码不是装饰,它是登录链路里成本最低、收益最高的一道人机识别闸门。
验证码(CAPTCHA)这个词本身的意思就是"全自动区分计算机和人类的图灵测试",它的核心目标只有一个:让真人能轻松通过,让批量脚本难以自动化。登录页面之所以要加它,是因为登录接口是整个系统里最容易被暴力破解的入口——攻击者只要拿到用户名,就可以用字典无限次尝试密码。如果没有验证码,一个简单的Python脚本配合密码字典,一晚上能跑几十万次请求。而加上验证码之后,每次请求都必须携带一个"只有真人才能获取"的凭证,脚本的自动化成本瞬间被拉高。
这里要区分一个概念:验证码防的不是"某一次请求",而是"批量请求"。单次请求里验证码是否正确,其实意义不大,真正有价值的是它打断了自动化的连续性。所以设计验证码模块时,不能只想着"怎么生成一张图",而要想清楚三件事:生成端怎么保证随机性、存储端怎么保证和当前会话绑定、校验端怎么保证一次性有效。这三点任何一点出问题,验证码都会形同虚设。
这篇文章我会围绕JavaWeb环境下登录页验证码的完整实现来讲,从Servlet时代的经典写法,到Spring Boot项目里的落地方式,把生成、存储、校验、刷新、防刷这几个环节全部拆开。适合正在做课程设计、毕业项目,或者刚接手一个老JavaWeb系统需要补验证码的同学。我不会只给你一段能跑的代码,而是把每一步"为什么这么写"讲清楚,这样你换到别的框架里也能自己迁移。
2. 验证码生成的底层逻辑:随机数、干扰与图像绘制
2.1 为什么验证码字符集要"挑着用"
生成验证码的第一步是确定字符集。很多人直接ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789一把梭,看起来没问题,但实际用起来会踩坑。最典型的就是字符0和O、1和I、2和Z这几组,在图片里稍微加点干扰线或者旋转,用户根本分不清,导致"明明输对了却提示错误"的投诉。我在一个项目里就遇到过,客服反馈说验证码"永远输不对",排查半天发现是字体渲染把0画得和O几乎一样。
所以字符集的选择原则是:去掉易混淆字符,保留辨识度高的。常见的做法是去掉0O1Il2Z这几个,剩下的字符足够用。如果你对安全性要求更高,可以只用大写字母加数字,长度控制在4到6位。4位是体验和安全的一个平衡点,6位更安全但用户输入成本高。我一般推荐4位,因为配合一次性校验和过期机制,4位的暴力破解空间(去掉易混淆字符后大约32的4次方,约100万种组合)已经足够让脚本头疼了。
// 去掉易混淆字符的字符集 private static final String CHAR_SET = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; private static final int CODE_LENGTH = 4;这里有个细节:Random和SecureRandom的区别。普通Random是基于线性同余算法,种子可预测,理论上攻击者如果知道种子就能推算出后续所有验证码。虽然在实际登录场景里这种攻击很少见,但既然验证码本身就是安全组件,用SecureRandom是更稳妥的选择,它的熵源来自系统底层,不可预测。
SecureRandom random = new SecureRandom(); StringBuilder code = new StringBuilder(); for (int i = 0; i < CODE_LENGTH; i++) { code.append(CHAR_SET.charAt(random.nextInt(CHAR_SET.length()))); }2.2 图片绘制的几个关键参数
生成验证码图片,核心是用Java的BufferedImage和Graphics2D。这里有几个参数直接决定用户体验和防识别能力,我一个个说。
图片尺寸:宽度一般120到150像素,高度40到50像素。太窄字符挤在一起,太宽又浪费空间。我习惯用130x48,这个尺寸在大多数登录框里显示比例协调。
背景色:不要用纯白。纯白背景对比度高,OCR识别起来太容易。用浅灰或者带一点随机噪点的浅色背景,能增加识别难度。但也不能太花,否则用户看不清。
干扰线:这是防OCR的常用手段,但要注意度。画太多干扰线,用户自己都看不清;画太少,等于没画。我的经验是画3到5条,颜色用比字符稍浅的随机色,线条要穿过字符区域但不能完全遮挡。
字符旋转:每个字符随机旋转-30度到30度,能有效破坏OCR的字符分割。但旋转角度太大,用户阅读困难,所以控制在正负30度以内。
字体:用系统自带的字体就行,但字号要够大,一般28到32号。字体样式可以随机选粗体或普通,增加变化。
BufferedImage image = new BufferedImage(130, 48, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); // 背景 g.setColor(new Color(240, 240, 240)); g.fillRect(0, 0, 130, 48); // 干扰线 for (int i = 0; i < 5; i++) { g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200))); g.drawLine(random.nextInt(130), random.nextInt(48), random.nextInt(130), random.nextInt(48)); } // 绘制字符 for (int i = 0; i < code.length(); i++) { g.setColor(new Color(random.nextInt(100), random.nextInt(100), random.nextInt(100))); g.setFont(new Font("Arial", Font.BOLD, 30)); // 旋转 double angle = (random.nextInt(60) - 30) * Math.PI / 180; g.rotate(angle, 20 + i * 28, 30); g.drawString(String.valueOf(code.charAt(i)), 15 + i * 28, 35); g.rotate(-angle, 20 + i * 28, 30); } g.dispose();这段代码里有个容易忽略的点:g.rotate之后一定要g.rotate(-angle)转回来,否则后续字符的坐标会全部错乱。我第一次写的时候没转回来,结果第二个字符直接飞到图片外面去了,排查了好一会儿。
2.3 噪点要不要加,加多少
噪点(随机像素点)是另一个防识别手段。原理是在图片上随机撒一些和背景色接近的小点,让OCR的预处理阶段难以二值化。但噪点加多了,图片会显得很脏,用户观感差。我的建议是加100到200个噪点,颜色和背景色差异不要太大,这样人眼看着是"有点纹理",但OCR会很难受。
for (int i = 0; i < 150; i++) { int x = random.nextInt(130); int y = random.nextInt(48); image.setRGB(x, y, new Color(random.nextInt(255), random.nextInt(255), random.nextInt(255)).getRGB()); }这里用setRGB直接操作像素,比用Graphics2D画点效率高。注意噪点要在绘制字符之后加,否则会被字符覆盖掉一部分,效果打折。
3. 验证码的存储与校验:Session绑定和一次性消费
3.1 为什么验证码必须存在服务端
验证码生成之后,答案存在哪里?这是整个模块最关键的设计决策。有些新手会把验证码答案通过隐藏字段或者Cookie传到前端,校验时前端传回来对比。这种做法等于把答案告诉了攻击者,验证码完全失去意义。
正确的做法是:答案只存在服务端,和当前用户的会话绑定。在传统JavaWeb里,最直接的方式就是存到HttpSession里。用户请求验证码图片时,服务端生成答案并session.setAttribute("captcha", code),同时把图片写回响应。用户提交登录表单时,服务端从同一个Session里取出答案进行比对。
// 生成验证码的Servlet protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String code = CaptchaUtil.generateCode(); req.getSession().setAttribute("captcha", code); BufferedImage image = CaptchaUtil.generateImage(code); resp.setContentType("image/png"); // 禁止浏览器缓存验证码图片 resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); resp.setHeader("Pragma", "no-cache"); ImageIO.write(image, "png", resp.getOutputStream()); }这里有个必须注意的点:验证码图片绝对不能缓存。如果不设置Cache-Control: no-store,浏览器可能会把第一次请求的验证码图片缓存下来,用户点击"换一张"时请求的还是同一张图,但Session里的答案已经变了,导致永远校验失败。这个坑我在早期项目里踩过,现象是"刷新验证码后输入新图上的字符还是错",排查了半天才发现是缓存问题。
3.2 一次性消费:校验后立即失效
验证码的第二个关键设计是一次性。也就是说,无论校验成功还是失败,验证码都应该立即从Session里移除。为什么?因为如果校验失败后验证码还留着,攻击者就可以用同一个验证码反复尝试密码,验证码就退化成了一个"固定密码",防不住暴力破解。
// 登录校验逻辑 String inputCode = req.getParameter("captcha"); String sessionCode = (String) req.getSession().getAttribute("captcha"); // 无论对错,先移除 req.getSession().removeAttribute("captcha"); if (sessionCode == null) { // 验证码已过期或未获取 return "验证码已失效,请刷新"; } if (!sessionCode.equalsIgnoreCase(inputCode)) { return "验证码错误"; } // 继续校验用户名密码注意这里用了equalsIgnoreCase,因为用户可能输入大小写不一致。虽然我们生成的字符集里只有大写字母和数字,但用户可能习惯性输入小写,忽略大小写能提升体验。当然,如果你对安全性要求极高,也可以严格区分大小写,但那样用户体验会差一些。
还有一个细节:先判断sessionCode == null再比对。如果用户直接访问登录接口而没有先获取验证码,Session里没有答案,这时候应该提示"验证码已失效"而不是"验证码错误",因为后者会误导用户以为是自己输错了。
3.3 过期时间:Session超时与主动失效
Session本身有超时机制,默认30分钟。但验证码的有效期不应该这么长。一个验证码从生成到用户提交,正常也就几十秒到几分钟。如果有效期太长,攻击者可以慢慢收集验证码再批量尝试。
我的做法是在Session里额外存一个时间戳,校验时判断是否超过有效期(比如5分钟)。超过就视为失效,要求重新获取。
// 存储时 session.setAttribute("captcha", code); session.setAttribute("captchaTime", System.currentTimeMillis()); // 校验时 Long createTime = (Long) session.getAttribute("captchaTime"); if (createTime == null || System.currentTimeMillis() - createTime > 5 * 60 * 1000) { return "验证码已过期"; }这样即使Session没超时,验证码本身也会在5分钟后失效。5分钟是个经验值,足够用户正常操作,又不至于给攻击者留太多时间窗口。
4. 前端交互与刷新机制:让"换一张"真正生效
4.1 图片刷新为什么不能直接改src
前端展示验证码,最朴素的写法是<img src="/captcha">。但用户点击"换一张"时,如果直接把src重新赋成同样的值,浏览器可能不会重新请求,而是直接用缓存。所以必须加一个随机参数来打破缓存。
function refreshCaptcha() { var img = document.getElementById("captchaImg"); img.src = "/captcha?t=" + new Date().getTime(); }这个t参数服务端可以忽略,它的唯一作用就是让每次请求的URL不同,从而绕过浏览器缓存。这是前端刷新的标准做法,简单有效。
4.2 点击图片刷新与按钮刷新的取舍
交互上,我建议图片本身可点击刷新,同时旁边放一个"看不清?换一张"的文字链接。图片可点击符合用户直觉,文字链接则给不习惯点击图片的用户一个明确入口。两者都调用同一个refreshCaptcha函数即可。
这里有个体验细节:刷新时最好给图片加一个短暂的加载状态,或者至少让用户感知到"正在刷新"。因为验证码图片生成很快,通常感觉不到延迟,但如果服务端压力大,可能会有几百毫秒的等待。加个简单的透明度变化或者loading提示,能避免用户以为没反应而反复点击。
4.3 表单提交前的空值校验
前端还应该做一层基础校验:用户没输入验证码就点登录,直接提示"请输入验证码",而不是把空值提交到后端。这能减少无效请求,也能让用户更快得到反馈。
function validateForm() { var code = document.getElementById("captchaInput").value.trim(); if (code === "") { alert("请输入验证码"); return false; } return true; }注意这只是体验优化,后端校验绝对不能省。前端校验可以被绕过,真正的安全边界永远在服务端。
5. 从Servlet到Spring Boot:不同项目结构下的落地差异
5.1 传统Servlet项目的目录与配置
在纯Servlet项目里,验证码通常做成一个独立的Servlet,在web.xml里配置映射路径。这种结构清晰,职责单一,适合老项目维护。
<servlet> <servlet-name>CaptchaServlet</servlet-name> <servlet-class>com.example.servlet.CaptchaServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>CaptchaServlet</servlet-name> <url-pattern>/captcha</url-pattern> </servlet-mapping>登录Servlet里则负责校验逻辑。这种分离的好处是验证码生成和登录校验互不干扰,验证码Servlet只关心生成和存储,登录Servlet只关心比对。
5.2 Spring Boot里的Controller写法
Spring Boot项目里,验证码生成通常写成一个@GetMapping的Controller方法,直接往HttpServletResponse里写图片流。
@GetMapping("/captcha") public void captcha(HttpServletResponse response, HttpSession session) throws IOException { String code = CaptchaUtil.generateCode(); session.setAttribute("captcha", code); session.setAttribute("captchaTime", System.currentTimeMillis()); BufferedImage image = CaptchaUtil.generateImage(code); response.setContentType("image/png"); response.setHeader("Cache-Control", "no-store"); ImageIO.write(image, "png", response.getOutputStream()); }这里有个Spring Boot特有的注意点:如果你用了Spring Security,验证码接口需要放行,否则会被安全过滤器拦截。在配置类里加上.antMatchers("/captcha").permitAll()即可。
5.3 分布式场景下的Session共享问题
如果你的项目部署了多个实例,用Session存验证码就会出问题:用户在实例A获取验证码,登录请求被负载均衡转发到实例B,实例B的Session里没有答案,校验必然失败。
解决思路有两种:一是用粘性会话(同一用户的请求固定打到同一实例),二是把验证码存到Redis这类集中式存储里。粘性会话配置简单但不够灵活,实例宕机后会话丢失。Redis方案更可靠,但需要引入额外依赖。
// Redis存储方案 redisTemplate.opsForValue().set("captcha:" + sessionId, code, 5, TimeUnit.MINUTES);用Redis时,key用Session ID或者一个独立的token,value是验证码答案,过期时间设5分钟。校验时从Redis取,取完即删。这样多实例之间共享验证码状态,扩展性更好。不过对于大多数课程设计和小型项目,Session方案已经够用,不必过度设计。
6. 那些年踩过的验证码坑:排查链路与修复方案
6.1 验证码永远校验失败:从缓存到编码的完整排查
这个问题的现象是:用户明明输入了图片上看到的字符,后端却一直返回"验证码错误"。排查这类问题,我一般按下面的顺序走。
第一步,确认Session是否正常。在验证码Servlet和登录Servlet里分别打印session.getId(),看两次请求的Session ID是否一致。如果不一致,说明Session没有保持,可能是Cookie被禁用或者跨域导致。这一步能排除掉大部分"看起来是验证码问题其实是会话问题"的情况。
第二步,确认答案是否被覆盖。有时候页面里验证码图片被请求了两次(比如同时有src和一个预加载逻辑),第二次请求会覆盖Session里的答案,导致用户看到的图和Session里的答案对不上。排查方法是看服务端日志里验证码生成了几次。
第三步,确认缓存设置。如果Cache-Control没设对,浏览器缓存了旧图,用户看到的是旧图但Session里是新答案。这个前面提过,是高频坑。
第四步,确认字符集和大小写。如果用户输入的是小写而答案是大小写敏感比对,就会失败。检查比对逻辑是否用了equalsIgnoreCase。
6.2 验证码图片显示成裂图或乱码
这个问题的原因通常有两个:一是response.setContentType没设成image/png,浏览器把二进制流当成了文本;二是Servlet里在写图片流之前有其他输出(比如out.println),导致图片数据前面混入了额外字节,图片解析失败。
排查方法是打开浏览器开发者工具,看验证码请求的响应头Content-Type是否正确,以及响应体开头是不是PNG的魔数(89 50 4E 47)。如果响应体开头是HTML或者文本,说明有额外输出混进去了。
修复方案就是确保验证码Servlet里只写图片流,不做任何其他输出,并且setContentType要在getOutputStream之前调用。
6.3 刷新验证码后旧验证码仍可用
这个问题的根源是刷新时没有清理旧的Session答案。用户点击"换一张",服务端生成了新答案覆盖了Session,但旧答案如果被攻击者提前拿到,在覆盖之前仍然有效。虽然时间窗口很短,但严格来说是个隐患。
更稳妥的做法是每次生成新验证码时,先移除旧的,再存新的。由于Session的setAttribute本身就是覆盖,所以只要保证每次生成都走同一个key,旧答案自然失效。真正需要注意的是不要用多个key存多个验证码,那样会出现"旧验证码还能用"的情况。
6.4 移动端输入法导致的输入问题
在移动端,验证码输入框如果没设置type和autocapitalize,输入法可能会自动首字母大写或者弹出中文输入法,导致用户输入困难。建议设置type="text"配合autocapitalize="off"和autocorrect="off",并且用inputmode提示输入类型。
<input type="text" id="captchaInput" autocapitalize="off" autocorrect="off" inputmode="latin" maxlength="4" placeholder="请输入验证码">maxlength="4"能防止用户输入超长内容,inputmode="latin"在部分移动浏览器上会直接弹出英文键盘,体验更好。
7. 安全加固:让验证码不只是"一张图"
7.1 频率限制:同一IP或同一账号的请求节流
验证码本身能防批量脚本,但如果攻击者用OCR自动识别验证码,单靠验证码就不够了。这时候需要配合频率限制:同一IP在1分钟内请求验证码超过10次,或者同一账号登录失败超过5次,就暂时拒绝服务或者要求更复杂的验证。
// 简单的内存计数示例 Map<String, AtomicInteger> ipCount = new ConcurrentHashMap<>(); // 每次请求验证码时 ipCount.computeIfAbsent(ip, k -> new AtomicInteger()).incrementAndGet(); // 定时清理或判断阈值生产环境建议用Redis的过期key来实现,比内存计数更可靠,也支持分布式。
7.2 验证码答案的比对要防时序攻击
普通的字符串equals比对是短路比较,理论上可以通过响应时间差异推断出正确字符。虽然在实际登录场景里这种攻击很难实施,但既然做安全组件,用MessageDigest.isEqual做恒定时间比对是更好的习惯。
boolean match = MessageDigest.isEqual( sessionCode.getBytes(StandardCharsets.UTF_8), inputCode.toUpperCase().getBytes(StandardCharsets.UTF_8) );这个细节很多人不知道,但写出来显得专业,也能在代码审查时加分。
7.3 日志里不要打印验证码答案
调试的时候为了方便,很多人会在日志里打印生成的验证码答案。上线前一定要去掉,否则日志泄露等于验证码泄露。我见过一个项目,日志文件被误传到公开目录,里面全是验证码答案,虽然危害有限,但属于典型的安全疏忽。
8. 我个人在实际项目中的几点体会
做了这么多登录模块,我对验证码最大的体会是:它的价值不在于"多难识别",而在于"打断自动化"。一个4位、带干扰线、5分钟过期的验证码,配合一次性校验和频率限制,已经能挡住99%的脚本攻击。过度追求复杂(比如滑动拼图、点选文字)反而会伤害用户体验,而且实现成本高,对中小项目来说性价比不高。
另一个体会是,验证码的坑大多不在生成逻辑,而在会话管理和缓存控制。我排查过的验证码问题里,十有八九是Session没绑对、缓存没禁掉、或者刷新逻辑有覆盖。所以如果你正在做这个模块,建议先把Session机制和HTTP缓存这两块搞清楚,比研究怎么画更复杂的干扰线有用得多。
最后分享一个小技巧:在开发阶段,可以在验证码Servlet里加一个开关,测试环境下固定返回"1234"并且不校验,这样联调登录流程时不用每次都看图输入。上线前记得把这个开关关掉,或者用配置项控制,避免误上线。这个技巧能省下大量调试时间,尤其是做自动化测试的时候。