docs: simplify migration documentation

This commit is contained in:
2026-07-28 16:22:50 +08:00
parent b8d14e8a7e
commit 5732df8886
13 changed files with 484 additions and 2016 deletions

View File

@@ -1,449 +1,128 @@
# TIKU SaaS 认证、授权与 Host 安全策略
# 认证、授权与 Host 安全策略
本文档描述 TIKU Backend 当前生效安全架构是认证、后台授权、租户隔离、Host 解析、Session、MFA 和短信验证码实现的统一约定。新增接口或修改登录流程时,以本文档和自动化测试为准,不能只依赖前端菜单、JWT 字符串历史 `TenantRole` 约定
本文当前生效安全规范。新增接口或修改登录流程时,以本文档和自动化测试为准前端菜单、JWT 字符串历史角色约定不能代替 API 授权
## 1. 安全目标与基本原则
## 授权域
系统同时存在两个互相隔离的授权域:
- `tenant`:租户业务域,必须绑定 Active 租户和 Active `TenantMembership`
- `platform`:平台运营域,只能从配置的 Platform Host 进入,不绑定租户。
- `tenant`:租户业务域,必须绑定一个 Active 租户和一个 Active `TenantMembership`
- `platform`:平台运营域,不绑定租户,只能从配置的 Platform Host 进入。
核心规则:
核心原则:
1. Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致
2. JWT 只证明一次已认证会话,不承载可直接授权的角色或权限
3. 后台权限每次从数据库角色绑定解析,菜单只负责 UI 展示,不负责 API 授权
4. 数据权限必须进入 SQL无法可靠映射 owner、region 或 class 的资源采用 `All`-only fail-closed不猜测数据归属
5. 用户、成员、租户、后台角色、后台权限、SecurityStamp 或 Session 任一失效,旧 token 都不能继续扩大访问权。
6. 所有 Controller 默认要求认证,公开接口必须显式标记 `[AllowAnonymous]`
整体边界如下:
- Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。
- JWT 只证明已认证会话,不承载可直接授权的角色或权限。
- 后台权限每次从数据库角色绑定解析;菜单只控制 UI 展示
- 数据权限必须进入 SQL无法可靠映射 owner、region 或 class 的资源采用 All-only fail-closed
- 用户、成员、租户、后台角色、权限、SecurityStamp 或 Session 任一失效,旧 token 不能继续取得能力
- Controller 默认要求认证;公开接口必须显式 `[AllowAnonymous]`
```text
浏览器 / App
|
| HTTPS + Host + Bearer/refresh/challenge
v
可信反向代理
|
| 仅 TrustedProxyAddresses 可以提供 Forwarded Headers
v
TenantResolutionMiddleware
|
+--> Platform Host --------> platform realm不得出现 TenantId
|
+--> Active Tenant Host ---> tenant realm锁定 Host 对应 TenantId
|
+--> Unknown Host ---------> 非豁免路径 404
v
JWT + 数据库 AuthSession 校验
v
IAuthorizationHandler + ICurrentAccessContext
v
EF tenant filter + DataScope SQL + PostgreSQL 约束
Client
-> Trusted proxy
-> TenantResolutionMiddleware
-> JWT + AuthSession validation
-> Authorization handler + current access context
-> EF tenant filter + DataScope SQL + PostgreSQL constraints
```
## 2. 账号安全底座
## 账号与 Session
账号由 ASP.NET Core Identity 管理`User` 继承 `IdentityUser<Guid>`Identity 与业务实体共用 `TikuDbContext`。系统不使用 ASP.NET 全局 Role 表,租户与平台后台角色由独立 SaaS RBAC 表维护
当前固定参数:
- 密码最少 10 位。
- Identity PBKDF2 迭代次数为 210,000。
- 账号由 ASP.NET Core Identity 管理。
- 密码最少 10 位PBKDF2 迭代次数 210,000。
- 连续 5 次密码失败后锁定 15 分钟。
- 用户 Active 状态在每次 Session 校验时检查;强制改密、退出全部设备和其他账号安全事件同时通过 SecurityStamp 使旧 Session 失效
- TOTP、恢复码、Authenticator Key 使用 Identity 标准能力
- 微信等外部身份只保留 provider subject、openid、unionid 等映射,不保存 `session_key` 或原始 secret
- TOTP、恢复码和 Authenticator Key 使用 Identity 标准能力
- 微信等外部身份只保存 provider subject、openid、unionid不保存 `session_key` 或原始 secret
- Data Protection key 持久化到 PostgreSQL非 Development 环境必须提供带私钥的 PKCS#12 证书保护 key ring
Identity 的 Data Protection key 持久化到 PostgreSQL。Development 可以不使用证书;非 Development 环境必须提供包含私钥的 PKCS#12 证书保护 key ring否则 API 启动失败。
Access token
## 3. JWT 与数据库 Session
- RSA SHA-256 签名Header 必须包含 `kid`
- 固定 15 分钟。
- 包含 `sub``sid``jti``iat``iss``aud``exp``scope`
- tenant token 必须包含 `tid`platform token 禁止包含 `tid`
- 完成 MFA 的 Session 可包含 `amr=mfa`
- 不包含 role 或 permission claim。
### 3.1 Access token
Access token 使用 RSA SHA-256 非对称签名Header 必须包含可识别的 `kid`。生产配置包含当前私钥;轮换期间把仍需验证的旧公钥放入 `PublicKeys`。未知 `kid`、错误签名、弱于 2048 位的 RSA key、把当前 `kid` 重复放入旧公钥集合等配置都会被拒绝。
Access token 固定 15 分钟,并包含:
| Claim | 含义 |
| --- | --- |
| `sub` | Identity User ID |
| `sid` | 当前 `AuthSession` ID |
| `jti` | 当前 access token 的唯一 ID |
| `iat` | 签发时间 |
| `iss` / `aud` / `exp` | issuer、audience 和过期时间 |
| `scope` | `tenant``platform` |
| `tid` | 仅 tenant token 必须包含platform token 禁止包含 |
| `amr=mfa` | 当前 Session 已完成 MFA 时包含 |
JWT 不包含用于 API 授权的 role 或 permission claim。
### 3.2 AuthSession
`auth_sessions` 是 access/refresh 的服务端事实来源,关键字段包括:
- `realm`、可空 `tenant_id``user_id`
- `token_family_id``parent_session_id``replaced_by_session_id`
- refresh token hash、SecurityStamp、MFA 状态;
- expires/revoked 时间与 revoked reason。
数据库 check constraint 保证 tenant Session 必须有 `tenant_id`platform Session 不得有 `tenant_id`。业务代码只能通过 `IAuthSessionStore` 访问 Session。
每次 Bearer token 验证都必须同时确认:
```text
RSA 签名/kid/iss/aud/exp
|
v
sub + sid + jti + iat + scope/tid 结构正确
|
v
Host realm 与 scope/tid 一致
|
v
AuthSession 存在、未撤销、未过期
|
v
Session.user/tenant/realm/MFA 与 JWT 一致
|
v
User Active + SecurityStamp 一致
|
v
tenant: Tenant Active + Membership Active
platform: 仍有有效平台后台权限
|
v
进入 Authorization Handler
```
Session 校验没有配置旁路;不能通过关闭选项把 JWT 降级为纯无状态 token。
### 3.3 Refresh、logout 与重放
Refresh token 格式为:
Refresh token
```text
v2.{t|p}.{tenantId|-}.{sessionId}.{64-byte-random-secret}
```
数据库只保存完整 refresh token 的 SHA-256 hash,明文只返回客户端一次
- 数据库只保存完整 refresh token 的 SHA-256 hash。
- 刷新在事务内轮换 Session。
- 并发刷新只允许一个成功。
- 已轮换 token 被复用时视为重放,撤销整个 token family 并写审计。
- logout 撤销当前 refresh token familylogout-all 更新 SecurityStamp 并撤销用户全部 Session。
刷新在事务内完成:
## Host 与 tenant 解析
```text
旧 refresh token
|
v
读取并验证当前 Session/用户/realm/tenant/SecurityStamp
|
v
原子设置 revoked=rotated + replacedBySessionId
|
v
创建同 family 的子 Session返回新 access/refresh
```
Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在 Authentication 前执行。
并发刷新只允许一个请求成功。已轮换 token 被再次使用时视为重放,整个 token family 被撤销并写入审计。
- `POST /api/auth/logout`:撤销 refresh token 所属 family。
- `POST /api/auth/logout-all`:更新 SecurityStamp并撤销用户全部 Session。
- 成员禁用、租户暂停、用户禁用、后台权限撤销后,旧 access/refresh 均不能继续取得对应后台能力。
## 4. Host 与 realm 安全策略
Host 不是普通路由参数而是认证上下文的一部分。Host 在 Authentication 之前由 `TenantResolutionMiddleware` 解析。
### 4.1 Host 类型
| 请求入口 | 租户上下文 | 允许的认证域 | 结果 |
| 请求入口 | 租户上下文 | 允许 realm | 默认结果 |
| --- | --- | --- | --- |
| 配置的 Platform Host | 默认无租户 | platform部分白名单路径可显式提供 tenantCode 进入 tenant | 继续处理 |
| Active 租户自定义 Host | 固定为该 Host 对应租户 | tenant | 继续处理 |
| 租户 Host + 不同 tenantCode/header | Host 与输入冲突 | 无 | 403 |
| Platform Host | 无租户 | platform白名单入口可用 tenantCode 引导 tenant 登录 | 继续 |
| Active 租户 Host | Host 绑定租户 | tenant | 继续 |
| 租户 Host + 其他 tenantCode/header | 冲突 | 无 | 403 |
| Platform Host + platform realm + tenantCode | 非法混合 | 无 | 400 |
| 非 Platform、未绑定租户的未知 Host | 无 | 无 | 非豁免路径 404 |
| 未知 Host 上的 platform 登录/refresh/logout/MFA challenge | 无 | 无 | 默认先由 Host 解析返回 404即使路径被配置为豁免平台 Host 二次校验仍返回 400 |
| 未知 Host | 无 | 无 | 非豁免路径 404 |
| Pending/禁用域名 | 无 | 无 | 404 |
默认 Platform Host 是 `localhost``127.0.0.1`,生产必须通过 `Tenancy:Resolution:PlatformHosts` 配置正式平台域名。
规则:
### 4.2 租户 Host 流程演示
- 自定义域名不接受 `tenantCode``host` query 或客户端转发头覆盖。
- tenant JWT 的 `tid` 必须与 Host 解析租户一致。
- platform JWT 不能访问租户 Host。
- 平台 Host 上的租户登录引导才允许受控使用 `tenantCode`
- 只接受可信代理写入的 Forwarded Headers直连客户端伪造无效。
假设 `school-a.example.com` 已绑定 Tenant A
## RBAC、菜单与 DataScope
```text
GET https://school-a.example.com/api/me
Host: school-a.example.com
Authorization: Bearer <tenant A token>
租户后台与平台后台角色分离:
Host ----查询----> Tenant A (Active)
token scope ------> tenant
token tid --------> Tenant A
session tenant ---> Tenant A
- 租户角色、权限、菜单、用户角色绑定都带租户上下文。
- 平台角色不带租户键,不能自动读取租户业务数据。
- 菜单只决定 UI bootstrap 展示,不作为 API 授权依据。
- 后台 API 必须声明明确 permission高风险写操作按策略要求 MFA 和审计。
四者一致:继续授权
```
DataScope
如果同一请求携带 Tenant B token
- `All`:当前租户内该模块全部资源。
- `Restricted`:按 region/class/owner 等资源关系过滤。
- `Self`:只允许当前用户关联资源。
- 无法可靠表达资源关系的模块只允许 `All`,不能退化为仅按 tenant 查询。
```text
Host -------------> Tenant A
token tid --------> Tenant B
X 不一致
结果 -------------> 403 tenant_context_conflict
```
## 短信验证码
在租户自定义 Host 上,`x-tenant-code`、query `tenantCode` 或 body 中的 tenant ID 都不能切换到另一个租户
- 验证码生成、哈希、频控、过期和校验由自有业务服务负责
- `ISmsProvider` 只负责发送。
- 发送失败必须记录失败状态,不能留下可验证验证码。
- 登录、绑定、找回密码等场景使用独立 purpose 和频控键。
### 4.3 Platform Host 流程演示
## 审计与错误
平台管理员登录
必须落审计
```text
POST https://admin.example.com/api/auth/login/password
{
"realm": "platform",
"identifier": "admin@example.com",
"password": "..."
}
- 登录、刷新重放、logout-all、强制改密、MFA 变更;
- 角色、权限、成员状态、租户状态、Provider 配置、支付运营动作;
- System Scope 和跨租户平台操作。
Host 在 PlatformHosts ----是----> tenant context 必须为空
tenantCode ----------不得提供
有效平台角色权限 ----必须存在
后台权限 ------------要求 TOTP/强改密流程
```
错误响应:
platform token 只能在 Platform Host 使用:
- 401未认证或 token/session 无效。
- 403已认证但 realm、tenant、permission、DataScope、MFA 或套餐能力不满足。
- 404未知 Host、不可见资源或需要隐藏存在性的资源。
- 响应不得泄露完整手机号、openId、邮箱、密钥、支付账号或内部 provider payload。
```text
platform token + admin.example.com -> 允许继续
platform token + school-a.example.com -> 403
platform token + unknown.example.com -> 拒绝
```
## 生产配置清单
### 4.4 在 Platform Host 访问 tenant realm
- 正式 `PlatformHosts`
- 可信代理地址和网络 ACL。
- 非通配 `AllowedHosts`
- JWT issuer、audience、当前 `KeyId`、RSA 私钥和旧公钥集合。
- Data Protection 证书。
- CORS 明确 Origin。
- Secret encryption key。
- 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。
统一平台 Host 上的部分公共/认证入口允许使用 `x-tenant-code` 或 query `tenantCode` 解析租户。允许的路径前缀由 `TenantCodePathPrefixes` 控制,默认包括 auth、tenant、catalog、assets、scoreline、referral 和支付通知等入口
示例:
```text
POST http://localhost/api/auth/login/password
x-tenant-code: school-a
{
"realm": "tenant",
"tenantCode": "school-a",
"identifier": "13800000000",
"password": "..."
}
Platform Host + 白名单路径 + tenantCode
|
v
解析 Active Tenant A
|
v
后续 token tid / Session tenant / request tenant 必须都是 Tenant A
```
普通业务路径不能借 `x-tenant-code` 任意切换租户。
### 4.5 Forwarded Host 与可信代理
API 可以读取标准 Forwarded Headers并限制 `ForwardLimit=1``TrustedProxyAddresses` 非空时只信任其中配置的代理;按照 ASP.NET Core Forwarded Headers 的语义KnownProxies/KnownNetworks 同时为空会接受任意转发源,因此生产环境必须配置至少一个可信代理地址,且不能把 API 暴露为可绕过网关的公网入口。生产网关必须:
- 覆盖客户端传入的 `X-Forwarded-Host``X-Forwarded-For``X-Forwarded-Proto`
- 只向 API 转发一个经过验证的外部 Host
- 把网关地址加入 `TrustedProxyAddresses`
- 禁止 API 直接暴露到可绕过网关的公网入口。
没有可信代理配置时,不应假设任意客户端提供的 `X-Forwarded-Host` 会被系统信任。
## 5. 登录状态、强制改密与 MFA
所有登录方式统一返回以下四种状态之一:
- `authenticated`
- `mfa_required`
- `mfa_enrollment_required`
- `password_change_required`
拥有任一 tenant/platform 后台权限的账号必须完成 TOTP。登录不会在 MFA 前签发业务 token只返回 5 分钟、一次性 challenge。
```text
账号密码/短信/微信验证成功
|
+--> ForcePasswordChange ------> password_change_required
|
+--> 有后台权限 + 未配置 TOTP -> mfa_enrollment_required
|
+--> 有后台权限 + 已配置 TOTP -> mfa_required
|
+--> 无后台权限 --------------> authenticated
```
TOTP 接口:
- `POST /api/auth/mfa/totp/setup`
- `POST /api/auth/mfa/totp/confirm`
- `POST /api/auth/mfa/totp/verify`
恢复码仅在首次确认 TOTP 时返回一次;每个恢复码只能兑换一次,重放会失败并记录审计。平台 challenge 的 setup/confirm/verify 也必须继续使用 Platform Host。
## 6. tenant/platform RBAC
授权由 `ICurrentAccessContext` 从数据库解析:
```text
当前 User
+-- tenant realm --> Active Membership
| +-- TenantBackendUserRole
| +-- Active TenantBackendRole
| +-- RolePermission
| +-- tenant:* permission
|
+-- platform realm -> PlatformBackendUserRole
+-- Active PlatformBackendRole
+-- RolePermission
+-- platform:* permission
```
后台授权不读取 JWT role claim、`User.PrimaryRole``TenantMembership.Role``TenantMembership` 只表达租户成员状态和业务身份。Tenant Owner 会绑定不可删除的 `tenant_owner` 系统后台角色;平台超级管理员只由 `platform_super_admin` 系统角色绑定产生。
当前基础权限点:
```text
tenant:dashboard:view platform:dashboard:view
tenant:staff:manage platform:tenant:manage
tenant:role:manage platform:staff:manage
tenant:student:manage platform:role:manage
tenant:content:manage platform:question-bank:manage
tenant:settings:manage platform:audit:view
tenant:provider:manage
tenant:commerce:operate
tenant:crm:manage
tenant:commission:manage
tenant:job:manage
```
主要 Authorization Requirement
- `CurrentTenantMemberRequirement`
- `TenantPermissionRequirement(code)`
- `PlatformPermissionRequirement(code)`
- `MfaRequirement`
- `TenantResourceAccessRequirement`
全局 fallback policy 要求认证;后台权限 policy 同时要求数据库 permission 和 MFA。拒绝访问会写统一审计。
### 菜单不是授权
tenant/platform UI bootstrap 只返回当前数据库有效权限对应的 active menu
```text
数据库有效 permissions ---> 过滤 active menus ---> 前端显示
|
+-------------------------------> API Authorization Handler 再验证
```
隐藏菜单不能代替 API 授权;手工调用 URL 仍会经过 policy。
## 7. DataScope
角色 DataScope 合并规则:
- `All`:允许访问当前租户内该模块全部资源,优先级最高。
- `Restricted(regionIds, classIds)`:多个角色的 region/class 取并集,可选包含 Self。
- `Self`:只允许 owner/当前用户关联资源。
资源列表、详情和写操作必须使用同一范围。越权详情或写入统一按未找到处理,返回 404避免泄露资源是否存在。
```text
多个有效角色
|
+--> 任一 All --------------------> All
|
+--> Restricted A + Restricted B -> region/class 并集
|
+--> 只有 Self -------------------> Self
```
当前学生、班级、现代内容管理,以及具备 owner/region 关系的订单、支付、退款和 DirectContent 资源已在 SQL 中应用范围。支付配置、激活码、积分、优惠券、对账、CRM webhook/config 等缺少可靠 owner/region/class 外键的资源只允许 `All`Restricted/Self 账号会 fail-closed不能退化为仅按 tenant 查询。
## 8. 短信验证码安全
短信发送入口为 `POST /api/auth/sms/send`,仅支持 tenant realm响应 `202` 且不返回验证码。
安全策略:
- 使用 `RandomNumberGenerator.GetInt32` 生成 6 位验证码。
- 使用服务端 pepper 的 HMAC-SHA256 保存验证码摘要。
- 同一验证码最多失败 5 次,第 5 次原子标记 `Blocked`
- 正确验证码只能原子消费一次;并发请求只有一个成功。
- 持久化限制 tenant、phone、IP、device 四个维度。
- HTTP 命名限流再按 phone + IP 分区。
- pepper 至少 32 个字符,缺失时启动校验失败。
默认额度:
| 维度 | 默认值 |
| --- | --- |
| tenant | 100 次/小时 |
| phone | 5 次/小时 |
| IP | 20 次/小时 |
| device | 10 次/小时 |
| 验证失败 | 5 次后 Blocked |
## 9. 审计与错误响应
统一审计覆盖:
- 登录成功、失败、锁定;
- MFA enrollment、验证、恢复码
- Session family 撤销、logout-all、refresh 重放;
- 角色、权限、菜单、用户角色绑定;
- 平台管理员 bootstrap
- 已认证用户的授权拒绝。
API 使用 ProblemDetails。常见结果
| 状态 | 场景 |
| --- | --- |
| 400 | realm/tenantCode/Host 契约错误、无效输入 |
| 401 | 未认证、token/Session 无效 |
| 403 | 已认证但权限不足,或 Host 与 token tenant 冲突 |
| 404 | 未知租户 Host、资源不存在或 DataScope 越权 |
| 429 | 密码、短信、MFA 或全局限流 |
## 10. 生产配置清单
上线前至少确认:
1. `Tenancy:Resolution:PlatformHosts` 只包含正式平台域名。
2. `Tenancy:Resolution:TrustedProxyAddresses` 只包含实际网关地址。
3. JWT `Issuer``Audience`、当前 `KeyId`、RSA 私钥和旧公钥集合已配置。
4. Access token 仍固定为 15 分钟,不能关闭数据库 Session 校验。
5. `TIKU_SMS_CODE_PEPPER` 使用独立高熵值,不使用开发默认值。
6. `TIKU_DATA_PROTECTION_CERTIFICATE_PATH` 指向包含私钥的 PKCS#12 文件,并配置密码。
7. CORS 只允许明确 Origin浏览器 cookie/BFF 不在当前 token JSON 契约内。
8. API/Worker 不自动迁移数据库;部署流程显式运行 `Tiku.DbMigrator`
9. 首次部署使用一次性 `--bootstrap-platform-admin`,完成强制改密和 TOTP 后销毁临时密码。
10. 网关阻断未知 Host并禁止绕过可信代理直连 API。
## 11. 新接口安全检查
新增或修改接口时必须回答:
- 它属于 tenant 还是 platform realm
- 是否显式 `[AllowAnonymous]`;若不是,使用哪个 permission policy
- Host、tenant context、JWT `tid` 和 Session 是否能形成一致闭环?
- 是否需要 MFA后台 permission policy 默认需要。
- 资源如何映射 owner、region、class列表和写入是否使用相同 SQL 范围?
- 越权是否返回 404
- 是否写审计?
- 是否需要 account+IP、phone+IP 或持久化多维限流?
- 是否错误地读取 JWT role、PrimaryRole、TenantRole 或前端菜单做授权?
如果资源没有可靠的数据范围关联,先限制为 `All`,再通过明确的 schema 变更补足 owner/region/class 外键;禁止用字符串 ID 或 JSON 内容猜测权限范围。
待补强事项见 [认证与授权待补强清单](authentication-authorization-hardening-plan.md)