给开源后台做等保适配,我发现了 5 个"看起来有、其实没用"的功能 从一个尴尬的现场测试说起

给开源后台做等保适配,我发现了 5 个"看起来有、其实没用"的功能

从一个尴尬的现场测试说起

等保测评有一个环节,测评师会坐在你旁边,让你现场操作。

轮到身份鉴别这一项时,他提了个要求:

"你把密码改成 123456 试试。"

我们打开个人中心,输入 123456,系统弹出一行红字:密码强度不符合要求。

测评师点点头,准备往下走。但我知道,拦住这次修改的其实不是校验逻辑,而是另一段前端表单规则------因为后端那个"密码强度"函数,最后一行是 return true。

这篇文章不是"等保十大要点"的复读机。它记录的是:一个已经跑了几年的开源后台项目,在做等保适配时,从代码里翻出来的那些**"文档上有、演示时能用、真被测就露馅"**的地方。


一、先破除两个常见误解

误解一:日志留存 6 个月,是等保的要求

不是。这句话来自《网络安全法》第二十一条,不是等保标准。

这个区别很关键,因为它决定了你该做什么:

  • 等保对审计的要求是"审计记录应受到保护、定期备份,避免受到未预期的删除、修改或覆盖";
  • 网安法要求网络日志留存不少于六个月。

一个是"别让人删",一个是"留够时间"。方向完全不同。

我们项目里原来的"清空登录日志"接口长这样:

csharp 复制代码
public void TruncateLogininfo()
{
    Truncate();   // 物理清空
}

接口有管理员校验、有独立权限、有操作日志------权限控制做得挺规范。但它做的事情是 TRUNCATE TABLE。

测评师看到这个接口,问了一句:"日志被删了,你怎么证明六个月内的登录行为?"

正确做法不是加一个定时删除任务,而是先归档再清理:

csharp 复制代码
public void TruncateLogininfo()
{
    var newTableName = $"sys_logininfor_{DateTime.Now:yyyyMMdd}";
    if (Queryable().Any() && !Context.DbMaintenance.IsAnyTable(newTableName))
    {
        Context.DbMaintenance.BackupTable("sys_logininfor", newTableName);
    }
    Truncate();
}

同一份数据,从"消失"变成"归档到历史表"。改动量二十行,但这才是合规要的东西。

误解二:过等保要从零搭一套安全体系

实际情况往往相反:大部分能力你早就有了,只是没生效。

我们在动手前先盘了一遍家底:

能力 状态
登录失败锁定 已有,可配 maxRetryCount / lockTime
密码有效期 已有,可配 passwordExpireDay
初始密码强制修改 已有,initPasswordModify
双因子 / Passkey 已有,TOTP + WebAuthn 全套
接口限流 已有,AspNetCoreRateLimit
数据脱敏 已有,MaskUtil
操作日志 / 登录日志 已有

真正缺的只有三项:密码复杂度、会话空闲超时、审计日志保护。

等保测评的逻辑不是"你有没有这个功能",而是"这个功能是不是真的拦住了"。功能清单齐全但校验被绕过,比没有功能更容易被判不符合------因为这说明流程失控。


二、五个"看起来有、其实没用"

1. 密码强度校验的函数,最后一行是 return true

这是最典型的一个。项目里确实有密码强度校验,也确实是注册时调用的:

csharp 复制代码
if (!Tools.PasswordStrength(dto.Password))
{
    throw new CustomException("密码强度不符合要求");
}

看起来很合理。但打开函数实现:

csharp 复制代码
public static bool PasswordStrength(string password)
{
    int iNum = 0, iLtt = 0, iSym = 0;
    foreach (char c in password)
    {
        if (c >= '0' && c <= '9') iNum++;
        else if (c >= 'a' && c <= 'z') iLtt++;
        else if (c >= 'A' && c <= 'Z') iLtt++;
        else iSym++;
    }

    if (iLtt == 0 && iSym == 0) return false; // 纯数字密码
    if (iNum == 0 && iLtt == 0) return false; // 纯符号密码
    if (iNum == 0 && iSym == 0) return false; // 纯字母密码

    if (password.Length >= 6 && password.Length < 16) return true;

    if (iLtt == 0) return true;
    if (iSym == 0) return true;
    if (iNum == 0) return true;

    return true; // 由数字、字母、符号构成的密码
}

它实际挡住的是:纯数字、纯符号、纯字母。

