写在前面
最近在做一个政务系统的登录模块时,扒出来一段看着能跑、实则漏洞百出的老验证码代码。
说它"能跑",是因为用户确实能收到验证码、能登录;说它"漏洞百出",是因为------它居然把验证码的明文答案,直接返回给了前端。🤦
这就好比考试的时候,老师把标准答案印在了试卷右上角,然后跟你说"别偷看哦"。你说这考试还有意义吗?
今天咱们就把这个 GenerateRandomCode() 方法开刀复盘,看看怎么从"答案满天飞",改造又安全、又体验好、还能跑在 Linux 容器里。
放心,全程大白话 + 代码,看完你也能上手。👇
🎬 一、这个接口在干啥?
简单说,就两步:
- 用户打开登录页,前端请求
GET /login/generaterandomcode,后端返回一张验证码图片。- 用户看着图把码敲进输入框,点登录,前端
POST /login,后端校验码对不对。
业务上再普通不过。问题出在"返回的那一下"------老方案返回的不是图片,而是验证码本身。
咱们先看看"案发现场"。🔍
👀 二、老代码长啥样?
老接口里留着这么一段:
ini
public async Task<IActionResult> GenerateRandomCode()
{
// ⚠️ 下面这段是"老方案"的化石
string randomCode = "";
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var random = new Random();
char[] code2 = new char[4];
for (int i = 0; i < 4; i++)
{
code2[i] = chars[random.Next(chars.Length)];
}
randomCode = new string(code2);
await _iLoginBLL.GenerateRandomCode(randomCode); // 存进数据库
return Success(randomCode); // ⚠️ 把明文返回给前端!
}
看起来逻辑清晰:随机拼 4 位 → 存库 → 返回。但只要你多看两眼,就能数出 4 个大坑。💣
🕳️ 三、四个坑,一个比一个狠
🕵️ 坑 1:验证码明文返回前端
注意最后那句 return Success(randomCode);------验证码的正确答案,被直接塞进了接口的响应体(JSON)。
这意味着什么?
用户哪怕不看图片,只要打开浏览器 F12 → Network,或者拿个抓包工具,就能直接看到答案。
打个比方:考场门口贴了张"今日答案",监考老师还站那儿维持秩序。这验证码防了个寂寞。😅
🗄️ 坑 2:每次登录都往数据库塞一条
那句 await _iLoginBLL.GenerateRandomCode(randomCode),最终走的是一条 INSERT 语句,往表里写一行,明文落库。
这一下惹出俩麻烦:
- 白给数据库加压:验证码这种"几分钟后就没用"的短时数据,存内存/缓存就够了,何必惊动数据库?
- 按"码的值"校验,会串号 :老校验逻辑是"拿用户输入的码去表里
SELECT,命中就软删"。可万一两个用户碰巧拿到了同一个码(4 位字符,撞码概率不低),就可能互相干扰------A 把码用掉了,B 那边就怎么都过不了。
🔤 坑 3:字符池里全是"双胞胎"
看老代码的字符池:
arduino
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
大小写字母 + 数字全上,听起来很"随机"、很"安全"。可对肉眼来说,这简直是灾难:
- 数字
0和大写字母O,长得几乎一模一样; - 数字
1、小写l、大写I,仨兄弟撞脸; - 小写
o又来凑热闹......
结果就是用户明明看对了、敲"对"了,系统却说错。客服后台一堆"我输的明明是对的"投诉,多半是这玩意儿惹的。😡
🔁 坑 4:错了也不失效,可以一直爆破
老校验逻辑是"命中才软删"------也就是说,只有输对了,那条码才会被删掉。
那要是有人写个脚本,对着同一个验证码接口疯狂试呢?
4 位字符,哪怕算上大小写也就千万级组合。脚本一秒几千次,分分钟爆破成功。
输错不删 = 这道门可以无限次"试钥匙",直到试开为止。这对防机器人来说,基本等于不设防。
🛠️ 四、新方案:对症下药
坑看完了,咱们一招一招拆。思路先行,代码后面贴。
第一招:答案不出门(治"裸奔")
接口只返回一张 PNG 图片 ,明文验证码永远不离开服务端。前端拿到的就是一张图,F12 翻烂了也只看到一堆图片字节。
第二招:会话标识走 Cookie(治"串号")
后端生成一个 Guid 当"会话标识"(captchaId),通过 HttpOnly Cookie 下发给浏览器。
HttpOnly 的意思是:前端的 JavaScript 读不到这个 Cookie。这样一来,就算页面被注入了恶意脚本(XSS),也偷不走你的会话标识。
而且每个会话一个 Guid,彻底告别"按码的值串号" ------你的码只跟你自己的 captchaId 绑定。
第三招:答案存分布式缓存(治"惊动数据库")
明文码不再落库,而是写进分布式缓存(如 Redis),5 分钟自动过期。
好处有二:
- 不压数据库,验证码这种短命数据就该待在缓存里;
- 多实例一致:服务部署好几台做负载均衡时,任何一台都能校验------因为缓存是共享的,不像本机内存那样"各存各的"。
第四招:字符池剔除"双胞胎"(治"看错投诉")
新字符池主动拿掉了 0/O、1/I/l 这些撞脸字符:
arduino
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
一眼看去没有 0、O、1、I、l、o、i。用户看啥就是啥,投诉立竿见影地少。
而且校验时用大小写不敏感 比较------图片上画的是大写 A,用户敲个小写 a 照样过。既保留了"大小写混合"对机器识别(OCR)的干扰,又不折腾真人用户。🎯
第五招:错也删,一次性消费(治"爆破")
校验的时候,不管用户输对输错,先把这个码从缓存里删掉。
一次一码,用过即焚。脚本想爆破?对不起,每试一次就得重新请求一张新图,爆破成本直接拉满。
第六招:画图用 SkiaSharp(治"Linux 跑不起来")
老办法画验证码常依赖 System.Drawing,这玩意儿在 Windows 上好好的,一上 Linux 容器就抓瞎 (要装 libgdiplus,坑巨多)。
新方案改用 SkiaSharp ------谷歌 Skia 的 .NET 版,跨平台,Windows 开发、Linux 部署一套代码通吃 。容器里 Dockerfile 装个 fonts-dejavu-core 就齐活。
✨ 五、新代码长啥样
干货来了。整个新方案分三块,咱们一块块看。
块 1:生成图片 + 验证码(主角)
这是真正的"产生验证码"逻辑,放在一个静态帮助类 CaptchaImageHelper 里:
csharp
public static class CaptchaImageHelper
{
// 字符池:去除易混淆的 0/O、1/I/l
private const string CodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
// 一组品牌色调,用来随机给笔画、噪点、字符上色
private static readonly SKColor[] Palette = { /* 6 个 SKColor */ };
public static (byte[] Image, string Code) Create(int length = 4, int width = 120, int height = 40)
{
// ⭐ 每次调用都 new 一个 Random:避开"共享 Random 实例不线程安全"的经典坑
var random = new Random();
// 1) 从字符池里随机挑 length 个字符,拼成明文验证码
char[] codeArr = new char[length];
for (int i = 0; i < length; i++)
codeArr[i] = CodeChars[random.Next(CodeChars.Length)];
string code = new string(codeArr);
using (var bitmap = new SKBitmap(width, height))
using (var canvas = new SKCanvas(bitmap))
{
canvas.DrawColor(new SKColor(0xF7, 0xF9, 0xFC)); // 浅色背景
// 2) 干扰线:随机起止点 + 半透明随机色
// 3) 字符:随机颜色 + 随机轻微旋转(-20°~20°),水平居中
// 4) 噪点:40 个半透明小圆点
// (这三段都是用同一个 random 画"视觉噪声",机器更难认)
using (var image = SKImage.FromBitmap(bitmap))
using (var data = image.Encode(SKEncodedImageFormat.Png, 90))
{
return (data.ToArray(), code); // 返回 PNG 字节 + 明文
}
}
}
}
几个关键点敲黑板:
- 字符池 55 个候选字符 (24 大写跳过 I/O + 23 小写跳过 i/l/o + 8 个数字跳过 0/1),4 位组合约 915 万种。配合下面"一次性消费",爆破基本没戏。
- 每次
new Random()是故意的:System.Random的单个实例不是线程安全 的,多线程并发调Next可能互相破坏内部状态。每次新建一个,简单粗暴地躲开这个坑。- 字符随机旋转 + 随机上色 + 干扰线 + 噪点,全是给机器识别(OCR)使绊子。
块 2:接口只返回图片,明文留服务端
csharp
[HttpGet("login/generaterandomcode")]
public async Task<IActionResult> GenerateRandomCode()
{
// 生成会话标识 + 4 位验证码
string captchaId = Guid.NewGuid().ToString("N");
var (pngBytes, code) = CaptchaImageHelper.Create();
// 验证码写入分布式缓存(5 分钟有效)
const int ttlMinutes = 5;
string cacheKey = $"captcha:{captchaId}";
await _iCache.WriteAsync(cacheKey, code, TimeSpan.FromMinutes(ttlMinutes));
// 通过 HttpOnly Cookie 下发会话标识,body 仅返回图片
Response.Cookies.Append("captchaId", captchaId, new CookieOptions
{
HttpOnly = true,
IsEssential = true,
Expires = DateTimeOffset.Now.AddMinutes(ttlMinutes),
SameSite = SameSiteMode.Lax, // 跨域前端必须 None+Secure;同域部署可改 Lax
Secure = false
});
return File(pngBytes, "image/png"); // ⭐ 只返回图片,明文 code 不出门
}
注意:明文
code只进了缓存 ,从没进过响应体。前端拿到的是captchaId(在 Cookie 里)+ 一张 PNG。
块 3:登录校验------"错也删",一次性消费
csharp
[HttpPost("login")]
public async Task<IActionResult> Login(LoginInputDto loginInputDto)
{
// 从 Cookie 里取会话标识
if (!Request.Cookies.TryGetValue("captchaId", out string captchaId)
|| string.IsNullOrEmpty(captchaId))
{
return Fail("验证码已失效,请刷新");
}
string cacheKey = $"captcha:{captchaId}";
string savedCode = await _iCache.ReadAsync<string>(cacheKey);
// ⭐ 无论对错都立即删掉,防止爆破------一次一码,用过即焚
await _iCache.RemoveAsync(cacheKey);
// 大小写不敏感比较:画的是 A,敲个 a 也算对
if (string.IsNullOrEmpty(savedCode) ||
!string.Equals(savedCode, loginInputDto.VerificationCode, StringComparison.OrdinalIgnoreCase))
{
return Fail("验证码错误");
}
return OK(await _iLoginBLL.Login(loginInputDto));
}
最精华的就是那句
await _iCache.RemoveAsync(cacheKey);------在比较之前就先把码删了。输对输错,这张码都作废,想拿同一个码反复试?没门。
📊 六、效果对比
口说无凭,直接上对比表:
| 维度 | 老方案 | 新方案 |
|---|---|---|
| 验证码答案 | 明文返回前端(F12 可见) | 只返回图片,明文不出服务端 |
| 存储方式 | 数据库表(明文落库) | 分布式缓存,5 分钟自动过期 |
| 会话隔离 | 按"码的值"软删,可能串号 | 每会话一个 Guid,互不干扰 |
| 易混淆字符 | 含 0/O、1/I/l,看错率高 | 主动剔除,大小写不敏感比较 |
| 防爆破 | 错了不删,可无限试 | 错也删,一次一码 |
| 跨平台 | 依赖 System.Drawing,Linux 受限 | SkiaSharp,Windows/Linux 通吃 |
| 多实例一致 | 落库才能共享 | 缓存天然共享,负载均衡无压力 |
一张表看下来,新方案是安全、体验、运维三头并进。
🧠 七、几条能带走的经验
复盘不是为了炫技,是为了下次少踩坑。这几点,希望能帮到你:
- 别把要校验的秘密,返回给被校验的那一端。 验证码、校验码、临时令牌都算------答案不出门是底线。
- 验证码这类"短命数据",用缓存,别落库。 几分钟就过期的东西,惊动数据库既慢又费。
- 字符池主动剔除"双胞胎"字符(0/O、1/I/l)。 一个小改动,投诉少一半,体验立竿见影。
- "错也删"是一次性消费,是防爆破的关键一行。 记住:校验前先作废,而不是校验对了才作废。
new Random()每次新建,够用且线程安全。 这是反直觉但正确的写法。当然,说句老实话 :System.Random并不是加密级安全的随机数;对于"防机器人点一下"的普通验证码,这是业界惯例、完全够用。但如果你这个码要保护的是高价值资产(比如重置密码、资金操作),那就请换上加密级的RandomNumberGenerator------别图省事。
顺带提一句上线注意:示例里 Cookie 是
Secure=false、SameSite=Lax,本地开发没问题;生产环境如果前后端跨域,记得改成SameSite=None+Secure=true(走 HTTPS),代码注释里也标了这个 TODO。
💬 写在最后
一个"用户能登录"的老接口,背后藏着明文泄露、数据库压力、字符撞脸、可被爆破一串问题。
其实很多线上"验证码老是报错""好像有人刷接口",根子都在这类不起眼的老代码里。
重构不是推倒重来,而是把隐患一个个排掉。 哪怕只是把"返回明文"改成"返回图片",也是在给系统扎紧一道篱笆。💪
那么问题来了------
你项目里的验证码,是哪种写法?还在"明文返回前端"吗?欢迎在评论区聊聊,咱们一起"排雷" 👇
关注公众号 CodeHu,可获取完整源码