一个常被忽略的事实
后台管理系统泄露账号,和社交网站泄露账号,完全不是一个量级。
社交账号丢了,损失的是一个账号;后台账号丢了,攻击者拿到的是订单、客户资料、配置、数据库连接,甚至服务器权限。但很多开源后台的登录保护是这样的:
- 只有账号密码,顶多加个图形验证码;
- 所谓的"2FA"就是登录页多一个输入框,输错也不锁;
- 验证码可以重复提交,30 秒内能反复用;
- 换个登录入口(短信登录、第三方登录、扫码登录),二次验证就被绕过去了。
ZRAdmin 是一个基于 .NET 8 + SqlSugar + Vue 3 的开源后台管理框架(MIT 协议)。它在最近的版本里补了一套完整的登录安全能力,核心就是双因子认证(TOTP)+ 可信设备 + 异常感知。
这篇文章不讲"怎么加一个验证码框",而是讲清楚一条企业级第二因子链路在工程上要解决哪些问题。文末附项目地址和在线体验。
一、先明确:这里的"因子"到底是什么
安全领域的因子分三类:你知道的(密码)、你持有的(设备/令牌)、你本身具有的(指纹/人脸)。
ZRAdmin 当前落地的组合:
| 因子类型 | 实现 | 说明 |
|---|---|---|
| 知识因子 | 密码 / 短信验证码 | 第一因子,三条登录入口都支持 |
| 持有因子 | TOTP 认证器(RFC 6238) | 6 位、30 秒、HMAC-SHA1、±1 时间窗 |
| 持有因子(兜底) | 10 个一次性恢复码 | 认证器丢失时使用,只存哈希 |
| 设备因子 | 服务端签发的设备凭据 | 256 bit 随机,"记住此设备"免二次验证 |
| 生物/硬件因子 | WebAuthn / Passkey | 常量与前端渲染结构已预留 |
严格说,当前是"双因子 + 可信设备",不是把所有因子都堆上。但结构是开放的:可用方法由服务端下发,前端只按列表渲染入口,后续接入 WebAuthn 不需要改前端契约。
这比"先做二十种因子再谈安全"更现实。先把两条链路做对,比画出五条链路更重要。
二、登录链路:不是加一个输入框,而是拆成两个阶段
很多项目的 2FA 实现是:登录成功后返回 token,再让用户补一个验证码。这种实现等于"先发钥匙再验身份",攻击者在拿到 token 的瞬间就已经登录成功了。
ZRAdmin 的做法是把登录拆成两段,第一段只验密码,不签发 token。
POST /login
账号密码校验通过
│
├─ 未启用 2FA / 命中可信设备
│ └─ 直接签发 JWT,登录完成
│
└─ 需要第二因子
└─ 不签发 token,返回业务码 203 + 一次性票据(TTL 5 分钟)
│
▼
POST /login/2fa/verify
票据 + 动态码(或恢复码)
│
├─ 校验通过 → 签发 JWT,落一条登录日志
└─ 校验失败 → 归还票据(可重试),失败计数 +1
几个不那么显然的设计点:
1)用 203 而不是 200
密码通过但还没过第二因子时,服务端并没有签发 token,这个状态不能返回 200。
很多客户端只判断 code == 200,看到 200 就把响应里的 token 写进本地存储。如果"待验证"也返回 200,这些客户端会把"没登录完"当成"登录成功",拿着空 token 或旧 token 继续请求——问题最终会在某个端上变成真实的越权。
所以这里单独定义了业务码 203(MFA_REQUIRED):响应里只有票据和可用方法,没有 token。"成功"和"待验证"在契约层面彻底分开,客户端是否处理 203 是显式选择,而不是靠字段猜。
2)票据是一次性的,但输错了会归还
票据通过 Redis 的 SETNX(未启用 Redis 时回退内存锁)原子抢占消费标记,并发提交同一票据只有一个请求能通过。
但输错验证码时票据会被归还——否则用户手抖打错一位,就得重新输密码。归还时按首次签发的绝对过期时间算剩余 TTL,反复重试不会给票据续命。
3)第二步是独立请求,租户信息必须放在票据里
多租户一租户一库的场景下,第二步请求到达时还没有登录态,服务端不知道该读哪个库。
所以票据缓存 key 刻意不加租户前缀(否则永远读不到),租户 ID 放在票据载荷里,消费后再切换租户上下文。
这是"一套代码跑多租户"时很容易踩到的坑:第一段和第二段根本不在同一个上下文里。
4)半程登录不写成功日志
密码通过但还没过 2FA 的请求,不写登录日志、不更新最后登录时间。日志只由第二步按最终结果落一条,避免统计报表里出现一堆"登录成功"但其实没进系统。
三、九个容易被忽略的安全细节
1. 重放:同一串码只能成功一次
TOTP 的验证码在 30 秒内是固定值。如果校验只返回 true/false,攻击者在这 30 秒里可以反复使用同一串码。
ZRAdmin 的 ValidateCode 返回的不是布尔值,而是命中的时间步(step):
public static long? ValidateCode(string secret, string code, int window = DefaultWindow, ...)
{
var currentStep = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
for (var offset = -window; offset <= window; offset++)
{
var step = currentStep + offset;
var expected = ComputeCodeByStep(key, step, digits);
if (FixedTimeEquals(expected, normalized))
{
return step; // 返回 step 而不是 true
}
}
return null;
}
调用方拿到 step 后,用 TryMarkTwoFactorStep(userId, step, 5) 做原子标记。同一个时间步只允许成功一次,第二次直接判为重放。
比对用 CryptographicOperations.FixedTimeEquals,避免通过响应时间逐位猜测。
2. fail-closed:解不开密钥就拒绝登录
TOTP 密钥用 AES-GCM 加密后落库。那么问题来了:如果加密口令变了、密文损坏了,解密失败时应该怎么办?
最直觉的写法是 if (解密失败) return 未启用。这会造成一个致命后果:保护被静默摘掉,账号降级成仅密码登录。
项目的选择是反过来的:
public bool IsEnabled(SysUser user)
{
// 只看库中标记,刻意不探测密钥能否解密
return user != null && user.TwoFactorEnabled == 1;
}
密钥不可用时,第二步校验直接报错并提示联系管理员重置,而不是放行。同时状态接口会返回 SecretUsable=false,让"已启用但密钥异常"这件事能被看见,而不是伪装成"未启用"。
安全设计里有个朴素的原则:异常时选择拒绝,而不是选择放行。
3. 密钥保护:AES-GCM,不是 Base64
密钥密文的格式是:
Base64( nonce[12] || tag[16] || ciphertext )
用 GCM 而不是 ECB/CBC,是因为它自带完整性校验:密文被篡改时解密直接失败,而不是产出一把"看起来能用的脏密钥"。
另外两个细节:
TwoFactorSecret和恢复码字段都标了[JsonIgnore],任何接口都不会把它带出去;- 已启用 2FA 的账号调用"重新绑定"接口会被直接拒绝。否则任何拿到有效会话的人(token 泄露、XSS)不需要密码和动态码,就能把别人的第二因子摘掉。
4. 设备凭据:指纹只能当名字,不能当钥匙
"记住此设备"是最容易被做错的功能。
常见实现是前端算一个浏览器指纹,存到 localStorage,下次登录带上,服务端比对一致就跳过 2FA。问题是:指纹是客户端可控的字符串,攻击者把它抄过来就能免验证——这等于自己把 2FA 拆了。
ZRAdmin 的信任判定只认服务端签发的凭据:
- 勾选"记住此设备"时,服务端生成 32 字节随机数,base64url 编码后返回客户端;
- 库里只存 SHA256 哈希;
- 下次登录,客户端带回明文,服务端哈希后比对;
- 每次勾选"记住"都轮换凭据,旧凭据当场作废;
- 客户端指纹只用于设备列表展示和审计归并,不参与信任判定。
设备表还做了两件小事:一是每个账号最多保留 20 条设备记录,超出后按最后使用时间淘汰最旧的非信任设备(防止伪造指纹刷表);二是普通登录不会清掉已有的信任期,只有勾选"记住"时才改写。
5. 恢复码:一次性,且并发下也只能用一次
恢复码是认证器丢失时的唯一退路,也是攻击者最想要的东西。
实现上:10 个恢复码,格式 XXXXX-XXXXX,字母表去掉了 0/O/1/I 等易混字符,库里只存 SHA256,明文只在启用时返回一次。
真正麻烦的是并发。如果按"读出 JSON → 删除一项 → 写回整列"的方式实现,两个请求同时提交同一个恢复码,可能都判定成功——一次性恢复码被用了两次。
项目用的是 CAS 条件更新:
var affected = Context.Updateable<SysUser>()
.SetColumns(it => it.TwoFactorRecoveryCodes == newJson)
.Where(it => it.UserId == user.UserId && it.TwoFactorRecoveryCodes == oldJson)
.ExecuteCommand();
return affected == 1;
只有"库中恢复码仍等于读取时那一份"的更新才生效,并显式检查影响行数。
恢复码被使用时还会触发一次告警(站内信 + 邮件,可配)。因为这是唯一能让用户发现"有人在用我的恢复码"的渠道——可能是换机,也可能是账号已经被盯上。
6. 暴力枚举:5 次失败锁 15 分钟,告警要限频
6 位数字只有 100 万种组合,不限制就能被跑穿。
默认策略:连续失败 5 次,锁定 15 分钟;校验成功即清零。
告警同样重要,但告警本身也会成为攻击面:攻击者可以故意反复触发锁定,把你的邮箱刷爆。所以告警做了账号级限频(默认 60 分钟一次),用原子的 TrySet 实现,多实例部署下也只有一个节点能抢到。
邮件走后台线程发送,SMTP 慢的时候不会拖住登录响应;站内信复用已有的 SYSTEM 消息类型,保证前端消息红点能正常显示。
7. 改密之后,必须清空设备信任
这条最容易被漏掉。
"记住此设备"的信任期默认 7 天。用户改密码,往往正是因为怀疑密码泄露。如果只改密码、不清信任,那台已经记住的设备在信任期内照样免动态码——改了等于没改。
所以自助改密和管理员重置密码都会调用同一个收敛方法:清空该账号全部设备信任期,并给用户发一条站内信。
注意这里只清信任期,保留设备登记记录。这样用户从常用设备重新登录时不会被误报成"新设备",历史设备也仍然可见。
8. 扫码登录不能成为绕过 2FA 的旁路
移动端扫码登录有个天然的时间差:用户可能在启用 2FA 之前就拿到了 token,然后用这个旧会话去扫码,PC 端就登录成功了。
项目的处理是给每个会话打一个"已通过 2FA"的标记(TTL 与 JWT 一致,续期时同步续期),扫码时校验:
if (!CacheService.IsTwoFactorSessionOk(tokenModel.UserId, tokenModel.SessionId)
&& twoFactorService.IsEnabled(tokenModel.UserId))
{
throw new CustomException(ResultCode.LOGIN_ERROR,
"该账号已启用双因子认证,请在移动端重新登录后再扫码", false);
}
每一条能拿到 token 的路径,都要单独检查一遍。 这是做登录安全最耗精力、也最容易出事的地方。
9. 审计:日志要能看出"这次到底怎么进来的"
登录日志新增了第二因子结果字段,取值是 none / totp / recovery_code / trusted_device。
为什么单独记?因为恢复码登录和可信设备登录,在日志的 Msg 字段里原本和普通登录一样,都是"登录成功"。而这两类恰恰最需要审计:
recovery_code:认证器丢了,或者恢复码被人拿到了;trusted_device:本次根本没有校验动态码。
同时保留第一步的凭据类型(密码 / 短信 / oauth:wechat_enterprise),排查时能精确到"用哪个渠道、哪种因子进来的"。
四、多入口一致性
账号密码登录、手机号验证码登录、第三方授权登录——三条入口,三套代码。
如果只在密码登录里加 2FA,另外两条就是现成的后门。这类问题在代码评审里极难发现,因为每条链路单独看都是对的。
ZRAdmin 的做法是把判定和响应收敛成两个公共点:
RequiresTwoFactor(user, deviceToken):三条入口共用同一套判定,设备凭据哈希也统一计算;LoginResponseMapper.Map(result):三条入口共用同一份响应映射,避免"某个入口漏了 203 分支"。
第三方登录尤其要注意:oauth:渠道 这种精确的鉴权类型必须由第一步写进票据、第二步原样落日志,不能在第二步按登录来源现推——现推只能得到"密码/短信"这种粗粒度,会把"用微信登录"记成"用密码登录"。
五、落地成本
配置全部可缺省,不配也能跑。个人中心提供 6 个接口(状态查询、初始化、启用、解绑、设备列表、设备吊销),权限统一为 common,路径都在 /system/user/profile/twoFactor 下;登录侧只动 /login 和新增的 /login/2fa/verify。数据层是 sys_user 加 5 个字段,再加一张 sys_user_2fa_device 设备表——设备要单独吊销、按用户和过期时间查询,塞进 JSON 列做不到。
六、关于 ZRAdmin
ZRAdmin(ZR.Admin.NET)是一个基于 .NET 8 的开源通用权限管理框架,MIT 协议,可商用。
除了登录安全,它还内置了:
- SaaS 多租户:一租户一库,租户全生命周期、套餐授权、用户配额、到期提醒;
- 可视化工作流:流程版本管理、会签/或签/依次审批、加签减签、转办委托、驳回策略、Webhook、效率统计;
- 代码生成器:一键生成 Model / Service / Controller / Vue 页面;
- 任务调度:Quartz.NET,支持程序集与 HTTP 任务;
- AI 助手:全局对话、工具编排、业务页 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 交流边界场景。
七、关于 ZRAdmin
ZRAdmin(ZR.Admin.NET)是一个基于 .NET 8 的开源通用权限管理框架,MIT 协议,可商用。
除了登录安全,它还内置了:
- SaaS 多租户:一租户一库,租户全生命周期、套餐授权、用户配额、到期提醒;
- 可视化工作流:流程版本管理、会签/或签/依次审批、加签减签、转办委托、驳回策略、Webhook、效率统计;
- 代码生成器:一键生成 Model / Service / Controller / Vue 页面;
- 任务调度:Quartz.NET,支持程序集与 HTTP 任务;
- AI 助手:全局对话、工具编排、业务页 AI、AI 治理与限额;
- 常规能力:RBAC 权限、数据权限、字典、日志、限流、SignalR、Redis、国际化。
技术栈:.NET 8 + Web API + SqlSugar + Swagger + Redis + Quartz.NET + Vue 3 + Element Plus。