
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-nameCaptchaServlet/servlet-name servlet-classcom.example.servlet.CaptchaServlet/servlet-class /servlet servlet-mapping servlet-nameCaptchaServlet/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或者一个独立的tokenvalue是验证码答案过期时间设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输入法可能会自动首字母大写或者弹出中文输入法导致用户输入困难。建议设置typetext配合autocapitalizeoff和autocorrectoff并且用inputmode提示输入类型。input typetext idcaptchaInput autocapitalizeoff autocorrectoff inputmodelatin maxlength4 placeholder请输入验证码maxlength4能防止用户输入超长内容inputmodelatin在部分移动浏览器上会直接弹出英文键盘体验更好。7. 安全加固让验证码不只是一张图7.1 频率限制同一IP或同一账号的请求节流验证码本身能防批量脚本但如果攻击者用OCR自动识别验证码单靠验证码就不够了。这时候需要配合频率限制同一IP在1分钟内请求验证码超过10次或者同一账号登录失败超过5次就暂时拒绝服务或者要求更复杂的验证。// 简单的内存计数示例 MapString, 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并且不校验这样联调登录流程时不用每次都看图输入。上线前记得把这个开关关掉或者用配置项控制避免误上线。这个技巧能省下大量调试时间尤其是做自动化测试的时候。