也就是说,a12345 能通过,abc123 能通过,任何长度 ≥ 6 的混合字符都能通过。注释里写的是"长度不大于6的密码",但条件其实是 < 16------注释和代码已经对不上了,说明这段逻辑很久没人认真看过。

这类代码的危险在于:它在代码评审里看起来是完全正确的。 函数名对、有字符分类、有返回值、有注释,只有逐行读到最后一个 return true 才会发现问题。

2. 三处改密入口,只有一处有校验

即使上面那个函数是好的,它也只在一个地方被调用。

后台系统的改密入口通常有四个,我们逐个查:

入口 校验情况
自助注册 有调用(就是上面那个失效的函数)
个人中心改密 只校验"新旧不能相同"
管理员重置密码 完全没有
管理员新增用户 完全没有

后两个更直接:拿到明文密码,直接 MD5() 落库:

csharp 复制代码
public IActionResult ResetPwd([FromBody] SysUserDto sysUser)
{
    sysUser.Password = NETCore.Encrypt.EncryptProvider.Md5(sysUser.Password);
    // ... 直接更新
}

这意味着:管理员可以创建一个密码为 123456 的账号,而这在测评现场只要演示一次就够了。

这里有个工程约束值得单独说:密码强度必须在明文阶段校验。等密码被 MD5 成 32 位十六进制串之后再校验,已经没有任何意义------那串值本身就"又长又含字母数字",任何规则都能通过。

所以校验点必须放在每个入口 MD5() 调用的前一行 ,而不能收敛进 ResetPwd(userId, password) 这种已经收到哈希值的底层方法里。

3. 批量导入的用户,密码是硬编码的

这一条是翻代码时偶然发现的。Excel 批量导入用户的实现里有这么一段:

csharp 复制代码
users.ForEach(x =>
{
    x.CreateTime = DateTime.Now;
    x.Status = 0;
    x.Password = "E10ADC3949BA59ABBE56E057F20F883E";
});

E10ADC3949BA59ABBE56E057F20F883E 是 MD5("123456")。

也就是说,所有通过 Excel 导入的账号,共用同一个初始密码 123456。

这一条在等保里比"密码太短"严重得多。它属于"默认口令/共用口令",攻击者拿到一份导入名单,可以批量尝试登录。

处理方式有两种,取决于你的运维流程:

  • 保留初始密码 + 强制首次登录修改 (改动最小,依赖已有的 initPasswordModify 配置);
  • 导入时随机生成,结果文件返回明文(无共用口令,但管理员要分发密码)。

我们选了第一种:把初始口令限制为"一次性、必须更改",测评时通常可以接受。但前提是那个强制修改的开关必须是打开的 ------很多项目有这个功能,只是配置里是 0。

4. 会话超时:只有"绝对过期",没有"空闲超时"

这是两个不同的东西:

  • 绝对过期:登录后 7 天,不管用不用,一律失效;
  • 空闲超时:30 分钟没操作,自动登出。

等保对身份鉴别的原文里,"结束会话""登录连接超时自动退出"是明确要求。很多项目只做了前者(JWT 的 exp),以为就够了。

只做绝对过期的问题是:用户早上登录,电脑一直开着不锁屏,到晚上下班前任何人都能直接操作。 这个窗口期内,token 一直是有效的。

我们后来的做法是在鉴权中间件里加一层:

csharp 复制代码
// 位置:会话吊销检查之后、token 自动续期之前
// 顺序很重要------超时的会话不应该再被续出一个新 token
if (!CheckIdleTimeout(context, loginUser, sysConfigService))
{
    await context.Response.WriteAsJsonAsync(
        ApiResult.Error(ResultCode.DENY, "会话已超时,请重新登录"));
    return;
}

活跃时间记在 Redis(未启用时回退内存),键的 TTL 略大于空闲阈值------键自然过期就代表"足够久没活跃",不需要额外的清理任务。

5. HTTPS 跳转被注释掉了

csharp 复制代码
//app.UseHttpsRedirection();

看到这一行,第一反应是"这是个漏洞"。但实际情况要看部署架构:

  • 如果前面有 Nginx / 云负载均衡做 TLS 终止,应用层不跳转是正确的(否则健康检查、内网调用会被 307 打断);
  • 如果 Kestrel 直接暴露在公网,那就是真的有问题。

所以"等保要不要整改 HTTPS"这个问题,答案不在代码里,在部署图里。测评时要提供的是架构说明 + 证书截图 + 抓包证明,而不是"我们代码里有 UseHttpsRedirection"。

