双因子认证(2FA:TOTP + Passkey)
双因子认证(2FA:TOTP + Passkey)
系统内置两类第二因子,登录时统一走同一道闸门:
- TOTP 动态码:认证器 App(Google / Microsoft Authenticator、1Password 等)上的 6 位码,30 秒一换,离线生成,不依赖短信通道,也不增加短信成本;自实现 RFC 6238(HMAC-SHA1 + Base32),无第三方 2FA 依赖。
- Passkey / WebAuthn:安全密钥(U-Key)、Windows Hello、Touch ID / Face ID、同步型通行密钥(iCloud 钥匙串、Google 密码管理器)。服务端只存公钥,私钥永远在认证器内,拖库也无法伪造登录。
其余保障:
- 四个登录入口统一闸门:账号密码、手机号验证码、扫码、第三方授权登录,换一种方式绕不过去;
- 用户自助绑定,TOTP 绑定即下发 10 个一次性恢复码(认证器丢失时的兜底通道);
- 记住此设备:可信设备凭据由服务端签发,信任期内免第二因子,可逐台吊销;
- 全程审计:登录日志记录"第一步凭据 + 第二因子结果",恢复码使用、连续失败告警都有留痕;
- 默认关闭,未绑定的账号登录行为完全不变(不影响存量用户)。
一、登录流程(统一闸门)
四个登录入口的第一步(校验账号密码 / 短信码 / 扫码会话 / 第三方授权)之后,都会经过同一个判定:
① 第一步:账号密码 / 手机号验证码 / 扫码 / 第三方授权
│
├─ 未启用 2FA ──────────────────────────────┐
│ │
├─ 已启用 2FA 且命中可信设备 ───────────────┤→ ③ 签发 token
│ (携带服务端签发的 deviceToken) │
│ │
└─ 已启用 2FA 且设备未被信任 ──→ ② 第二因子
② 第二因子:统一返回 203 与一次性票据
判定"需要二次验证"时不签发 token,返回业务码 203 MFA_REQUIRED:
{
"code": 203,
"msg": "需要双因子验证",
"mfaRequired": true,
"ticket": "…", // 一次性票据(默认 5 分钟有效)
"expireMinutes": 5,
"methods": ["totp", "webauthn"], // 服务端下发的可用方法,前端按列表渲染入口
"preferredMethod": "totp"
}
响应为扁平结构:
code/msg之外的字段(mfaRequired/ticket/expireMinutes/methods/preferredMethod)都在顶层,不在data里。
⚠️ 这里刻意使用 203 而不是 200。返回 200 会被只判断
code == 200的客户端(含移动端)当成登录成功去写 token —— 而此刻并没有 token。
methods 取值与规则:
| 值 | 出现条件 |
|---|---|
totp | 账号已启用双因子即出现(恢复码与动态码共用同一个输入框,不单列) |
webauthn | 系统启用了 WebAuthn(WebAuthnEnabled=true 且 Origins 配置有效)且该账号已绑定至少一个 Passkey |
②-1 TOTP / 恢复码路径:POST /login/2fa/verify
| 入参 | 说明 |
|---|---|
ticket | 第一步返回的一次性票据,必填 |
code | 认证器 6 位动态码,或一次性恢复码(XXXXX-XXXXX) |
rememberDevice | true 时把本设备加入信任列表,并返回 deviceToken(仅返回一次) |
tenantId | 租户标识(SaaS 下第二步是独立请求,仍需定位租户库) |
校验通过后签发 token,响应与普通登录一致(code / msg / data=token),另含:
| 字段 | 说明 |
|---|---|
deviceToken | 仅在 rememberDevice=true 时返回的设备凭据(顶层字段),客户端须保存并在下次登录时带回 |
notice | 异地登录提醒(与普通登录同一口径) |
票据的生命周期:
- 一次性消费:并发/重复提交同一票据只有一次生效;
- 失败可重试但不续命:验证失败时票据按绝对过期时间归还,用户可在剩余有效期内重输正确的码;
- 成功或达到失败上限即失效:已锁定就不再归还票据,否则持续试探可把票据"续命"到锁定解除;
- 票据过期返回"双因子验证已超时,请重新登录"(走返回值而非抛异常,避免制造无意义的异常日志)。
②-2 Passkey 路径:两段式 options → verify
WebAuthn 必须"取挑战 → 浏览器签名 → 提交断言"两次往返,因此与 TOTP 分开两个接口:
POST /login/2fa/webauthn/options { ticket }
→ { challenge, optionsJson } 前端 JSON.parse 后交给 navigator.credentials.get()
POST /login/2fa/webauthn/verify { ticket, challenge, assertion, rememberDevice, tenantId }
→ 与 TOTP 第二步同构:200 签发 token(可带 deviceToken / notice)
关键行为:
- 取 options 时票据只读不消费:用户取消系统弹窗或指纹识别失败后,可以用同一票据重新取 options 再试,不必重新输密码;
- 校验顺序固定为"先消费 challenge → 再验断言 → 最后消费票据":challenge 是一次性凭据,失败也必须作废;只有断言通过才消耗登录机会,防止一票两用;
- challenge 同时绑定用户 + 用途 + 票据,注册的 challenge 不能用于登录、A 次登录的 challenge 不能完成 B 次登录;
optionsJson由服务端保存原始内容并在校验时回读,不采信前端回传的 options。
③ 扫码登录的闸门
扫码登录复用了移动端会话,因此额外要求:移动端当前会话必须带有"已通过 2FA"标记。用启用 2FA 之前签发的旧 token 去扫码会被挡下,否则扫码就成了绕开 2FA 的旁路。
二、用户自助操作
入口:个人中心 → 安全设置(前端页面 views/system/user/profile/twoFactor.vue)。
1. 绑定认证器(TOTP)
- 点击「绑定认证器」,页面用返回的
otpauthUri在本地渲染二维码(不依赖服务端图片接口),也可展开手动密钥; - 认证器 App 扫码后输入 6 位动态码 + 当前登录密码;
- 提交成功后弹窗展示 10 个一次性恢复码,关闭后不再显示,请立即抄存。
要点:
- 密钥以"未启用"状态先落库,校验通过才置为启用,中途放弃不会留下半绑定状态;
- 同一时间步(30 秒窗口)只能成功一次,防止同码重放;
- 已启用状态下不允许再次 Init(否则任何持有有效会话的人都能无密码摘掉第二因子)。
2. 绑定 Passkey
Passkey 是附加在 TOTP 之上的第二因子方式,不改变 TwoFactorEnabled 语义。可用方法列表只对已启用双因子的账号下发 webauthn,因此实际操作顺序是「先启用 TOTP → 再添加 Passkey」:
- 点击「添加 Passkey」,先输入当前登录密码(仅凭有效会话就能新增因子,等于把"会话劫持"升级成"持久接管");
- 服务端下发注册 options,浏览器唤起系统弹窗(指纹 / 面容 / 系统 PIN / 插入安全密钥);
- 认证器返回 attestation,服务端校验后落库,列表展示设备名、认证器型号(AAGUID)、是否可同步备份、绑定时间、最后使用时间。
要点:
- 同一认证器重复注册会被
ExcludeCredentials直接拦下,不会产生"一台设备两条记录"; - 每账号最多
WebAuthnMaxCredentials(默认 10)个凭据,超出时提示先删不用的;options 与写入之间还会再校验一次数量(用户按指纹期间可能在另一标签页完成了绑定); - 认证器必须做本地用户验证(
UserVerification = Required),否则只是"点一下确认",达不到第二因子强度; - 支持重命名(仅展示用)与逐条解绑;认证器丢失时可先解绑止损,TOTP / 恢复码仍可登录。
3. 解绑双因子
需密码 + 动态码/恢复码同时正确。解绑会:
- 清空
sys_user上的全部 2FA 字段; - 清空该账号全部设备信任期(设备登记记录保留);
- 删除该账号全部 Passkey 凭据 —— 凭据在独立表里,只置
TwoFactorEnabled=0会出现"以为已解绑、用户仍能用 Passkey 登录"的静默绕过。
4. 可信设备("记住此设备")
在 TOTP 或 Passkey 第二步勾选「记住此设备」后,服务端签发 deviceToken 并写入信任期(默认 7 天):
- 客户端保存
deviceToken,后续登录请求带上它;信任期内该设备免第二因子; - 安全设置页展示全部已登记设备(设备名 / 登录IP / 最后使用 / 信任状态),支持逐台移除;
- 设备记录与信任解耦:每次登录都会登记设备(无论是否勾选记住),只列"受信任设备"会漏掉异常登录痕迹;
deviceToken只在签发时返回一次,服务端只存 SHA-256 哈希;- 设备标识(前端指纹 visitorId)仅用于展示与审计归并,不参与信任判定。
修改登录密码(自助改密或管理员重置密码)会清空该账号全部设备信任期:改密往往正是因为怀疑密码泄露,"记住此设备"的信任期不清掉等于改了密码也白改。设备登记记录会保留,因此不会误报"新设备"。
5. 恢复码
- 每次启用时生成 10 个,用掉即作废(剩余数量在安全设置页可见);
- 库中只存 SHA-256 哈希,明文仅在启用时返回一次;
- 使用恢复码登录是高危信号,服务端会自动发站内信告警(提示"如果不是本人操作请立即改密")。
三、管理员操作
| 场景 | 操作 |
|---|---|
| 用户认证器丢失 / 换手机 | 用户管理 → 行操作「重置双因子」→ PUT /system/user/resetTwoFactor,权限 system:user:reset2fa |
| 管理员自己丢认证器(无法登录后台) | 由具备该权限的其他管理员重置,或用下方 SQL 手动重置(需谨慎) |
管理员重置会做什么(Reset):
- 清空
TwoFactorEnabled(置 0)、TwoFactorSecret、TwoFactorRecoveryCodes、TwoFactorBoundAt(统一写空串 / NULL); - 清空该账号全部设备的信任期(
TrustUntil置为1900-01-01),但保留设备登记记录 —— 避免用户从常用设备登录被误报为"新设备",也仍能看到历史设备; - 删除该账号全部 Passkey 凭据(
sys_user_webauthn_credential):只清sys_user的字段不会影响独立表里的凭据,不删就会出现"管理员以为已重置、用户仍能用 Passkey 登录"的静默绕过; - 清除失败计数与锁定,日志留痕(
双因子认证被管理员重置)。
TwoFactorPreferredMethod不在重置范围内:未启用时方法列表为空,该字段本就不参与判定。
兜底 SQL:管理员自己丢失认证器时手动重置(谨慎操作)
分别在该用户所在库(SaaS 多租户下是对应租户库)执行,@userId 换成目标用户 ID:
-- 1) 清空双因子配置
UPDATE sys_user
SET TwoFactorEnabled = 0,
TwoFactorSecret = '',
TwoFactorRecoveryCodes = '',
TwoFactorBoundAt = NULL
WHERE UserId = @userId;
-- 2) 清空该账号全部设备信任期(保留登记记录)
UPDATE sys_user_2fa_device
SET TrustUntil = '1900-01-01'
WHERE UserId = @userId;
-- 3) 删除全部 Passkey 凭据(系统库表)
DELETE FROM sys_user_webauthn_credential WHERE UserId = @userId;
执行后需重启 / 等待缓存中的失败计数与锁定自然过期(CACHE-2FA-* 键,最长 30 分钟),或直接清理对应缓存键。
四、审计与告警
1. 登录日志新增两个字段
| 字段 | 取值 | 含义 |
|---|---|---|
AuthType | password / sms_code / oauth:{渠道} | 第一步用的哪种凭据 |
TwoFactorType | none / totp / recovery_code / trusted_device / webauthn | 第二因子结果 |
说明:
trusted_device表示本次并未校验第二因子,只是命中了可信设备 —— 审计时必须能一眼区分;recovery_code表示用恢复码登录,是审计上最需要关注的事件;webauthn表示用 Passkey 通过(由 WebAuthn 分支写入);- 登录日志列表页已展示并支持按上述取值筛选。
2. 自动告警(站内信,可配邮件)
| 事件 | 触发条件 | 配置 |
|---|---|---|
| 双因子连续失败 | 失败次数达到上限并触发锁定 | AlertBySiteMessage / AlertByEmail |
| 新设备登录 | 首次出现某设备标识即视为新设备(含已信任设备) | AlertOnNewDevice |
| 恢复码被使用 | 每次成功使用恢复码 | 固定开启(站内信) |
- 邮件告警复用
MailOptions中配置的发件邮箱,未配置时静默跳过,只发站内信; - 告警有账号级最小间隔(
AlertIntervalMinutes,默认 60 分钟),防止攻击者靠反复试错把用户邮箱刷爆。
五、配置(appsettings.json → TwoFactor)
"TwoFactor": {
"Issuer": "ZRAdmin", // 认证器 App 中显示的发行方名称
"SecretKey": "", // TOTP 密钥的加密口令;留空时回退用 JwtSettings:SecretKey 派生
"TrustDeviceDays": 7, // “记住此设备”的信任天数;≤0 按 7 天
"MaxFailCount": 5, // 验证码失败上限,超出后按 LockMinutes 锁定
"LockMinutes": 15, // 验证失败锁定时长(分钟)
"TicketMinutes": 5, // 一步登录后的一次性票据有效期(分钟)
"AlertBySiteMessage": true, // 连续失败是否发站内信告警
"AlertByEmail": true, // 是否同时发邮件告警(需先配好 MailOptions)
"AlertMailFromName": "system", // 告警邮件发件人(对应 MailOptions 的 FromName)
"AlertIntervalMinutes": 60, // 同账号告警最小间隔(分钟);≤0 按 60
"AlertOnNewDevice": true, // 新设备登录是否提醒
"MaxDeviceRecords": 20, // 每账号保留的设备记录上限;≤0 按 20
// Passkey / WebAuthn。必须配好 Origins 才真正生效(fail-closed)
"WebAuthnEnabled": false,
"WebAuthnOrigins": [], // 例:["https://admin.example.com"],含协议;未配置则功能整体关闭
"WebAuthnRpId": "", // 默认取 Origins 首项域名;多子域共用时建议配成根域名
"WebAuthnRpName": "ZRAdmin",
"WebAuthnChallengeMinutes": 5, // 注册/认证 challenge 有效期(分钟)
"WebAuthnMaxCredentials": 10 // 每账号最多可绑定的 Passkey 数量
}
整个节点可以缺省,全部字段都有默认值。WebAuthnEnabled 代码默认为 true,但未配置 WebAuthnOrigins 时功能整体关闭,因此示例里显式写 false 更直观。
启用 Passkey 的前置条件
| 条件 | 说明 |
|---|---|
WebAuthnEnabled = true | 只是开关,单独打开无效 |
WebAuthnOrigins 非空 | 必须显式配置且含协议;Origin 是 WebAuthn 的核心安全边界,绝不从请求头推导,否则伪造 Host 即可绕过 |
| Origin 能用 RP ID 覆盖 | RP ID 必须等于 Origin 域名或是其可注册域后缀(如 RP ID example.com 覆盖 admin.example.com) |
| HTTPS 或环回地址 | 非 HTTPS 仅允许 localhost / 127.0.0.1 / ::1(规范例外),内网 IP + HTTP 部署不可用 |
SecretKey 一旦有用户绑定就不要改
TwoFactor:SecretKey 是 TOTP 密钥的加密口令(AES-GCM 密钥派生源)。留空会回退用 JwtSettings:SecretKey 派生,因此:
- 若你既没配
TwoFactor:SecretKey,又改了JwtSettings:SecretKey,历史密钥会无法解密; - 密钥不可解密时系统 fail-closed:登录直接拒绝(状态接口
secretUsable=false),不会静默跳过第二因子; - 生产建议显式配置
TwoFactor:SecretKey(appsettings.Development.json/ 环境变量TwoFactor__SecretKey),并与其他密钥分开管理; - 万一已经发生:由管理员对受影响账号「重置双因子」,用户重新绑定即可。
六、数据库
1. sys_user 新增 5 列
| 列名 | 类型/长度 | 说明 |
|---|---|---|
TwoFactorEnabled | int,默认 0 | 是否启用双因子(判定只看这个标记,fail-closed 优先) |
TwoFactorSecret | 500 | TOTP 密钥密文(AES-GCM,Base64(nonce‖tag‖ciphertext)),接口不回传 |
TwoFactorBoundAt | datetime? | 绑定时间 |
TwoFactorRecoveryCodes | 2000 | 恢复码 SHA-256 哈希(JSON 数组),明文不留存 |
TwoFactorPreferredMethod | 16 | 首选第二因子方法,取值见 TwoFactorMethod(totp / webauthn) |
2. sys_user_2fa_device(可信设备,独立表)
| 列名 | 说明 |
|---|---|
UserId | 用户ID |
DeviceId | 设备标识(前端指纹),仅展示/审计归并,不参与信任判定 |
DeviceTokenHash | 设备凭据 SHA-256,信任判定的唯一依据 |
DeviceName | 设备名称(浏览器/系统) |
TrustUntil | 信任截止时间,超过即需重新验证;1900-01-01 表示仅登记未信任 |
LoginIp | 可信时登录 IP(仅记录,不校验) |
LastUsedAt | 最后使用时间(每次免验证登录刷新,便于识别僵尸设备) |
CreatedAt | 创建时间 |
3. sys_user_webauthn_credential(Passkey 凭据,独立表)
| 列名 | 类型/长度 | 说明 |
|---|---|---|
Id | bigint | 主键 |
UserId | bigint | 用户ID |
CredentialId | 512 | 凭据 ID(base64url),唯一索引;超长直接拒绝不截断 |
PublicKey | 2000 | 公钥(COSE Key 的 base64),只存公钥 |
UserHandle | 64 | 用户句柄(base64url),认证时确认凭据归属 |
SignCount | bigint | 签名计数器,用于检测认证器被克隆 |
AaGuid | 36 | 认证器型号,仅展示与风控 |
Transports | 100 | usb / nfc / ble / internal / hybrid,仅展示 |
IsBackupEligible | int | 是否可同步备份(同步型 Passkey) |
IsBackedUp | int | 是否已同步备份 |
DeviceName | 200 | 设备名称(用户可改) |
CreatedAt / LastUsedAt | datetime | 绑定时间 / 最后使用时间 |
索引:uk_webauthn_credential_id(CredentialId) 唯一、idx_webauthn_user(UserId)。
两张表都落在系统库(
[Tenant("0")])。独立成表而不是塞进sys_user的 JSON 列:需要按设备/凭据单独吊销、按用户列举、按 CredentialId 精确查找,JSON 列都做不到。
4. 建表与补列
SysUser2faDevice、SysUserWebAuthnCredential 与 SysUser 已登记到 DbMigrationService.SystemEntityTypes,前两张设备/凭据表同时登记到 TenantBusinessEntityTypes:
| 库 | 如何补齐 |
|---|---|
| 主库 | 运行 dotnet run --project ZR.Admin.WebApi -- --initdb,或后台「同步结构」/「商城库同步」类入口 |
| SaaS 租户库 | 启动时自动补齐(EnsureTenantBusinessTables → TenantBusinessEntityTypes);新建租户同样自动建表补列 |
详见 数据迁移。
七、安全设计要点
| 风险 | 处置 |
|---|---|
| 客户端标识可伪造 | 信任判定只认服务端签发的高熵设备凭据(32 字节随机 + Base64URL),指纹降级为展示字段 |
| 凭据泄露后长期可用 | 库中只存 SHA-256;再次签发即轮换;支持逐台吊销;改密清空全部信任 |
| 第二步换设备 | 票据绑定第一步携带的设备凭据哈希,第二步禁止替换(入参 clientId 已废弃并忽略) |
| 票据重放 / 暴力试码 | 票据一次性消费;失败达上限即失效;失败次数达阈值锁定账号 |
| 同码重放(30 秒窗口) | 记录已消费的时间步,同一步只能成功一次(登录与绑定同口径) |
| 恢复码并发双花 | 恢复码消费走 CAS 条件更新(条件:哈希仍在列表中),并发只有一个成功 |
| 密钥不可解密时静默跳过(fail-open) | 判定只看 TwoFactorEnabled,密钥不可用即拒绝登录(fail-closed),绝不静默降级为单因子 |
| 会话劫持降级 | 已启用状态下拒绝 TOTP Init;新增 Passkey 需密码二次校验(否则会话劫持可直接接管第二因子) |
| 钓鱼站点套取 Passkey | RP ID 与 Origins 只从配置读,不从请求头推导;未配置 Origins 时功能整体关闭 |
| challenge 跨流程重放 | challenge 绑定"用户 + 用途(register/authenticate)+ 票据",一次性原子消费;校验用服务端保存的原始 options |
| 认证器克隆 | 签名计数器带旧值做 CAS 更新;同步型 Passkey 计数恒为 0 时不误判 |
| 私钥外泄 | 服务端只存公钥,私钥始终在认证器(TPM / Secure Enclave)内 |
| 凭据进日志 | 登录/2FA/Passkey 接口不落响应正文,请求正文对 password / deviceToken / ticket / phoneCode / assertion 打码;设备/告警日志中的邮箱、IP 走 MaskUtil 脱敏 |
| 刷表 / 刷告警 | 设备记录数、Passkey 数量均有上限;告警按账号限频 |
八、常见问题
换了手机 / 认证器丢失,怎么登录?
任选其一:
- 用恢复码登录(在密码之后输入
XXXXX-XXXXX);登录后立即到"安全设置"重新绑定认证器; - 如果之前绑过多个 Passkey(如手机 + 笔记本),用另一台设备的 Passkey 通过验证;
- 让管理员在用户管理页「重置双因子」,重置后即可用账号密码登录并重新绑定。
Passkey 入口不出现 / 点击后浏览器无反应?
按顺序排查:
- 系统未启用:
TwoFactor:WebAuthnEnabled = true且WebAuthnOrigins已配置(只配开关不配 Origins 无效); - 协议不满足:浏览器只在 HTTPS(或
localhost/127.0.0.1)下允许 WebAuthn,内网 IP + HTTP 打开页面必然失败; - RP ID 不匹配:Origin 配
http://127.0.0.1:8887而 RP ID 是localhost属不同 RP ID,浏览器只给一个不可读的错误; - 账号未绑定:
methods里只在该账号已有凭据时才出现webauthn; - 浏览器不支持:前端会把
webauthn从列表里剔除,此时"换一种方式"里只剩动态码。
动态码一直提示错误?
绝大多数是认证器与服务器的时间不同步。项目内置的校验窗口是 ±1 个时间步(±30 秒)。请在手机系统设置中开启"自动设置日期和时间",或使用认证器 App 内的"时间校正"功能。
若你已把 TwoFactor:SecretKey 改动过,也可能是历史密钥无法解密 —— 此时状态接口会返回 secretUsable=false,界面上会提示"请联系管理员重置"。
为什么会收到"新设备登录"提醒?
系统每次登录都会登记设备(不只是勾选记住的设备)。首次出现某个设备标识即视为新设备并提醒,因此:换浏览器、换设备、清理浏览器缓存/无痕模式都可能触发,属于正常现象。
如果提醒中的设备/IP 你完全不认识,请立即修改密码并检查"安全设置"中的设备列表,逐台移除可疑设备。
关闭双因子后,之前记住的设备和 Passkey 还在吗?
都不在。解绑会清空全部设备信任期并删除全部 Passkey 凭据,设备登记记录保留(便于查看历史登录设备)。重新绑定后旧的信任期与凭据不会恢复,需要重新添加 Passkey、重新勾选"记住此设备"。
Passkey 换电脑 / 换手机还能用吗?
看凭据类型:
- 同步型 Passkey(iCloud 钥匙串、Google 密码管理器、1Password 等):随账号云同步,换设备后通常直接可用,列表里
IsBackedUp=true; - 设备绑定型(平台认证器、单独的 U-Key):换设备不可用,需要在新设备上重新添加。
两者都与 TOTP 并存,任何一条通道可用即可登录。
可以给全站用户强制开启 2FA 吗?
当前版本是用户自助开启 + 管理员按需重置的模型,没有"强制全员开启"开关。若确需强制,可在用户表中批量为账号预置 TwoFactorEnabled=1(配合管理员重置流程让用户重新绑定),但这属于定制改造,请自行评估用户绑定率与客服成本。
