17 KiB
TIKU SaaS 认证、授权与 Host 安全策略
本文档描述 TIKU Backend 当前生效的安全架构,是认证、后台授权、租户隔离、Host 解析、Session、MFA 和短信验证码实现的统一约定。新增接口或修改登录流程时,应以本文档和自动化测试为准,不能只依赖前端菜单、JWT 字符串或历史 TenantRole 约定。
1. 安全目标与基本原则
系统同时存在两个互相隔离的授权域:
tenant:租户业务域,必须绑定一个 Active 租户和一个 ActiveTenantMembership。platform:平台运营域,不绑定租户,只能从配置的 Platform Host 进入。
核心原则:
- Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。
- JWT 只证明一次已认证会话,不承载可直接授权的角色或权限。
- 后台权限每次从数据库角色绑定解析,菜单只负责 UI 展示,不负责 API 授权。
- 数据权限必须进入 SQL;无法可靠映射 owner、region 或 class 的资源采用
All-only fail-closed,不猜测数据归属。 - 用户、成员、租户、后台角色、后台权限、SecurityStamp 或 Session 任一失效,旧 token 都不能继续扩大访问权。
- 所有 Controller 默认要求认证,公开接口必须显式标记
[AllowAnonymous]。
整体边界如下:
浏览器 / 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 约束
2. 账号安全底座
账号由 ASP.NET Core Identity 管理,User 继承 IdentityUser<Guid>,Identity 与业务实体共用 TikuDbContext。系统不使用 ASP.NET 全局 Role 表,租户与平台后台角色由独立 SaaS RBAC 表维护。
当前固定参数:
- 密码最少 10 位。
- Identity PBKDF2 迭代次数为 210,000。
- 连续 5 次密码失败后锁定 15 分钟。
- 用户 Active 状态在每次 Session 校验时检查;强制改密、退出全部设备和其他账号安全事件同时通过 SecurityStamp 使旧 Session 失效。
- TOTP、恢复码、Authenticator Key 使用 Identity 标准能力。
- 微信等外部身份只保留 provider subject、openid、unionid 等映射,不保存
session_key或原始 secret。
Identity 的 Data Protection key 持久化到 PostgreSQL。Development 可以不使用证书;非 Development 环境必须提供包含私钥的 PKCS#12 证书保护 key ring,否则 API 启动失败。
3. JWT 与数据库 Session
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 验证都必须同时确认:
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 格式为:
v2.{t|p}.{tenantId|-}.{sessionId}.{64-byte-random-secret}
数据库只保存完整 refresh token 的 SHA-256 hash,明文只返回客户端一次。
刷新在事务内完成:
旧 refresh token
|
v
读取并验证当前 Session/用户/realm/tenant/SecurityStamp
|
v
原子设置 revoked=rotated + replacedBySessionId
|
v
创建同 family 的子 Session,返回新 access/refresh
并发刷新只允许一个请求成功。已轮换 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 类型
| 请求入口 | 租户上下文 | 允许的认证域 | 结果 |
|---|---|---|---|
| 配置的 Platform Host | 默认无租户 | platform;部分白名单路径可显式提供 tenantCode 进入 tenant | 继续处理 |
| Active 租户自定义 Host | 固定为该 Host 对应租户 | tenant | 继续处理 |
| 租户 Host + 不同 tenantCode/header | Host 与输入冲突 | 无 | 403 |
| Platform Host + platform realm + tenantCode | 非法混合 | 无 | 400 |
| 非 Platform、未绑定租户的未知 Host | 无 | 无 | 非豁免路径 404 |
| 未知 Host 上的 platform 登录/refresh/logout/MFA challenge | 无 | 无 | 默认先由 Host 解析返回 404;即使路径被配置为豁免,平台 Host 二次校验仍返回 400 |
默认 Platform Host 是 localhost 和 127.0.0.1,生产必须通过 Tenancy:Resolution:PlatformHosts 配置正式平台域名。
4.2 租户 Host 流程演示
假设 school-a.example.com 已绑定 Tenant A:
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
四者一致:继续授权
如果同一请求携带 Tenant B token:
Host -------------> Tenant A
token tid --------> Tenant B
X 不一致
结果 -------------> 403 tenant_context_conflict
在租户自定义 Host 上,x-tenant-code、query tenantCode 或 body 中的 tenant ID 都不能切换到另一个租户。
4.3 Platform Host 流程演示
平台管理员登录:
POST https://admin.example.com/api/auth/login/password
{
"realm": "platform",
"identifier": "admin@example.com",
"password": "..."
}
Host 在 PlatformHosts ----是----> tenant context 必须为空
tenantCode ----------不得提供
有效平台角色权限 ----必须存在
后台权限 ------------要求 TOTP/强改密流程
platform token 只能在 Platform Host 使用:
platform token + admin.example.com -> 允许继续
platform token + school-a.example.com -> 403
platform token + unknown.example.com -> 拒绝
4.4 在 Platform Host 访问 tenant realm
统一平台 Host 上的部分公共/认证入口允许使用 x-tenant-code 或 query tenantCode 解析租户。允许的路径前缀由 TenantCodePathPrefixes 控制,默认包括 auth、tenant、catalog、assets、scoreline、referral 和支付通知等入口。
示例:
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
所有登录方式统一返回以下四种状态之一:
authenticatedmfa_requiredmfa_enrollment_requiredpassword_change_required
拥有任一 tenant/platform 后台权限的账号必须完成 TOTP。登录不会在 MFA 前签发业务 token,只返回 5 分钟、一次性 challenge。
账号密码/短信/微信验证成功
|
+--> ForcePasswordChange ------> password_change_required
|
+--> 有后台权限 + 未配置 TOTP -> mfa_enrollment_required
|
+--> 有后台权限 + 已配置 TOTP -> mfa_required
|
+--> 无后台权限 --------------> authenticated
TOTP 接口:
POST /api/auth/mfa/totp/setupPOST /api/auth/mfa/totp/confirmPOST /api/auth/mfa/totp/verify
恢复码仅在首次确认 TOTP 时返回一次;每个恢复码只能兑换一次,重放会失败并记录审计。平台 challenge 的 setup/confirm/verify 也必须继续使用 Platform Host。
6. tenant/platform RBAC
授权由 ICurrentAccessContext 从数据库解析:
当前 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 系统角色绑定产生。
当前基础权限点:
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:
CurrentTenantMemberRequirementTenantPermissionRequirement(code)PlatformPermissionRequirement(code)MfaRequirementTenantResourceAccessRequirement
全局 fallback policy 要求认证;后台权限 policy 同时要求数据库 permission 和 MFA。拒绝访问会写统一审计。
菜单不是授权
tenant/platform UI bootstrap 只返回当前数据库有效权限对应的 active menu:
数据库有效 permissions ---> 过滤 active menus ---> 前端显示
|
+-------------------------------> API Authorization Handler 再验证
隐藏菜单不能代替 API 授权;手工调用 URL 仍会经过 policy。
7. DataScope
角色 DataScope 合并规则:
All:允许访问当前租户内该模块全部资源,优先级最高。Restricted(regionIds, classIds):多个角色的 region/class 取并集,可选包含 Self。Self:只允许 owner/当前用户关联资源。
资源列表、详情和写操作必须使用同一范围。越权详情或写入统一按未找到处理,返回 404,避免泄露资源是否存在。
多个有效角色
|
+--> 任一 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. 生产配置清单
上线前至少确认:
Tenancy:Resolution:PlatformHosts只包含正式平台域名。Tenancy:Resolution:TrustedProxyAddresses只包含实际网关地址。- JWT
Issuer、Audience、当前KeyId、RSA 私钥和旧公钥集合已配置。 - Access token 仍固定为 15 分钟,不能关闭数据库 Session 校验。
TIKU_SMS_CODE_PEPPER使用独立高熵值,不使用开发默认值。TIKU_DATA_PROTECTION_CERTIFICATE_PATH指向包含私钥的 PKCS#12 文件,并配置密码。- CORS 只允许明确 Origin;浏览器 cookie/BFF 不在当前 token JSON 契约内。
- API/Worker 不自动迁移数据库;部署流程显式运行
Tiku.DbMigrator。 - 首次部署使用一次性
--bootstrap-platform-admin,完成强制改密和 TOTP 后销毁临时密码。 - 网关阻断未知 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 内容猜测权限范围。