Skip to content
bitzorcas
中EN

Concept

Identity 登录、MFA、令牌与会话

说明 LoginFlow 的密码客户端加密、风控、验证码、用户解析、时序防护、锁定、MFA、JWT、会话、审计查询和失败补偿。

Last updated

LoginFlow 是 Identity 的认证深模块。它不是“查用户、比密码、发 JWT”三行逻辑,而是一台会返回多种业务结果的状态机。客户端和扩展认证入口都必须理解这些分支。

密码在进入 LoginFlow 前必须已经是 RSA-OAEP-SHA256 密文(见下文 §2.1)。解密后的明文只在进程内短暂存在,随后仍走 BCrypt 校验;JSON 请求体不得出现明文 password 字段。

1. 状态机总览

BlockCaptcha 首次Captcha 二次MFA / StepUpNone引擎失败失败成功是否

LoginRequest 密文

RSA 解密 + Nonce 防重放

风险评估

RiskBlocked

生成验证码挑战

消费并验证答案

标记风险 MFA

解析用户

RiskAssessmentUnavailable

账号状态守卫

密码验证 BCrypt

失败计数 / 锁定

用户 2FA 或风险 MFA?

签发 5 分钟 MFA 挑战

签发 Access / Refresh Token

保存 Refresh Token

写入 UserSession

登录成功审计

Login.Command 只是把请求、租户、平台、IP 和 User-Agent 组装成 LoginInput 后委托 ILoginFlow。架构测试固定了这条边界,防止 Handler 再长成第二套登录实现。

2. HTTP 输入和客户端平台

POST /api/auth/login 是匿名端点,使用 authPolicy 和标准命令超时。有效租户来自 ICurrentTenant,IP 来自连接信息,User-Agent 来自请求头。

2.1 密码客户端加密(强制)

  1. 渲染登录页时调用 GET /api/auth/cipher-key(匿名),拿到 keyId、SPKI Base64 publicKey、serverTimestamp、maxClockSkewSeconds。
  2. 浏览器用 Web Crypto 生成 16 字节 Nonce,载荷为 nonce|timestamp|password,再 RSA-OAEP-SHA256 加密。
  3. 登录请求只提交 encryptedPassword + cipherKeyId,不提交明文 password。
  4. 服务端按 keyId 恢复对应私钥并解密,校验时间戳偏差与 Nonce 一次性(缓存 TTL 默认 120s),再进入 BCrypt。

密钥机制(服务端):

项行为
生成分布式模式在缓存缺失时生成共享首对密钥;内存降级模式才在每个 API 进程启动时生成
keyId{yyyyMMddHHmmss}-{6hex}
轮换默认 RotationHours=24;生效范围 1–720 小时(上限 30 天),超出被静默 clamp,不挡启动。后台 先 sleep 再轮换。旧密钥宽限默认 10 分钟(至少 1 分钟)
环容量仅 当前 + 上一把 可解密
多实例ICacheStore + 私钥材料保护器可用时,共享 current + previous;实例必须共用 Data Protection key ring。任一依赖缺失时退回进程内并记录 Warning
NonceICacheStore(区域 identity:cipher:nonce);无 Redis 时进程内降级并打 Warning
错误码KeyExpired / DecryptFailed / ClockSkewExceeded / ReplayDetected / EncryptedPasswordRequired / PayloadInvalid

前端 SDK(@bitz/platform-sdk)在 auth.login、改密、重置、激活、创建用户等路径自动加密,并对 KeyExpired 隐式刷新公钥重试一次。

完整 LoginRequest(加密后)
{
"userName": "alice@example.com",
"encryptedPassword": "Base64(RSA-OAEP(nonce|serverTs|password))",
"cipherKeyId": "20260811120000-a1b2c3",
"rememberMe": false,
"deviceId": "alice-browser-01",
"captchaChallengeId": null,
"captchaAnswer": null
}

后台配置 Identity:Password:Cipher