这一点很能说明等保的性质:它是结果合规,不是代码合规。


三、前端也是合规的一部分

这一点最容易被后端开发者忽略。密码策略改了后端,前端不改,会出两类问题。

问题一:规则数字对不上

  • 后端:8-32 位
  • 前端改密页:6-20 位
  • 前端注册页:5-20 位(提示文案写的却是"6 和 20")

结果是:

  • 用户输 21-30 位密码 → 前端直接拦掉,但后端是允许的;
  • 用户输 6-7 位密码 → 前端放行,提交后才被后端拒绝。

前者是功能被前端限制,后者是体验断裂。两种都不算漏洞,但都会让用户觉得"这系统有问题"。

问题二:maxlength 会让 rules 失效

Vue3 版本的重置密码弹窗里:

html 复制代码
<el-input v-model="resetPwd.password" show-password maxlength="20" />

你把校验规则改成 max: 32 也没用------输入框物理上就输不进第 21 个字符。

规则是一层,控件属性是另一层。改规则的时候必须一起搜 maxlength。

问题三:随机生成密码的函数,长度低于后端下限

管理端重置密码时,很多系统会预填一个随机密码,方便管理员直接念给用户。Vue3 版本里这个函数写的是:

js 复制代码
// 6 ~ 10 位随机长度
const length = 6 + Math.floor(Math.random() * 5)

后端下限是 8 位。随机到 6 位或 7 位时,系统自己生成的密码自己不合规,管理员点确定直接被拦,还得手动改。

这类问题在测试阶段很难发现,因为它是概率性的------点三次可能两次都合格。


四、改法:把规则收敛到一个地方

四个入口、两套前端,如果每个地方各写一份校验,迟早会漏。所以我们做了三件事。

1. 抽出密码策略服务

规则不再硬编码,全部走 sys_config,读不到就用默认值:

csharp 复制代码
public PasswordPolicy GetPolicy()
{
    var cached = CacheStore.Default.Get<PasswordPolicy>(CacheKey);
    if (cached != null) return cached;

    var policy = LoadPolicy();
    CacheStore.Default.Set(CacheKey, policy, CacheMinutes);  // 60 秒
    return policy;
}

默认规则:8-32 位、必须含数字和小写字母、禁止常见弱口令、禁止包含账号名。

配置项设计成"缺省即默认",好处是不需要写数据迁移脚本------没配过的存量部署,升级后自动生效,不会因为少一条配置就报错。

2. 四个入口统一调用

csharp 复制代码
// 注册
PasswordPolicyService.ValidatePassword(dto.Password, dto.Username);

// 管理员新增
PasswordPolicyService.ValidatePassword(user.Password, user.UserName);

// 管理员重置
PasswordPolicyService.ValidatePassword(sysUser.Password);

// 自助改密
PasswordPolicyService.ValidatePassword(newPassword, user?.UserName);

四处调用位置有一个共同点:都在 MD5 之前。

3. 弱口令用"完全匹配",不用"包含匹配"

csharp 复制代码
if (policy.ForbidWeak && WeakPasswords.Contains(password))
{
    throw new CustomException("密码过于简单,请更换更复杂的密码");
}

这里踩过一个坑:一开始想用"包含常见词就拒绝",结果 normal_password 这种完全合法的口令被误伤。弱口令黑名单应该做等值匹配,长度和字符集要求交给前面的规则。


五、四个反直觉的取舍

这部分是整篇文章里最值得抄走的东西。安全功能加进去不难,难的是在什么情况下允许它失效。

1. 策略读不到时,选择放行(fail-open)

密码策略存在数据库里。如果主库不可用,读配置会抛异常。

这时候如果抛出去,结果是所有人都不能改密码、不能注册------一个安全问题变成了可用性事故。

所以读取失败时回退默认规则并记一条 Warn:

csharp 复制代码
catch (Exception ex)
{
    logger.Warn(ex, "读取密码策略配置失败,已回退默认规则");
    return new PasswordPolicy();
}

判断依据是:这是"防御性"校验,不是"放行性"校验。放行只是回到旧行为(弱密码),而拒绝会让整个系统停摆。

2. 会话超时跳过 SignalR(fail-safe for 长连接)

空闲超时按 HTTP 请求计活跃,但 SignalR 长连接的活跃是 WebSocket 帧,不走 HTTP 管道。

