.NET/C# 实战:我如何把登录验证码从“裸奔”改到“铜墙铁壁”

写在前面

最近在做一个政务系统的登录模块时,扒出来一段看着能跑、实则漏洞百出的老验证码代码。

说它"能跑",是因为用户确实能收到验证码、能登录;说它"漏洞百出",是因为------它居然把验证码的明文答案,直接返回给了前端。🤦

这就好比考试的时候,老师把标准答案印在了试卷右上角,然后跟你说"别偷看哦"。你说这考试还有意义吗?

今天咱们就把这个 GenerateRandomCode() 方法开刀复盘,看看怎么从"答案满天飞",改造又安全、又体验好、还能跑在 Linux 容器里。

放心,全程大白话 + 代码,看完你也能上手。👇


🎬 一、这个接口在干啥?

简单说,就两步:

  1. 用户打开登录页,前端请求 GET /login/generaterandomcode,后端返回一张验证码图片
  2. 用户看着图把码敲进输入框,点登录,前端 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/O1/I/l 这些撞脸字符:

arduino 复制代码
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"

一眼看去没有 0O1Iloi。用户看啥就是啥,投诉立竿见影地少。

而且校验时用大小写不敏感 比较------图片上画的是大写 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 通吃
多实例一致 落库才能共享 缓存天然共享,负载均衡无压力

一张表看下来,新方案是安全、体验、运维三头并进。


🧠 七、几条能带走的经验

复盘不是为了炫技,是为了下次少踩坑。这几点,希望能帮到你:

  1. 别把要校验的秘密,返回给被校验的那一端。 验证码、校验码、临时令牌都算------答案不出门是底线。
  2. 验证码这类"短命数据",用缓存,别落库。 几分钟就过期的东西,惊动数据库既慢又费。
  3. 字符池主动剔除"双胞胎"字符(0/O、1/I/l)。 一个小改动,投诉少一半,体验立竿见影。
  4. "错也删"是一次性消费,是防爆破的关键一行。 记住:校验前先作废,而不是校验对了才作废。
  5. new Random() 每次新建,够用且线程安全。 这是反直觉但正确的写法。当然,说句老实话System.Random 并不是加密级安全的随机数;对于"防机器人点一下"的普通验证码,这是业界惯例、完全够用。但如果你这个码要保护的是高价值资产(比如重置密码、资金操作),那就请换上加密级的 RandomNumberGenerator------别图省事。

顺带提一句上线注意:示例里 Cookie 是 Secure=falseSameSite=Lax,本地开发没问题;生产环境如果前后端跨域,记得改成 SameSite=None + Secure=true(走 HTTPS),代码注释里也标了这个 TODO。


💬 写在最后

一个"用户能登录"的老接口,背后藏着明文泄露、数据库压力、字符撞脸、可被爆破一串问题。

其实很多线上"验证码老是报错""好像有人刷接口",根子都在这类不起眼的老代码里。

重构不是推倒重来,而是把隐患一个个排掉。 哪怕只是把"返回明文"改成"返回图片",也是在给系统扎紧一道篱笆。💪


那么问题来了------

你项目里的验证码,是哪种写法?还在"明文返回前端"吗?欢迎在评论区聊聊,咱们一起"排雷" 👇

关注公众号 CodeHu,可获取完整源码

相关推荐
王中阳Go2 小时前
AI编程实践:规则 + 技能 + 子代理 + 钩子,拆解Trae AI 开发规范的完整落地范式
后端·agent·ai编程
码事漫谈2 小时前
放下代码洁癖,享受挖坑人生
后端
_张有志_2 小时前
手撕 STL:一棵红黑树,凭什么同时撑起 set 和 map?
后端
妙码生花2 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(二十七):管理员登录验证码开关
前端·后端·ai编程
她的男孩2 小时前
当 Spring Boot 遇上 Flowable 7:Forge 框架企业级工作流引擎架构全解析
人工智能·后端·程序员
java小白小2 小时前
SpringBoot(15):日志框架选型与配置——Logback 从入门到生产级
后端
庄周de蝴蝶3 小时前
两次考试,总算压线通过系统分析师
后端·架构
孫治AllenSun3 小时前
【MDC】使用 MDC 封装一个 LogMdcUtil 工具类
java·spring boot·后端
神奇小汤圆3 小时前
Java 26正式发布!这3个新特性,让代码量直接减半
后端