绑定 PasswordCipherOptions。省略整节时使用默认值即可;生产建议显式写出,便于运维对照。完整说明、生命周期与校验语义见 Identity 配置与集成 §2.1。

键(相对 Identity:Password:Cipher)默认范围 / 越界说明
KeySize20482048–4096;启动校验失败则进程起不来RSA 位数
RotationHours241–720(上限 720h = 30 天);不做启动校验,Math.Clamp 静默钳制密钥轮换周期(小时)。写大于 720 仍能启动,但按 30 天轮换
GracePeriodMinutes10运行时至少 1;无启动上限旧密钥仍可解密的宽限(分钟);建议 5–30 且远小于轮换周期
MaxClockSkewSeconds12030–600;启动校验时间戳偏差上限(秒);也会经 cipher-key 下发
NonceTtlSeconds12030–600;启动校验 + 领取时再 clampNonce 缓存 TTL(秒)
appsettings 片段(可直接粘贴)
{
"Identity": {
"Password": {
"Cipher": {
"KeySize": 2048,
"RotationHours": 24,
"GracePeriodMinutes": 10,
"MaxClockSkewSeconds": 120,
"NonceTtlSeconds": 120
}
}
}
}
  • 分布式模式把 Data Protection 保护后的 PKCS#8 私钥快照写入 identity:cipher:keyring:v1;私钥明文字节随即清零,实例须共享 Data Protection key ring。内存降级模式才是重启即换钥。
  • Nonce 走 ICacheStore;无分布式缓存时进程内降级(多实例重放防护较弱,生产接 Redis)。
  • 不是所有字段都 ValidateOnStart:KeySize / MaxClockSkewSeconds / NonceTtlSeconds 越界会挡启动;RotationHours 只会被钳到 1–720,GracePeriodMinutes 只保证 ≥1。
  • 明文 HTTP / IP 访问:浏览器无 crypto.subtle 时 SDK 自动走纯 JS RSA-OAEP-SHA256 回退,密码加密仍可用;服务端 refresh / 受信设备 cookie 在非 HTTPS 请求上自动 Secure=false,并将 SameSite=None 降为 Lax(否则 cookie 写不进、refresh 401)。生产仍应上 HTTPS。

客户端平台从 X-Client-Platform 解析,缺省为 Web。它会进入 JWT platform Claim、刷新令牌槽位和会话记录;客户端不能在刷新时把原 Token 的平台改成另一个值。

3. 风控在密码之前

IRiskAssessmentEngine 收到租户、用户名、IP、User-Agent 和 DeviceId。它返回四类挑战:

挑战LoginFlow 行为是否执行密码验证
None继续是
Block记录失败并返回 RiskBlocked否
Captcha首次生成挑战;二次消费答案验证通过后才执行
MFA / StepUp记录二次认证要求,继续验证密码是

风险 Block 在 BCrypt 前拒绝,既减少高危爆破的计算成本,也意味着这个分支不执行 dummy BCrypt。它仍要写登录失败审计,给风控下一轮评估提供证据。

4. 验证码是两阶段协议

第一次风险判断要求验证码且请求没有 captchaChallengeId 时,系统生成挑战并返回:

验证码挑战响应
{
"accessToken": "",
"refreshToken": "",
"expiresIn": 0,
"requiresMfa": false,
"mfaToken": null,
"requiresPasswordChange": false,
"requiresCaptcha": true,
"captchaChallengeId": "captcha-ticket-id",
"captchaRenderData": "svg-or-base64-render-data",
"isHost": false
}

客户端显示挑战后,使用同一登录请求补上 captchaChallengeId 和 captchaAnswer。验证端消费票据,阻止同一个答案重放。

带验证码的第二次登录请求
# 第二阶段必须沿用第一次登录的身份、设备和租户上下文。
# ChallengeId 是一次性票据,成功或失败消费后不要重放。
# 密码必须先经 GET /api/auth/cipher-key 与 Web Crypto 加密;以下为字段示意。
curl --fail-with-body \
--request POST 'https://localhost:5001/api/auth/login' \
--header 'Content-Type: application/json' \
--data '{
"userName":"alice@example.com",
"encryptedPassword":"<rsa-oaep-base64>",
"cipherKeyId":"<key-id-from-cipher-key>",
"rememberMe":false,
"deviceId":"alice-browser-01",
"captchaChallengeId":"captcha-ticket-id",
"captchaAnswer":"8241"
}'

