LoginFlow 是 Identity 的认证深模块。它不是“查用户、比密码、发 JWT”三行逻辑,而是一台会返回多种业务结果的状态机。客户端和扩展认证入口都必须理解这些分支。
密码在进入 LoginFlow 前必须已经是 RSA-OAEP-SHA256 密文(见下文 §2.1)。解密后的明文只在进程内短暂存在,随后仍走 BCrypt 校验;JSON 请求体不得出现明文 password 字段。
1. 状态机总览
Login.Command 只是把请求、租户、平台、IP 和 User-Agent 组装成 LoginInput 后委托 ILoginFlow。架构测试固定了这条边界,防止 Handler 再长成第二套登录实现。
2. HTTP 输入和客户端平台
POST /api/auth/login 是匿名端点,使用 authPolicy 和标准命令超时。有效租户来自 ICurrentTenant,IP 来自连接信息,User-Agent 来自请求头。
2.1 密码客户端加密(强制)
- 渲染登录页时调用
GET /api/auth/cipher-key(匿名),拿到keyId、SPKI Base64publicKey、serverTimestamp、maxClockSkewSeconds。 - 浏览器用 Web Crypto 生成 16 字节 Nonce,载荷为
nonce|timestamp|password,再 RSA-OAEP-SHA256 加密。 - 登录请求只提交
encryptedPassword+cipherKeyId,不提交明文 password。 - 服务端按 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 |
| Nonce | ICacheStore(区域 identity:cipher:nonce);无 Redis 时进程内降级并打 Warning |
| 错误码 | KeyExpired / DecryptFailed / ClockSkewExceeded / ReplayDetected / EncryptedPasswordRequired / PayloadInvalid |
前端 SDK(@bitz/platform-sdk)在 auth.login、改密、重置、激活、创建用户等路径自动加密,并对 KeyExpired 隐式刷新公钥重试一次。
{ "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) | 默认 | 范围 / 越界 | 说明 |
|---|---|---|---|
KeySize | 2048 | 2048–4096;启动校验失败则进程起不来 | RSA 位数 |
RotationHours | 24 | 1–720(上限 720h = 30 天);不做启动校验,Math.Clamp 静默钳制 | 密钥轮换周期(小时)。写大于 720 仍能启动,但按 30 天轮换 |
GracePeriodMinutes | 10 | 运行时至少 1;无启动上限 | 旧密钥仍可解密的宽限(分钟);建议 5–30 且远小于轮换周期 |
MaxClockSkewSeconds | 120 | 30–600;启动校验 | 时间戳偏差上限(秒);也会经 cipher-key 下发 |
NonceTtlSeconds | 120 | 30–600;启动校验 + 领取时再 clamp | Nonce 缓存 TTL(秒) |
{ "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 按以下顺序解析:
- 在
IsHost = true的全局命名空间按 UserName 查 Host 用户。 - UserName 含
@时,按邮箱跨租户查找,并使用持久化用户的 TenantId。 - 其他输入按可信租户解析链给出的 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_id | UserAggregate.Id | HttpContextCurrentUser 的稳定用户标识 |
platform | 登录请求平台 | 客户端隔离和风险分析 |
caller_type | Host 或 User | 区分平台运维与租户用户 |
| 角色 | IUserQueryStore.GetUserRolesAsync | 后续授权上下文 |
auth_source | 可选外部/小程序入口 | 区分凭据签发渠道 |
Access Token 默认 15 分钟。角色查询失败时当前实现使用空角色继续签发;如果业务要求角色读取失败即禁止登录,需要把这个降级边界改为失败并补测试。
9. 刷新令牌族与平台隔离
Refresh Token 默认 7 天,存储名是 RefreshToken:{platform}。注册 IRefreshTokenStore 后,服务还保存 SHA-256 Hash 的 RefreshTokenRecord,支持 Active、Rotated 和 Revoked 族链状态。
刷新 Web Token 不得覆盖 App Token。轮换出的新记录继承原平台。再次使用 Rotated Token 时,服务可以识别重用并处置族链。
未注册 IRefreshTokenStore 时,JwtTokenService 走 Legacy 单 Token 路径,没有完整族链追踪。生产验收必须检查实际 DI,而不是只检查接口存在。
10. 会话写入和补偿
登录生成 Access/Refresh Token 后,顺序是:
- 保存按平台隔离的 Refresh Token。
- 计算 Access Token 的 SHA-256 Hash。
- 创建 7 天到期的
UserSession,记录 DeviceId、IP、User-Agent 和平台。 - 保存会话。
- 写登录成功日志。
如果 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 → Gateway | Host 与 X-Forwarded-Host 使用 $http_host,并发送 X-Forwarded-Proto、X-Forwarded-Port $server_port |
| API ForwardedHeaders | KnownProxies 信任网关;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. 测试与源码定位
# 登录状态机的应用级分支。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 和令牌签发代码。