如果不对 /msgHub 做豁免,会出现:用户一直挂着页面、在线状态正常、消息能收到,但 30 分钟后被判定超时踢掉。

csharp 复制代码
if (context.Request.Path.StartsWithSegments("/msgHub")) return true;

3. 缓存丢失时,选择不踢人(fail-open)

空闲超时依赖 Redis 里记录的"最后活跃时间"。如果 Redis 重启、键丢了,怎么办?

我们选择:读不到就当没超时,并重新写入当前时间。

理由很简单:宁可漏踢一次,也不能因为缓存故障把所有在线用户一次性踢光。这和项目里已有的会话吊销逻辑保持同一口径。

4. 空闲超时默认不启用

这个配置默认值是 0,也就是不启用。

不是技术上做不到,而是上线即生效的风险太大------如果默认 30 分钟,升级当天所有挂着不动的人都得重新登录,运维电话会被打爆。

安全能力的默认值,应该让存量部署行为不变,由使用者显式打开。


六、等保真正难的不是功能,是覆盖面

复盘下来,最花时间的不是写校验逻辑,而是找出所有该校验的地方。

  • 改密码的入口有几个?(我们找到了 4 个)
  • 能创建用户的地方有几个?(注册、后台新增、Excel 导入,3 个)
  • 能拿到 token 的路径有几条?(密码、短信、第三方、扫码,4 条)
  • 前端有几套?(Vue2 旧版、Vue3 新版,2 套)

每一条路径单独看都是对的,但漏掉任何一条,就等于整条防线有个洞。

这和做双因子登录时的结论是一样的:安全不是加一个功能,是把每一条路径都堵上。

等保测评之所以有价值,恰恰是因为它会挨个问你:"这里呢?""这个入口呢?"------它逼你把覆盖面想全。


七、关于 ZRAdmin

ZRAdmin(ZR.Admin.NET)是一个基于 .NET 8 的开源通用权限管理框架,MIT 协议,可商用。

上面这些改造已经合进主干。除了本次的合规能力,它还内置:

  • SaaS 多租户:一租户一库、套餐授权、用户配额、到期提醒;
  • 可视化工作流:版本管理、会签/或签/依次审批、加签减签、驳回策略、Webhook、效率统计;
  • 代码生成器:一键生成 Model / Service / Controller / Vue 页面;
  • 任务调度:Quartz.NET,支持程序集与 HTTP 任务;
  • AI 助手:全局对话、工具编排、业务页 AI、治理与限额;
  • 常规能力:RBAC、数据权限、字典、日志、限流、SignalR、Redis、国际化。

技术栈:.NET 8 + Web API + SqlSugar + Swagger + Redis + Quartz.NET + Vue 3 + Element Plus。

相关地址

如果这篇排查思路对你有用,欢迎点个 Star。项目是 MIT 协议,代码可以直接拿去做二次开发,也欢迎提 Issue 交流合规场景。


最后

等保这件事,很多人以为是在买设备、写制度、凑材料。

但真正会让你在测评现场卡住的,往往是代码里那些"看起来有"的东西:

一个 return true、一处漏掉的入口、一次物理 TRUNCATE、一个 maxlength="20"。

合规不是把功能清单填满,是让每一条规则真的能拦住人。


相关推荐
程序猿阿越1 小时前
从 StatefulSet 到容器挂载:CSI 源码走读
后端·kubernetes·源码阅读
苏三说技术2 小时前
继PHP之后,Java也爆出了重大安全漏洞!
后端
程序猿乐锅3 小时前
【从0-1学习Python | 第七篇】Python 面向对象基础
开发语言·笔记·后端·python·学习·1024程序员节
code2cat3 小时前
Java高级篇之Hibernate:让对象映射到表,理解持久化与事务
java·后端·hibernate·1024程序员节
Younglina4 小时前
三个晚上,我和 WorkBuddy 做了个生图网站
前端·后端·ai编程
金銀銅鐵5 小时前
[Java] 用 CLI 展示 EnumSet 增减元素时的内部变化(下)
java·后端·ai编程
陈随易5 小时前
副业搞钱点子之收入查看器
前端·后端
Profile排查笔记5 小时前
浏览器指纹逆向怎么分析:从网络请求追踪到 JavaScript 采集链
前端·人工智能·后端·自动化
lazy H6 小时前
【计算机网络】第 5 篇:TCP 和 UDP 有什么区别?从 Java 后端场景理解两种传输协议
经验分享·后端·网络协议·tcp/ip·计算机网络·1024程序员节