给开源后台做等保适配,我发现了 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。
相关地址
- GitHub:github.com/izhaorui/Zr...
- Gitee:gitee.com/izory/ZrAdm...
- 官方文档:www.izhaorui.cn/doc
- Web 端体验:demo.izhaorui.cn/vue3
- 移动端体验:demo.izhaorui.cn/uplus
- 账号密码:
user / 123456
如果这篇排查思路对你有用,欢迎点个 Star。项目是 MIT 协议,代码可以直接拿去做二次开发,也欢迎提 Issue 交流合规场景。
最后
等保这件事,很多人以为是在买设备、写制度、凑材料。
但真正会让你在测评现场卡住的,往往是代码里那些"看起来有"的东西:
一个 return true、一处漏掉的入口、一次物理 TRUNCATE、一个 maxlength="20"。
合规不是把功能清单填满,是让每一条规则真的能拦住人。