
1. 图片验证码到底在防什么从登录接口被刷说起登录接口是所有系统的门面也是最容易被盯上的入口。我最早做后台管理系统的时候登录页只有账号和密码两个输入框上线不到两周日志里就出现了大量来自同一网段的请求每秒几十次账号字典从admin一路试到test001。那时候才真正意识到登录接口如果不加一层人机识别等于把大门钥匙挂在门把手上。图片验证码要解决的核心问题就是区分坐在浏览器前的人和跑在服务器上的脚本。它的原理并不复杂服务端生成一张包含随机字符的图片把答案存在会话里用户肉眼识别后填进输入框服务端比对。脚本可以轻松构造 HTTP 请求但要它看懂一张带干扰的图片成本就高得多。这里有个容易被忽略的点验证码不是登录流程的装饰品而是整个认证链路的第一道闸机。它的价值不在于绝对防住而在于把攻击成本从几乎为零抬升到不划算。一个纯数字的四位验证码理论上暴力枚举一万次就能过但如果配合一次性失效、有效期限制、错误次数锁定攻击者要付出的请求量和时间成本就会指数级上升。我在实际项目里见过三种典型的错误做法值得先摆出来当反面教材验证码答案直接放在前端 JS 里比对用户输入后由前端判断对错。这种做法的安全性约等于零打开控制台就能看到答案。验证码生成后不设过期时间一个验证码能用一整天。这给了脚本充足的时间去慢慢试。验证码校验通过后不立即销毁同一个验证码可以重复提交。这等于把一次性凭证变成了永久凭证。这三种问题的共同点是把验证码当成了显示一张图的任务而不是完成一次人机校验的流程。真正要做的是让验证码的生成、存储、校验、销毁形成一个闭环每个环节都有明确的约束。从技术选型上看图片验证码的实现方式大致分三类。第一类是纯后端生成图片用 Java 的BufferedImage、Python 的Pillow、Node 的canvas这类库直接画图前端只负责展示。第二类是前端 Canvas 绘制后端只下发字符答案的哈希。第三类是接入第三方验证码服务。对于大多数中小型项目第一类是最稳妥的选择逻辑集中在服务端前端改动小排查问题也方便。下面我就以这个方案为主线把整套实现拆开讲。2. 服务端生成图片的完整链路字符、干扰与存储2.1 随机字符池的设计为什么不能只用数字很多人图省事验证码只生成四位数字。从用户体验看确实好认但从安全角度看字符空间太小。四位纯数字只有一万种组合脚本用 OCR 识别加枚举很快就能突破。我的做法是数字加大小写字母混合长度控制在四到六位。但这里有个坑字母里有些字符长得太像用户根本分不清。比如数字0和大写O、数字1和小写l和大写I、数字2和字母Z。如果字符池里同时包含这些用户输错率会明显上升客服投诉随之而来。所以我在实际项目里会主动剔除易混淆字符字符池大概长这样private static final String CHAR_POOL 23456789ABCDEFGHJKLMNPQRSTUVWXYZ;注意这里去掉了0、1、I、O、L这些。字符池剩下 32 个字符取四位组合数是 32 的四次方约一百万种。这个量级配合错误次数限制脚本基本没戏。生成随机数的时候不要用Math.random()。它基于伪随机算法种子可预测在安全场景下不够用。Java 里用SecureRandomPython 里用secrets模块Node 里用crypto.randomInt。这些是密码学安全的随机源生成的字符序列不可预测。2.2 图片绘制的参数宽度、高度、字号与干扰线图片尺寸直接影响识别难度和用户体验。太小的图字符挤在一起用户看不清太大的图加载慢移动端体验差。我一般用120×40 像素作为基准四位字符字号 28 左右字符间距均匀分布。绘制流程大致是创建BufferedImage对象拿到Graphics2D画笔先填充背景色再画干扰元素最后画字符。背景色不要用纯白稍微带一点灰度或浅色能增加 OCR 识别的难度。我常用的是浅灰蓝或者浅米色。干扰元素是图片验证码的灵魂。没有干扰的验证码OCR 识别率能到 95% 以上加了干扰之后能压到 30% 以下。常见的干扰手段有三种干扰线在图片上随机画若干条曲线或直线颜色比背景深一点但不要盖住字符主体。噪点随机撒一些像素点密度控制在每平方像素 0.05 左右太多会影响可读性。字符旋转与扭曲每个字符随机旋转 -15 到 15 度或者做轻微的仿射变换。我试过把干扰线加到 8 条结果用户投诉看不清。后来降到 4 到 5 条且线条颜色用半透明效果就平衡了。干扰的目的是让机器难识别不是让人也识别不了这个度要把握好。字符颜色也要随机化。如果所有字符都是同一个颜色OCR 可以通过颜色分割来提取字符。每个字符用不同的深色比如深蓝、深绿、深红、深紫能有效干扰基于颜色聚类的识别算法。2.3 答案存哪里Session、Redis 还是签名令牌验证码生成后答案不能直接返回给前端必须存在服务端。存储位置有三种常见选择各有适用场景。Session 存储是最传统的方式。用户请求验证码时服务端生成一个 Session ID通常通过 Cookie 下发把答案存进 Session。校验时从同一个 Session 里取答案比对。这种方式实现简单但有个明显问题分布式部署时会话不共享。如果后端有多台机器用户第一次请求打到 A 机器第二次校验打到 B 机器B 机器找不到 Session校验必然失败。Redis 存储是我现在最推荐的方式。生成验证码时服务端生成一个唯一标识比如 UUID把标识 - 答案这个键值对写进 Redis设置 60 到 120 秒的过期时间。前端拿到图片的同时也拿到这个标识校验时把标识和用户输入一起提交。Redis 天然支持分布式过期时间自动清理不用手动维护。键的命名可以用captcha:{uuid}这种前缀方便排查和统计。签名令牌是一种无状态方案。服务端把答案和一个时间戳拼在一起用密钥做 HMAC 签名把答案时间戳签名编码后返回给前端。校验时前端把这一串传回来服务端验签并检查时间戳是否过期。这种方式不需要存储但答案暴露在前端虽然被签名保护且无法实现一次性使用因为服务端不记录状态。除非有特殊的无状态需求否则我不推荐这种方式。下面是一个 Redis 存储的典型实现片段String captchaId UUID.randomUUID().toString(); String code generateRandomCode(); redisTemplate.opsForValue().set( captcha: captchaId, code, 120, TimeUnit.SECONDS ); // 返回 captchaId 和图片的 Base64 给前端这里有个细节过期时间要略大于用户完成登录的合理时间。60 秒对大多数人够用但如果用户手慢或者网络卡可能刚输完就过期了。我一般设 120 秒既给了余量又不至于让攻击者有太多时间。3. 前端交互与校验时机什么时候比对比对完做什么3.1 图片刷新机制点击刷新与自动刷新用户看不清验证码时必须能刷新。最常见的交互是点击图片刷新前端重新请求一次生成接口拿到新图片和新标识替换掉旧的。这里要注意刷新后旧标识对应的答案要立即失效否则攻击者可以保留旧标识继续尝试。实现上前端维护一个变量存当前的captchaId每次刷新都覆盖它。后端在生成新验证码时如果知道旧标识可以主动删除旧的 Redis 键。但更简单的做法是让旧键自然过期因为攻击者拿着旧标识去校验时如果服务端已经用新标识覆盖了会话旧标识就查不到了。自动刷新是另一种思路验证码每隔 60 秒自动换一张。这能防止用户长时间停留在一个页面上用同一个验证码慢慢试。但自动刷新会让正在输入的用户突然发现图片变了体验不好。我的折中方案是只在用户点击刷新时换图同时后端设置较短的过期时间两者结合。3.2 校验的触发点提交时校验还是失焦时校验校验时机有两种设计。一种是用户点击登录按钮时把账号、密码、验证码一起提交服务端先校验验证码通过后再校验账号密码。另一种是验证码输入框失焦时就单独校验一次通过后才允许提交登录。第一种方式请求少但用户体验差如果验证码错了账号密码也白填了要重新输。第二种方式体验好但多一次请求且需要防止有人绕过前端直接调登录接口。我的做法是两者结合前端失焦时做一次预校验给用户即时反馈后端登录接口里再做一次强制校验确保安全。前端的预校验只是体验优化后端的校验才是真正的防线。这里有个关键原则任何前端校验都可以被绕过服务端必须独立完成完整校验。我见过有项目只在前端比对验证码后端登录接口完全不检查结果脚本直接调登录接口就绕过了。这种漏洞在安全扫描里是必报项。3.3 校验通过后的销毁一次性使用的实现验证码校验通过后必须立即从 Redis 里删除对应的键。这保证了同一个验证码只能用一次。如果不删除攻击者可以在验证码有效期内反复使用同一个答案配合账号枚举效率会高很多。删除的时机要精确在比对成功的那一刻删除而不是在登录成功之后删除。因为登录可能因为账号密码错误而失败但验证码本身已经用掉了用户需要重新获取。如果等到登录成功才删那账号密码错误时验证码还能重复用就失去了意义。代码上大概是这样String cached redisTemplate.opsForValue().get(captcha: captchaId); if (cached null) { return Result.fail(验证码已过期); } redisTemplate.delete(captcha: captchaId); if (!cached.equalsIgnoreCase(userInput)) { return Result.fail(验证码错误); } // 继续校验账号密码注意这里先删除再比对。这样即使比对失败验证码也已经作废防止暴力尝试。有人会问那用户输错一次就要重新获取是不是太严格了确实严格但安全场景下这个取舍是值得的。如果觉得太严可以改成错误三次后才作废但实现会复杂一些。4. 那些年踩过的坑从内存泄漏到 OCR 绕过4.1 图片对象不释放导致的内存问题用 Java 的BufferedImage生成图片时如果频繁创建而不释放会占用大量内存。我遇到过一次线上问题验证码接口 QPS 上来之后服务的内存曲线一路飙升最后触发 Full GC接口响应时间从 50ms 涨到 2s。排查后发现Graphics2D对象用完没有dispose()。这个对象持有大量图形上下文资源不释放就会堆积。修复很简单在绘制完成后加一行graphics.dispose();另外BufferedImage本身虽然由 GC 管理但在高并发下创建频率太高也会给 GC 带来压力。可以考虑用对象池复用图片对象但实现复杂度较高。对于大多数项目确保dispose()调用加上合理的 JVM 堆大小就够了。4.2 验证码被 OCR 识别的真实案例我曾经做过一次内部测试用开源的 OCR 库去识别自己生成的验证码。第一版验证码只有字符没有干扰识别率 92%。加了干扰线后降到 65%。再把字符旋转和颜色随机化加上降到 28%。最后把字符间距打乱、加噪点降到 11%。这个测试说明干扰手段要叠加使用才有效。单一手段很容易被针对性破解。比如只加干扰线OCR 可以先做去线处理只做旋转可以做旋转校正。但多种手段叠加后预处理成本急剧上升攻击者往往就放弃了。不过也要注意不要为了防 OCR 把验证码做得连人都认不出。我见过一些验证码字符扭曲得像波浪用户要试五六次才能过。这种设计在安全上可能得分但在用户体验上是负分。我的经验是以正常视力的人一次能认出为标准在这个前提下尽量增加机器识别难度。4.3 分布式环境下的 Session 不一致前面提到过 Session 存储在分布式环境下的问题这里展开说一个真实场景。某项目后端部署了三台机器用 Nginx 做负载均衡默认轮询策略。用户请求验证码打到机器 ASession 存在 A 的内存里。用户提交登录时请求打到机器 BB 的 Session 里没有验证码答案校验直接失败。当时的临时方案是配置 Nginx 的ip_hash让同一 IP 的请求固定打到同一台机器。但这有两个问题一是用户切换网络比如从 WiFi 切到 4G后 IP 变了会话就丢了二是负载不均衡某些 IP 段请求集中时会把单台机器压垮。根本的解决方案还是把验证码答案外置到 Redis。这样无论请求打到哪台机器都能从同一个 Redis 里读到答案。改造之后负载均衡策略可以随意调整不用再担心会话问题。这个坑让我深刻体会到任何依赖本地内存的状态在分布式环境下都是隐患。4.4 验证码接口本身被刷验证码接口虽然不涉及账号密码但如果被高频调用也会消耗服务器资源。生成一张图片涉及随机数、绘图、编码CPU 开销不小。如果攻击者用脚本疯狂请求验证码接口可能把 CPU 打满影响正常用户。防护手段有几个限制单 IP 的请求频率比如每分钟最多请求 10 次验证码给验证码接口加简单的令牌校验要求请求方先获取一个一次性令牌才能请求图片监控异常流量当某个 IP 的请求量突增时自动加入黑名单。我在项目里用的是 Redis 做频率限制以 IP 为键每次请求时INCR并设置 60 秒过期。如果计数超过阈值直接返回 429。这个逻辑用 Redis 的原子操作实现几行代码就能搞定效果立竿见影。5. 从可用到好用几个提升体验与安全的细节5.1 图片格式与传输优化验证码图片默认用 PNG 格式无损压缩适合包含文字和线条的图像。JPEG 在有干扰线的场景下会产生压缩噪点反而可能影响识别不推荐。PNG 的缺点是文件体积比 JPEG 大但验证码图片本身很小120×40 的 PNG 通常只有几 KB传输压力可以忽略。传输方式上有两种选择直接返回图片二进制流或者返回 Base64 编码的字符串。前者需要前端用img标签的src直接指向接口地址浏览器会自动处理缓存和显示。后者需要前端把 Base64 塞进img的src属性多了一步解码但好处是接口可以返回 JSON把captchaId和图片数据放在一起前端处理更统一。我倾向于后者因为把标识和图片放在同一个响应里能避免前端维护两个接口调用。前端拿到响应后把 Base64 赋给图片把标识存起来逻辑很清晰。5.2 无障碍与降级方案图片验证码对视觉障碍用户不友好这是它天生的缺陷。如果项目有无障碍要求需要提供替代方案比如语音验证码把字符读出来用户听音输入。语音验证码的实现成本比图片高需要音频生成和存储但能覆盖一部分特殊用户。另一个降级方案是邮箱或短信验证码。当图片验证码连续失败多次后引导用户走其他验证路径。这既能防止暴力破解又能给正常用户一条出路。我在实际项目里的做法是图片验证码作为默认方案连续失败三次后弹出提示引导用户联系管理员或走其他验证方式。这样既保证了安全性又不会把用户逼到死角。5.3 日志与监控怎么知道验证码在被攻击验证码接口的日志要记录几个关键字段请求时间、请求 IP、生成的标识、校验结果成功/失败/过期。这些数据积累起来可以做很多分析。比如如果某个 IP 在短时间内大量请求验证码但校验全部失败基本可以判定是脚本在尝试。如果某个时间段的验证码请求量突增可能是被盯上了。如果校验失败率突然升高可能是验证码图片生成出了问题用户看不清。我在项目里配了一个简单的监控每分钟统计验证码请求量和校验失败率超过阈值就发告警。这个监控帮我提前发现过几次异常流量在造成实质影响之前就做了限流处理。5.4 验证码的有效期与错误次数策略有效期和错误次数是两个需要权衡的参数。有效期太短用户来不及输入太长攻击者有更多时间尝试。错误次数限制太严用户容易误伤太松防不住暴力破解。我的经验值是有效期 120 秒单个验证码最多允许错误 3 次超过 3 次立即作废并要求重新获取。同时同一 IP 在 10 分钟内最多获取 20 次验证码防止脚本无限刷新。这些参数不是固定的要根据实际流量和攻击情况调整。如果发现攻击量上升可以临时收紧如果用户投诉太多可以适当放宽。关键是这些参数要可配置而不是硬编码在代码里这样调整时不用重新发布。6. 一套可复用的验证码模块该怎么组织6.1 接口设计生成与校验分离一个清晰的验证码模块应该暴露两个接口生成接口和校验接口。生成接口返回图片和标识校验接口接收标识和用户输入返回成功或失败。生成接口的路径可以是GET /captcha/generate校验接口是POST /captcha/verify。两个接口的职责单一便于测试和复用。校验接口可以被登录、注册、找回密码等多个场景调用不用每个场景都重新实现一遍。接口的返回结构要统一。生成接口返回{ captchaId, imageBase64 }校验接口返回{ success, message }。这样前端处理起来很一致不用为每个接口写不同的解析逻辑。6.2 配置项抽离让参数可调验证码模块里有一堆参数字符池、字符长度、图片宽高、干扰线数量、过期时间、错误次数上限、IP 频率限制。这些参数不应该散落在代码各处而应该集中在一个配置类或配置文件里。我通常会在application.yml里加一段captcha: length: 4 width: 120 height: 40 expire-seconds: 120 max-errors: 3 ip-limit-per-minute: 10然后在代码里通过ConfigurationProperties注入。这样调整参数时只改配置不用动代码也方便不同环境用不同的值。比如测试环境可以把过期时间设长一点方便调试生产环境设短一点更安全。6.3 单元测试怎么验证验证码逻辑正确验证码模块的测试有几个要点。生成接口的测试要验证返回的图片不为空、标识不为空、Redis 里有对应的键。校验接口的测试要覆盖几种情况正确输入、错误输入、过期输入、重复使用同一个标识。过期输入的测试可以用一个技巧生成验证码后手动删除 Redis 里的键再调用校验接口应该返回已过期。重复使用的测试是第一次校验成功后再用同一个标识校验一次应该返回已过期或无效。这些测试能覆盖大部分边界情况保证模块的健壮性。我在项目里把这些测试放在 CI 流程里每次提交代码都跑一遍防止改动引入回归问题。6.4 与其他安全措施的配合验证码不是孤立的安全措施它要和账号锁定、IP 黑名单、行为分析等配合使用。比如同一账号连续登录失败 5 次后除了要求验证码还应该临时锁定账号 15 分钟。同一 IP 连续触发验证码错误 20 次后应该加入临时黑名单。这些措施叠加起来才能形成有效的防护体系。单独一个验证码面对有组织的攻击还是会被慢慢磨穿。但加上频率限制、账号锁定、IP 封禁之后攻击成本会高到不值得。我在实际项目里把这些逻辑封装成一个SecurityService登录接口调用它来完成验证码校验 账号锁定检查 IP 频率检查这一整套流程。这样登录接口本身保持简洁安全逻辑集中管理后续调整也方便。最后分享一个我在多个项目里验证过的小技巧验证码的字符池和图片样式可以定期轮换。比如每周换一次字符池的组成或者调整干扰线的样式。这样即使攻击者针对当前样式训练了 OCR 模型过一段时间就失效了需要重新训练。这个轮换不需要很频繁但能有效增加攻击者的持续成本。实现上可以把样式参数做成可配置的通过定时任务或者配置中心动态调整不用改代码。