票据存在但答案缺失、校验失败或提供者返回失败时,返回 Identity.Login.CaptchaFailed,并记录脱敏失败原因。

5. 用户解析顺序与租户信任

ResolveUserAsync 按以下顺序解析:

  1. 在 IsHost = true 的全局命名空间按 UserName 查 Host 用户。
  2. UserName 含 @ 时,按邮箱跨租户查找,并使用持久化用户的 TenantId。
  3. 其他输入按可信租户解析链给出的 TenantId,在租户内查 UserName。

客户端不能凭空提交一个 TenantId 让系统跨租户登录。Host 的 TenantId = "0" 是平台语义,也不能伪装成普通租户用户。

Host 用户落在遗留兼容的租户表里(TenantId = "0"),而双 ORM 的统一租户可见性过滤器会拦截这种跨租户读取。早先 Host 登录因此被租户过滤器误拦。现在的修复不关闭过滤器,而是通过 IIdentityLoginPersistenceScope 安装一个已登记的 RootCrossTenantOperation 根能力票据(操作码 identity.login.resolve-and-establish-session),在有界、有审计的前提下完成 Host 精确键查找;Host 身份确认后,CurrentUser、CurrentTenant 与 ORM 执行上下文同步,使 host-admin 角色与既定权限进入会话,但这一切只发生在已确认的 IsHost 登录作用域内,作用域退出时按 LIFO 还原。

当查找失败或用户不存在时,系统对输入密码执行 dummy BCrypt,再返回失败。这会拉近”用户存在”和”不存在”的响应时间,降低用户名枚举风险。

6. 账号状态和锁定

默认最大失败次数为 5,锁定 15 分钟。密码失败先调用 VerifyPasswordFailed 并保存聚合;如果失败计数保存失败,登录返回 Store 错误,而不是继续发令牌。

锁定期未结束时执行 dummy BCrypt 并拒绝。锁定期结束后调用 Unlock() 再检查状态。只有 UserStatus.Active 可以继续;Disabled 返回 Identity.Login.AccountDisabled,其他非 Active 状态返回 Identity.Login.AccountNotActive。

登录状态守卫的关键语义
// 活跃锁定先执行等时防护,再返回稳定的锁定错误。
if (user.Status == UserStatus.Locked && user.LockoutEnd > clock.UtcNow)
{
passwordHasher.VerifyPassword(input.Password, DummyPasswordHash);
return LoginResult.Locked(accountLockedError);
}
// 密码正确也不能绕过 PendingActivation、Disabled 或 Expired。
if (user.Status != UserStatus.Active)
{
passwordHasher.VerifyPassword(input.Password, DummyPasswordHash);
return LoginResult.Locked(accountNotActiveError);
}

该片段只保留状态判断,未列出登录审计调用;真实常量、错误分支和日志位于 LoginFlow.cs。

7. 密码验证成功仍不一定登录完成

BCrypt 成功后,聚合调用 VerifyPasswordSucceeded 并先保存登录状态。随后系统取 user.TwoFactorEnabled 与风险 MFA/StepUp 的并集。

当租户强制 MFA 策略要求二次认证、而用户尚未注册任何 MFA 凭据时,登录会返回 MfaEnrollmentRequired 分支:签发一个受限令牌(mfaEnrollmentRequired: true),仅允许完成 MFA 注册流程,不能访问其他资源。这条分支保证强制策略不会因为用户没注册 MFA 而被绕过。

需要 MFA 时,IMfaService 生成一次性挑战,保存到 UserToken:

  • Provider:MFA
  • Name:Challenge
  • 有效期:5 分钟

