登录验证码工程实战:Pillow绘制、Redis校验与防刷限流
1. 一次撞库告警引出的需求验证码在登录体系中的真实定位大概在几个月前我负责维护的一个社区类网站突然收到了登录接口的高频告警。日志里同一个来源IP以每秒两三次的频率连续尝试不同的用户名和密码明显是在跑字典。一开始我以为只是普通的扫描流量把那个IP封掉就完事了。但第二天换了新的IP继续打而且每次尝试都走完了登录接口的正常流程只是不存在验证码环节。那一刻我意识到登录模块的缺口不在于密码算法而在于缺少一道“人机识别”的关卡。于是就有了“用户登录 随机验证码”这个改造案例。很多刚开始写登录功能的人会把验证码当成一个可有可无的装饰品甚至觉得它就是“为了不让用户太闲”而存在的。但真正经历过撞库、批量注册、刷接口这些攻击之后你会明白验证码在登录体系里的定位很具体它提高了自动化攻击的成本。验证码不能保证绝对安全但它能把批量脚本挡在门外让攻击者必须换成人肉识别或打码平台成本瞬间上升好几个量级。对一个中小型站点来说这已经足够形成有效威慑。理解这个定位很重要因为它直接决定了验证码的设计方案。既然定位是“提高自动化成本”那么验证码就必须具备几个硬条件服务端生成、服务端校验、随机性足够、一次性消费、有时效限制。缺了任何一条都可能被绕过。比如把验证码直接放在前端页面的隐藏字段里那就是把钥匙挂在门上又比如生成逻辑写在JS里攻击者直接调用同一个JS函数就能生产验证码。这些都是实战中常见的错误。在动手写代码之前我建议先想清楚一个场景你的登录接口到底在保护什么如果只是自己内部系统可能一个简单的固定密码就够了如果是面向公网的用户系统验证码几乎是刚需。它挡住的不是睿智的用户而是没有感情的脚本。基于这个前提我在这个案例里选择了最经典的图形随机验证码方案后端生成随机字符串绘制成图片把答案存在Redis里前端展示图片并让用户输入登录时后端比对并立刻作废。方案确定之后剩下的问题就是怎么把这个流程做到“能用”和“好用”。下面我会按验证码的生命周期一条条拆解。你会发现每个环节都有值得推敲的细节有些坑不走到生产环境根本踩不到。2. 验证码从生成到消亡的完整链路每步都在防什么先把整条链路画在脑子里浏览器请求验证码图片 - 后端生成随机文本并画图 - 随机文本存入服务端存储案例中是Redis - 图片返回给浏览器 - 用户填写登录表单用户名 密码 验证码 - 后端拿到用户输入和验证码标识 - 从存储取出正确答案 - 比对 - 成功则继续登录逻辑并删除验证码失败则给提示并可更换。这个链路看起来简单但每一步的细节都决定了安全性。先说验证码的值和显示图片的关系。很多没做过的人会好奇为什么不让前端生成一个随机数然后直接显示出来因为那样攻击者只要看HTML源码就能拿到答案验证码就失去了意义。随机验证码必须由服务端生成且答案不能以任何直接形式暴露在客户端。图片本身就是“密文”而它的识别对象是人眼不是程序。然后是存储。为什么用Redis而不是直接存到Session或数据库因为验证码是短生命周期的高频数据用内存型存储最合适。Session也可以但如果你后面上了多实例部署Session默认无法跨机器共享就会遇到“这台机器生成验证码那台机器校验验证码”的尴尬。Redis天然支持分布式共享而且可以设置过期时间非常贴合验证码“短命”的特性。我这里用的具体存储策略是用一个随机的UUID作为验证码的标识captcha_id把它作为key前缀的一部分答案作为value。请求验证码图片时后端生成UUID和随机字符串把captcha:{uuid}作为key存到Redis过期时间5分钟然后把UUID通过Cookie或响应体返回给前端。提交登录表单时前端必须带上这个UUID和用户输入的验证码文本。后端拿着UUID找Redis里的答案再和用户输入比对。这样做的好处是验证码和用户身份没绑定可以灵活适配前后端分离、跨域等场景。易混淆字符过滤也是我这次特意加入的。生成随机字符的字符集里我把0、O、1、l、I、i这些视觉上容易混淆的都剔除了。你可能觉得无所谓但用户在手机上看到验证码是“0”还是“O”真的会反复输入好几次。一旦用户连续输错三次他自己就会烦躁更糟的是他可能会认为是你的系统太难用。所以在生成环节多排除几个字符是性价比非常高的体验优化。防什么这个问题在链路里其实很清晰防止攻击者从图片里自动读出答案防止验证码被重复使用防止验证码被无限期保留。所以线条、噪点、干扰线的存在是为了对抗OCR程序UUID的存在是为了让每次验证码都有独立身份Redis过期时间是为了让验证码“过期作废”后面要说的“校验成功后立即删除”则是为了不让同一个验证码被用第二次。3. 图形验证码的生产细节随机源、Pillow绘图与防OCR设计技术选型上我用的是Python Flask Pillow Redis。三者的分工非常清楚Flask负责提供接口Pillow负责把文字画成图片Redis负责存答案。有很多人一提到验证码就想去调第三方API但自己的业务里集成一个简单的图形验证码还不至于要到付费服务的程度。自己画反而更好控制风格也能够随时调整干扰强度。先看生成验证码文本的代码。我把随机源从random模块换成了secrets模块因为secrets基于系统熵源随机性更强。验证码虽然只是登录校验但涉及对抗自动化能多一分随机是一分。import secrets import string # 字符集去掉易混淆字符 CAPTCHA_CHARS 23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ def generate_captcha_text(length: int 4) - str: return .join(secrets.choice(CAPTCHA_CHARS) for _ in range(length))接着是绘图。Pillow的基本思路是创建一张空白图片先画背景和干扰线、噪点再把字符一个一个绘制上去。这里有几个经验参数可以借鉴。图片尺寸我用了160x60字符数4个字体大小约40像素。为什么是4个字符因为3个字符太容易被枚举5个字符在PC端尚可但手机上就显得拥挤且难认。4个字符是在安全和体验之间比较折中的选择。干扰线和噪点也不能乱画。干扰线数量控制在全图6条左右每条线的颜色在灰度范围内随机避免颜色过深反衬出字符。噪点控制在300个以内太多了会连成片用户肉眼都看不清。有的验证码库把干扰线画得跟蜘蛛网一样结果用户每次都要输错几次这其实是过度设计。防OCR的效果边际递增用户体验却边际递减要找到那个平衡点。我的实测感受是“能一眼认出但程序难以自动分割”是最佳状态。字符绘制方面我做了两个小动作。一个是每个字符在水平方向加上小幅偏移不是整齐排列破坏字符的垂直对齐增加OCR切分的难度另一个是给每个字符随机选择不同颜色颜色集中在深色系避免和干扰线混在一起。还有一个更进阶的操作是对字符进行微旋转。直接用ImageDraw.text不支持旋转需要先把每个字符单独渲染到透明图层再旋转后贴到背景图。旋转角度在正负30度之间比较安全太大用户会认不出。下面是一段完整的生成接口代码包含了刚才说的几个关键点import io import uuid from PIL import Image, ImageDraw, ImageFont from flask import make_response import redis r redis.Redis(host127.0.0.1, port6379, db0) def draw_captcha(text: str) - Image.Image: width, height 160, 60 image Image.new(RGB, (width, height), (245, 245, 245)) draw ImageDraw.Draw(image) # 干扰线 for _ in range(6): x1 secrets.randbelow(width) y1 secrets.randbelow(height) x2 secrets.randbelow(width) y2 secrets.randbelow(height) draw.line((x1, y1, x2, y2), fill(secrets.randbelow(155) 100, secrets.randbelow(155) 100, secrets.randbelow(155) 100), width1) # 噪点 for _ in range(300): xy (secrets.randbelow(width), secrets.randbelow(height)) draw.point(xy, fill(secrets.randbelow(255), secrets.randbelow(255), secrets.randbelow(255))) # 字体 font_path /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf font ImageFont.truetype(font_path, 40) char_width width / len(text) for idx, ch in enumerate(text): x idx * char_width secrets.uniform(2, 10) y secrets.uniform(2, 10) draw.text((x, y), ch, fill(secrets.randbelow(80), secrets.randbelow(80), secrets.randbelow(100)), fontfont) return image app.route(/captcha) def get_captcha(): captcha_id uuid.uuid4().hex text generate_captcha_text() # 统一存小写比对时不区分大小写 r.setex(fcaptcha:{captcha_id}, 300, text.lower()) img draw_captcha(text) buf io.BytesIO() img.save(buf, formatPNG) resp make_response(buf.getvalue()) resp.headers[Content-Type] image/png resp.set_cookie(captcha_id, captcha_id, max_age300, httponlyTrue, samesiteLax) return resp字体路径这里容易踩坑。很多服务器上字体文件路径并不固定如果使用ImageFont.truetype加载一个不存在的字体接口会直接抛异常。更稳妥的做法是把字体文件放进项目目录用相对路径加载。另外提示一点如果你准备生成中文验证码必须确保字体包含中文字形否则会画出一堆方框。考虑到中文在图形验证码里识别难度更高我建议默认用字母数字混合即可。还有一个容易被忽略的细节图片的Content-Type一定要设置正确并且建议在响应头里加上Cache-Control: no-store。否则部分浏览器会默认缓存图片导致用户点击刷新后看到的还是同一张图。关于缓存问题后面第五部分会细说这里先埋个伏笔。4. 登录接口怎么消费验证码校验位置、一次性原子操作与并发处理验证码生成得再好如果登录接口的消费逻辑不对前面全白搭。我见过一些实现把验证码校验和账号密码粗暴地分成两次接口调用先调一个接口验证验证码是否正确返回“验证码正确”后再调另一个接口传用户名密码。这是很危险的等于给攻击者提供了一个“验证码是否正确的免费预测接口”他可以用它来批量探测验证码。正确的做法是验证码和用户名密码放在同一个登录请求里一次提交、一起校验。所以我的登录接口长这样app.route(/login, methods[POST]) def login(): data request.get_json() username data.get(username, ).strip() password data.get(password, ) captcha_code data.get(captcha_code, ).strip() captcha_id request.cookies.get(captcha_id) or data.get(captcha_id) if not captcha_id or not captcha_code: return jsonify({code: 400, msg: 请填写验证码}) key fcaptcha:{captcha_id} # 使用 Redis 6.2 的 GETDEL读取并删除保证一次性 saved r.getdel(key) if saved is None: return jsonify({code: 400, msg: 验证码已过期请刷新}) if saved.decode().lower() ! captcha_code.lower(): return jsonify({code: 400, msg: 验证码错误}) # 验证码通过后再校验账号密码 user find_user_by_username(username) if user is None or not verify_password(user, password): return jsonify({code: 400, msg: 用户名或密码错误}) session_token create_session(user.id) return jsonify({code: 200, token: session_token})这里最关键的一行是r.getdel(key)。它把“读取验证码”和“删除验证码”合并成一个原子操作。为什么不用普通的r.get(key)再加r.delete(key)因为在高并发场景下一个验证码可能同时被两个请求拿着去比对。你先get再delete中间有间隙两个请求都能读到同一个值导致验证码被重复使用。而GETDEL能保证只有一个请求能取到值取完即删后面的请求只能拿到None。这个细节在登录接口被刷的时候尤其重要因为攻击者完全可能一个验证码图片配置多个账号并行尝试。再说校验位置。我的代码把验证码校验放在密码校验之前并且取一次就删一次。带来的副作用是如果用户密码输错了验证码也一起失效用户必须刷新验证码重新输入。从体验上讲确实有点狠但从安全上讲这是最保守的策略。为什么不能密码错但验证码保留因为这样攻击者在同一个验证码生命周期内可以反复尝试不同密码验证码的拦截意义就打了折扣。如果你觉得体验太差可以折中失败但保留验证码并限制同一个验证码最多比对3次比对完成后强制失效。但实现复杂度会上升而且每一次比对都要浪费一次Redis访问。考虑到这个案例面向的是中小型站点我选择了一错即删的简单策略在用户提示上写清楚“验证码错误或已失效请刷新”用户也基本能理解。还有一个必须处理的边界验证码正确了但用户名或密码错误这时候验证码已经被删掉了。用户在页面上点“登录”可能会连续两次都因为密码错误而需要重新输验证码。这会让用户觉得验证码怎么老是过期。我后来在提示文案上做了区分“验证码错误”和“用户名或密码错误”分开显示让用户知道下一步该干什么。如果验证码错误直接刷新图片如果验证码正确但密码错误用户只需要重新输入密码但抱歉验证码还是需要再填一次。这个交互在日常使用中多少有点反人性但它是安全性和简单性平衡的结果。除了GETDEL之外另一个值得说的是验证码比对时的大小写问题。用户输入验证码时经常分不清大小写或者输入法自动把首字母大写导致明明看对了却提示错误。所以我在存储和比对时统一转成小写。这个惯例几乎所有验证码系统都在用代价是字符空间略有缩减但换来的是用户体验和通过率的大幅提升值。登录成功后的会话管理不在这次案例的标题里但既然写了登录我顺带提一句后续的会话最好用随机不可预测的token而不是自增ID。token需要设置过期时间并且保存在服务端或使用签名字段。我自己的习惯是使用短时有效的签名token并在Redis里记录刷新状态这样可以在用户封禁、密码修改等场景下主动失效会话。账号密码校验本身不是这个案例的核心就不展开细写了。5. 上线后我踩过的真实坑位图片缓存、跨域Cookie、OCR对抗与防刷限流图形验证码看起来是个小功能真正上线跑起来之后才会遇到各种边角问题。下面这几个坑是我和某同学一起把这个案例部署到真实站点后一周内逐个排查出来的。每个都有具体场景和解决方式希望对你有帮助。5.1 图片缓存导致“刷新后不换图”第一个线上反馈就是用户说“验证码图片坏了怎么刷新都不变”。我一开始以为是Redis没存上后来打开浏览器开发者工具才发现是HTTP缓存。虽然接口路径是/captcha但浏览器认为同一个URL的资源没有变化直接用了本地缓存。解决方案很粗暴前端每次刷新图片时在URL后面拼一个时间戳或随机数。function refreshCaptcha() { const img document.getElementById(captchaImg); img.src /captcha?t Date.now(); }同时后端在响应头里加上Cache-Control: no-store双保险。这里还有个小经验Cookie里的captcha_id也要一并刷新因为图片和id是配对的。好在图片接口每次返回都会重新种Cookie所以前端不需要额外处理。5.2 前后端分离下的跨域Cookie问题如果是纯前后端分离前端页面和后端API不在同一个域浏览器默认不会在跨域请求中携带Cookie。我的案例最初是服务端渲染模板Cookie天然带着。后来同事要把登录模块拆到Vue前端时发现验证码图片能显示但提交登录时后端拿不到captcha_id。解决办法有两个方向。第一个方向是后端允许CORS携带凭证from flask_cors import CORS CORS(app, supports_credentialsTrue, origins[https://你的前端域名])前端fetch请求中需要设置credentials: include。但这种方式要求后端明确指定允许的源不能用通配符*否则浏览器会拒绝。第二个方向更简单不依赖Cookie而是把captcha_id放在接口响应的JSON里返回前端把captcha_id存到表单隐藏字段或内存中提交登录时随请求体一并发给后端。因为图形验证码的captcha_id本身不是敏感信息放在JSON里不会增加什么风险我后来推荐团队用这个方案彻底绕开跨域Cookie的兼容地狱。5.3 OCR对抗没想象中那么简单也没想象中那么玄我写这个案例时故意加了干扰线和噪点但我承认专业的OCR库确实能一定程度识别这种基础图形验证码。自己画验证码能防住的是“没有针对性训练”的通用脚本。如果攻击者真的盯上你的站他可以用深度学习方法针对你的图片样本训练分类模型识别率会非常高。所以要做对抗升级的话可以加字符重叠、波浪变形、背景纹理。Pillow画起来都能实现但会增加用户识别难度。这里要有一个清醒的认知纯图形验证码的上限就在那里。如果你的业务对安全要求很高后期应该考虑滑块行为验证、无感验证或二步验证。但作为第一道门槛随机图形验证码已经能把成本抬到足够高。至少在我们这个案例里上线之后登录接口被脚本扫的频率明显下降撞库日志几乎消失了。打码平台虽然存在但每个验证码的价格累积起来也是一笔开销对小规模攻击者来说不划算。5.4 Redis故障时的降级策略Redis是验证码的核心存储如果它挂了登录接口会直接报错。我在案例上线前没有考虑这个问题结果某次运维重启Redis时登录功能全部被影响。事后想了一下这里不能简单降级为“不验证验证码”那等于给攻击者开绿灯。比较合理的降级方案是至少保住错误提示的可读性在接口里捕获Redis连接异常返回“验证码服务暂时不可用请稍后重试”。同时加强Redis的可用性比如主从部署。本地内存存储只能作为单机开发环境的临时方案不能上生产因为多实例部署时各个实例的本地缓存互不可见会导致“这个节点签发的验证码那个节点不认账”。5.5 别让登录接口自己变成攻击入口限流必须有验证码挡的是“低成本的批量尝试”但如果攻击者绕过验证码或者使用打码平台登录接口依然会被高频调用。所以登录接口层面必须叠一层限流。我在登录接口里加了基于IP的简单计数限流from flask import request, jsonify def login_rate_limit(): ip request.remote_addr key flogin_rate:{ip} current r.incr(key) if current 1: r.expire(key, 60) if current 10: return False return True这段逻辑的意思是同一个IP在一分钟内最多只能请求登录接口10次。这个阈值可以根据实际用户行为调整比如公司内网多人共用出口IP时10次就不够用要适当放宽。另外还应该对同一账号的登录失败次数单独计数防止攻击者用大量IP慢速撞库。这些限流规则和验证码不是二选一而是配合使用。5.6 日志脱敏验证码错误别记明文排查问题时我发现日志里有人把验证码明文和提交的完整参数一起打出来了包括密码。这相当危险。正确的日志应该只记录结果比如“登录失败验证码错误”而不是记录用户填写的验证码是什么。密码更是任何时候都不能出现在日志里。哪怕只是在本地开发环境也要养成这个习惯。后来我把登录模块的日志输出统一封装了一份脱敏工具对password、captcha_code这类字段直接替换成***。6. 从这次案例想到的下一步优化思路验证码上线两周后整体表现符合预期登录接口自动化请求明显减少用户侧的通过率也稳定在可接受范围。回顾这个案例最让我有感触的不是某个代码技巧而是“验证码绝不是孤立功能”这件事。它和会话管理、限流策略、前端交互、运维稳定性绑在一起任何一个环节出问题用户感受到的就是“登录真难用”。如果你准备在自己的项目里参考这个案例我建议先从小而简单的方案起步Pillow画验证码 Redis存校验值 登录接口一次性消费然后再逐步加码限流、负载均衡、日志监控。不要一上来就追求复杂的滑块或行为式验证码那是另一套体系成本也高。图形验证码虽然技术含量不高但它是登录安全的第一道闸门值得认真对待。最后说一个我目前还在继续打磨的方向验证码识别难度的自适应调节。目前干扰强度是固定的未来可以根据用户的异常行为动态调整比如IP连续失败超过3次后给这个用户下发更高难度的验证码正常用户则始终保持低难度。这个优化做起来需要在生成接口里读取调用方的失败计数然后动态选择绘制参数。听起来不复杂但涉及的地方比我预想得多我后面会单独拉出来写一篇。这次先说到这里希望这个登录验证码案例的复盘对你有用。