forked from xiongyuxing/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
449
docs/architecture/authentication-authorization-security.md
Normal file
449
docs/architecture/authentication-authorization-security.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# TIKU SaaS 认证、授权与 Host 安全策略
|
||||
|
||||
本文档描述 TIKU Backend 当前生效的安全架构,是认证、后台授权、租户隔离、Host 解析、Session、MFA 和短信验证码实现的统一约定。新增接口或修改登录流程时,应以本文档和自动化测试为准,不能只依赖前端菜单、JWT 字符串或历史 `TenantRole` 约定。
|
||||
|
||||
## 1. 安全目标与基本原则
|
||||
|
||||
系统同时存在两个互相隔离的授权域:
|
||||
|
||||
- `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]`。
|
||||
|
||||
整体边界如下:
|
||||
|
||||
```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 约束
|
||||
```
|
||||
|
||||
## 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 验证都必须同时确认:
|
||||
|
||||
```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 格式为:
|
||||
|
||||
```text
|
||||
v2.{t|p}.{tenantId|-}.{sessionId}.{64-byte-random-secret}
|
||||
```
|
||||
|
||||
数据库只保存完整 refresh token 的 SHA-256 hash,明文只返回客户端一次。
|
||||
|
||||
刷新在事务内完成:
|
||||
|
||||
```text
|
||||
旧 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:
|
||||
|
||||
```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
|
||||
|
||||
四者一致:继续授权
|
||||
```
|
||||
|
||||
如果同一请求携带 Tenant B token:
|
||||
|
||||
```text
|
||||
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 流程演示
|
||||
|
||||
平台管理员登录:
|
||||
|
||||
```text
|
||||
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 使用:
|
||||
|
||||
```text
|
||||
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 和支付通知等入口。
|
||||
|
||||
示例:
|
||||
|
||||
```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 内容猜测权限范围。
|
||||
Reference in New Issue
Block a user