此分支返回 requiresMfa = true,不签发最终 Access Token、Refresh Token 或 Session。完成 MFA 的用例再负责最终令牌和会话。

8. JWT Claim 契约

最终 Access Token 包含框架标准 Claim,并明确包含:

Claim来源用途
user_idUserAggregate.IdHttpContextCurrentUser 的稳定用户标识
platform登录请求平台客户端隔离和风险分析
caller_typeHost 或 User区分平台运维与租户用户
角色IUserQueryStore.GetUserRolesAsync后续授权上下文
auth_source可选外部/小程序入口区分凭据签发渠道

Access Token 默认 15 分钟。角色查询失败时当前实现使用空角色继续签发;如果业务要求角色读取失败即禁止登录,需要把这个降级边界改为失败并补测试。

9. 刷新令牌族与平台隔离

Refresh Token 默认 7 天,存储名是 RefreshToken:{platform}。注册 IRefreshTokenStore 后,服务还保存 SHA-256 Hash 的 RefreshTokenRecord,支持 Active、Rotated 和 Revoked 族链状态。

refreshmark

Web Token A
Active

Web Token B
Active

Web Token A
Rotated

App Token X
Active

App 槽位保持不变

刷新 Web Token 不得覆盖 App Token。轮换出的新记录继承原平台。再次使用 Rotated Token 时,服务可以识别重用并处置族链。

未注册 IRefreshTokenStore 时,JwtTokenService 走 Legacy 单 Token 路径,没有完整族链追踪。生产验收必须检查实际 DI,而不是只检查接口存在。

10. 会话写入和补偿

登录生成 Access/Refresh Token 后,顺序是:

  1. 保存按平台隔离的 Refresh Token。
  2. 计算 Access Token 的 SHA-256 Hash。
  3. 创建 7 天到期的 UserSession,记录 DeviceId、IP、User-Agent 和平台。
  4. 保存会话。
  5. 写登录成功日志。

如果 Refresh Token 保存失败,不返回任何 Token。如果会话保存失败,LoginFlow 会撤销刚保存的 Refresh Token,再记录签发失败。这条补偿由 LoginFlowTests 固定。

11. 登录日志查询

登录流程产生的审计通过两条只读 QUERY 端点暴露:

  • QUERY /api/login-logs 需要授权(资源 identity/loginlog、动作 View),支持按 UserId/UserName、PageIndex/PageSize 过滤,供管理员或风控回看;POST fallback 是 /api/login-logs/_query。
  • QUERY /api/login-logs/me 只需认证,服务端固定当前 TenantId/UserId,是自助查询入口;POST fallback 是 /api/login-logs/me/_query。

管理端查询由 GetLoginLogsQueryRule 在 Handler 前校验:PageIndex 必须 ≥1、PageSize 必须在 1..1000;UserId/UserName 去首尾空白后最长 64 字符,且不得包含控制字符。空白过滤会规范为 null。失败返回稳定错误 Identity.LoginLog.InvalidQuery,不会访问 Store。自助端当前没有同一请求规则,底层服务会按 PagingLimits 归一分页;这是两端仍需统一的边界。

热库与归档按 (CreateTime desc, LogId desc) 合并,单次窗口最大 100000。超过该合并窗口的深分页不能保证取得目标页;若登录历史需要长期逐页浏览,应改为游标或让热/冷存储提供统一 Query Shape。

返回的 LoginLogDto 字段:LogId、UserId、UserName、Result(LoginResult)、FailureReason、IpAddress、UserAgent、DeviceInfo、LoginProvider、CreateTime。Result 和 LoginProvider 是枚举,便于前端按成功/失败和登录渠道分类统计。登录审计本身由 LoginLogService 写入,归档由独立的留存策略负责(见 Operations 备份与归档,LoginLog 是两个固定归档策略之一)。

12. 改密、登出与撤销的真实边界

操作当前实现不能假定的行为
Logout按调用方持有的刷新令牌撤销权威族链,不要求 Access Token不等于删除所有设备
RevokeSession撤销指定 Session不等于改密码
RevokeAllSessions撤销用户全部会话必须显式调用
ChangePassword验证旧密码、策略、历史;更新 Hash 和安全戳当前不会自动调用 RevokeAllSessions

