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(host='127.0.0.1', port=6379, db=0) 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), width=1) # 噪点 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)), font=font) return image @app.route('/captcha') def get_captcha(): captcha_id = uuid.uuid4().hex text = generate_captcha_text() # 统一存小写,比对时不区分大小写 r.setex(f'captcha:{captcha_id}', 300, text.lower()) img = draw_captcha(text) buf = io.BytesIO() img.save(buf, format='PNG') resp = make_response(buf.getvalue()) resp.headers['Content-Type'] = 'image/png' resp.set_cookie('captcha_id', captcha_id, max_age=300, httponly=True, samesite='Lax') 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 = f'captcha:{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_credentials=True, 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 = f'login_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次后,给这个用户下发更高难度的验证码;正常用户则始终保持低难度。这个优化做起来需要在生成接口里读取调用方的失败计数,然后动态选择绘制参数。听起来不复杂,但涉及的地方比我预想得多,我后面会单独拉出来写一篇。这次先说到这里,希望这个登录验证码案例的复盘对你有用。