Access Token 过期不能把仍有效的刷新 Cookie 困在浏览器中。因此 logout 端点不要求 Bearer 认证,但操作仍以高熵刷新令牌的持有权认证,用户和租户只能从权威令牌记录解析。Cookie 登出仍要求同源或显式允许的 Origin;原生客户端才通过请求体提交刷新令牌。

安全戳变化只有在认证验证管线实际检查 SecurityStamp 时才能使旧 Access Token 立即失效。JWT 默认会在自身到期前有效,因此高风险产品应显式组合会话和刷新令牌撤销,并验证 Access Token 拒绝策略。

13. HTTP 边界:Cookie Origin 与反代

浏览器登录在验密码之前会做 refresh-cookie 来源校验。若返回:

"errorCode": "Authentication.Cookie.OriginRejected"

表示 Origin 与 API 看到的请求来源不一致,不是账号密码错误。

检查项要求
浏览器地址栏与 Frontend__BaseUrl、Cors__AllowedOrigins__0 完全一致(含 http/https 与端口)
OpenResty → GatewayHost 与 X-Forwarded-Host 使用 $http_host,并发送 X-Forwarded-Proto、X-Forwarded-Port $server_port
API ForwardedHeadersKnownProxies 信任网关;ForwardLimit 覆盖 OpenResty+Gateway 两跳;Forwarded Port 中间件紧随其后
Cookie Origin 实现比较浏览器 Origin 与可信中间件校正后的 Scheme://Host(及显式白名单),不把未经验证的原始转发头当作 Origin
HTTP 预览Development 下可设 Auth__WebRefreshCookie__Secure=false、SameSite=Lax;生产 HTTPS 保持 Secure

同源反代下,API 的 Scheme://Host 必须能变成公网入口;否则只能依赖显式 Origin 白名单。运维手册见 1Panel 开发预览 和 OpenAPI 与 Scalar 文档面。

14. 客户端实现规则

  • 不用 HTTP 200 与否单独判断登录完成;读取 requiresCaptcha、requiresMfa 和 Token 字段。
  • 验证码二次请求保留同一用户名、密码、设备和租户上下文。
  • MFA 成功前不保存空 Access Token。
  • Refresh Token 按平台和安全存储区隔离,不能在 Web 与 App 之间复制。
  • 收到账号状态、锁定或通用凭据错误时,不在 UI 中泄露用户是否存在。
  • requiresPasswordChange 为 true 时进入受控改密流程,并根据产品策略显式撤销会话。
  • 部署预览时先排除 OriginRejected,再排查 InvalidCredentials(Demo 种子是否启用、密码是否配置)。

15. 测试与源码定位

Terminal window
# 登录状态机的应用级分支。
dotnet test tests/BitzOrcas.Application.Tests/BitzOrcas.Application.Tests.csproj \
--configuration Release \
--filter 'FullyQualifiedName~Identity.LoginFlowTests|FullyQualifiedName~Identity.LoginRiskIntegrationTests|FullyQualifiedName~Identity.VerifyMfaLoginCompletionTests'
# Claim 和客户端平台隔离。
dotnet test tests/BitzOrcas.Unit.Tests/BitzOrcas.Unit.Tests.csproj \
--configuration Release \
--filter 'FullyQualifiedName~Identity.TokenServiceClaimsTests|FullyQualifiedName~Identity.PlatformTokenIsolationTests'
# 确认登录深模块仍是唯一编排点。
rg -n "ILoginFlow|new LoginFlow|GenerateAccessToken" \
src/Platform/Identity src/Hosts/BitzOrcas.Api -g '*.cs'

新增认证入口时,应复用 ILoginFlow 或明确记录为何有不同状态机;不能复制密码锁定、MFA 和令牌签发代码。

返回 Identity 总览 · 查看配置与集成 · 查看测试与运维

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%