# LicenseManager Platform - 安全设计文档

| 项目 | 内容 |
| --- | --- |
| 文档版本 | v1.0 |
| 文档类型 | 安全设计文档 (Security Design Document) |
| 关联文档 | [RDP v1.0.1](./LicenseManager-Platform-RDP-v1.0.1.md) · [TAD v1.0](./LicenseManager-Platform-TAD-v1.0.md) · [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md) · [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) · [Coding Standards](./LicenseManager-Platform-Coding-Standards-v1.0.md) |
| 目标读者 | 安全工程师、后端开发、架构师、SRE、合规审计人员 |
| 文档日期 | 2026-07-25 |

---

## 0. 修订历史

| 版本 | 日期 | 修订内容 |
| --- | --- | --- |
| v1.0 | 2026-07-25 | 初稿，基于 `src/LicenseManager.Api` 与 `src/LicenseManager.Platform` 现有安全实现编写 |
| v1.1 | 2026-09 | 修正客户生命周期端点认证表述：`activate`/`verify`/`heartbeat`/`deactivate`/`refresh-file` 在 `Auth:AllowPublicActivation=true`（默认）下全部匿名放行（删除"heartbeat 始终强制认证"的错误表述），可配置收紧 |
| v1.2 | 2026-10-08 | 商业化裁剪：移除/标注资金流相关内容（入站 PSP webhook 验签、PSP 凭据与 Stripe/PayPal 配置键、公开路径中的 `/v1/webhooks/payment`），移交外部销售系统 |
| v1.3 | 2026-10-14 | 默认种子管理员凭据同步为 `admin@licensemanager.cn` / `Admin@123456`（与演示环境一致，占位密码由 `Admin@123` 更新） |

---

## 1. 概述

本文档系统化描述 LicenseManager Platform 的安全设计，覆盖认证、授权、请求安全、数据保护、输入验证、审计合规与前端安全等维度。所有内容均基于实际代码实现，不包含未落地的规划。

### 1.1 安全设计原则

| 原则 | 实现要点 |
| --- | --- |
| **纵深防御 (Defense-in-Depth)** | 中间件管道分层：指标采集 → 异常屏蔽 → 限流 → 认证 → 授权 → 幂等 → 响应包装（幂等性位于认证/授权**之后**，缓存键并入调用方身份，防止未认证调用方凭他人 `Idempotency-Key` 回放响应绕过认证）；前端路由守卫 + 后端中间件双重校验 |
| **最小权限 (Least Privilege)** | API Key 绑定租户与 scope；JWT 显式 `role` claim；`/v1/admin/*` 路径强制已认证用户 |
| **失败安全 (Fail-Safe)** | 生产环境未配置 `Jwt:Secret` / `LICENSEMANAGER_DATA_PROTECTION_KEY` / `Seed:AdminPassword` 时**拒绝启动** |
| **不信任输入 (Never Trust Input)** | 模型验证 + 模板变量 HTML 转义 + EF Core 参数化查询 + 客户端 `escapeHtml` |
| **常量时间比较 (Constant-Time Comparison)** | JWT 签名校验、API Key 哈希校验、HMAC 签名校验均使用 `CryptographicOperations.FixedTimeEquals`；例外：独立浮动服务器 `LicenseManager.Server` 的 `ApiKeyMiddleware` 对 `X-Api-Key` 使用 `string.Equals(Ordinal)` 普通比较 |
| **最小化攻击面 (Attack Surface Reduction)** | 生产环境关闭 Swagger；异常消息脱敏；`InnerException` 不外泄 |
| **可审计 (Auditable)** | `AuditService` 记录所有关键操作；`AuditActorSnapshot` 捕获操作者上下文 |

### 1.2 威胁模型（参考 OWASP Top 10 2021）

| OWASP 类别 | 对应风险 | 本平台缓解措施 | 实现位置 |
| --- | --- | --- | --- |
| A01 - 失效的访问控制 | 越权访问管理 API | 认证中间件 + 授权中间件 + 前端路由守卫 | `AuthenticationMiddleware` / `AuthorizationMiddleware` / `router/index.ts` |
| A02 - 加密失败 | 凭据明文存储、弱签名 | AES-GCM 加密敏感字段；SHA256 哈希存储 API Key；HMAC-SHA256 签名 | `SecretProtector` / `ApiKeyService` / `JwtIssuer` |
| A03 - 注入 | SQL 注入、XSS、模板注入 | EF Core 参数化；Vue 默认转义；`WebUtility.HtmlEncode` | 控制器 / 前端 / `NotificationService` |
| A04 - 不安全设计 | 缺少限流、幂等 | 固定窗口限流 + `Idempotency-Key` 幂等缓存 | `RateLimitingMiddleware` / `IdempotencyMiddleware` |
| A05 - 安全配置错误 | 默认凭据、CORS 过宽、Swagger 暴露 | 生产环境强制配置密钥；CORS 白名单；Swagger 仅 Development | `Program.cs` / `JwtSecretResolver` / `SeedService` |
| A06 - 易受攻击的组件 | 第三方依赖漏洞 | NuGet 锁定版本；测试套件持续验证 | `Directory.Packages.props` |
| A07 - 认证失败 | 暴力破解、弱密码 | 登录失败 5 次锁定 15 分钟；IP 白名单（CIDR） | `LoginSecurityService` / `AuthController` |
| A08 - 数据完整性失败 | 签名绕过 | JWT 三段式签名校验；HMAC 时间戳容差 ±300 秒 | `AuthenticationMiddleware` |
| A09 - 日志监控不足 | 攻击无追溯 | 全量审计日志 + Actor 快照 + 结构化日志 | `AuditService` / `AuditActorSnapshotService` |
| A10 - 服务端请求伪造 | 出站请求被劫持 | `IHttpClientFactory` 集中管理；OIDC 严格 `iss`/`aud` 校验 | `OidcClient` / Webhook 投递 |

---

## 2. 认证机制

### 2.1 认证中间件处理流程

`AuthenticationMiddleware` 是中间件管道中的认证入口，位于 `RateLimitingMiddleware` 之后、`AuthorizationMiddleware` 之前（`IdempotencyMiddleware` 位于 `AuthorizationMiddleware` **之后**，确保幂等缓存键并入认证身份）。处理流程：

```
请求进入 → Auth:Enabled=false? → 是: 标记 user="system" 放行
          ↓ 否
          IsPublicPath(path)? → 是: 放行（不设置 user）
          ↓ 否
          解析 Authorization 头
          ↓
          scheme = Bearer | ApiKey | Hmac
          ↓
          认证成功? → 是: HttpContext.Items["user"] = userId; 放行
          ↓ 否
          返回 401 { code: "auth.invalid_token", message: "Authentication failed." }
```

**关键实现要点**：

- 通过 `Auth:Enabled=false` 配置可完全关闭认证（默认开启，关闭时所有请求标记为 `system` 用户）。
- 公开路径白名单（`PublicPathRegistry` 统一维护，源码全集）：`/swagger`、`/v1/healthz`、`/v1/readyz`、`/v1/status`、`/v1/error-codes`、`/v1/metrics`、`/v1/auth/login`、`/v1/config/features`、`/v1/auth/sms`、`/v1/portal/auth/sms`、`/v1/auth/sso`、`/v1/portal/auth/sso`、`/v1/keys`（许可证签名公钥分发，见 §2.6）、`/v1/portal/auth/login`、`/v1/portal/auth/forgot-password`、`/scim`、`/v1/partner`、`/v1/sandbox`（后两者为存根端点临时公开，写操作返回 501、查询返回空数据，功能实现后应收紧）。匹配按段边界进行：路径等于前缀本身或以 `前缀 + "/"` 开头才命中，杜绝 `/v1/auth/login-evil` 这类同前缀碰撞被误放行。注意 `/`（根路径）**不在**白名单中。（原白名单中的 `/v1/webhooks/payment` 已随商业化裁剪移除。）
- ~~`/v1/webhooks/payment/*` 虽为公开路径，但由 `WebhookSignatureVerifier` 强制 PSP 签名验证：验签密钥仅从 `PspConfig`（加密存储）查询解密，不接受调用方通过请求头自带密钥（如 `X-Webhook-Secret`）——自带密钥即可伪造合法签名通过校验；验签失败记录死信（`PspDeadLetterEntry`）并审计。~~（已随商业化裁剪下线：入站 PSP webhook 与 `WebhookSignatureVerifier` 已移除，资金流由外部销售系统承接。）
- ~~**入站验签密钥双轨优先级**：`PaymentRegistrationHostedService` 启动时先以 env/config 兜底注入各 PSP 的 `WebhookSecret`，再用数据库 `PspConfig` 的解密值覆盖（PspConfig 优先，仅非空才覆盖）。凭据（Stripe SecretKey / PayPal AccessToken 等）仍仅 env/config 注入。PspConfig 命中以启动日志 `"Webhook secret for {Code} loaded from PspConfig (database)"` 标识。~~（已随商业化裁剪下线。）
- ~~**证书族 PSP fail-dead 防伪造**：`alipay` / `wechat_pay` / `braintree` / `unionpay` 四家适配器覆写 `VerifyWebhookSignatureAsync` **恒返回 `false`**——真实协议为 RSA2 / APIv3 平台证书等非对称验签，未实现前沿用 HMAC 占位等于"持有密钥即可伪造回调"；fail-dead 确保接入真实 API 前这四家的回调必然被拒（401），杜绝占位验签被伪造利用。~~（已随商业化裁剪下线。）
- `/v1/customer/licenses/activate`、`/verify`、`/heartbeat`、`/deactivate`、`/refresh-file` 客户端生命周期端点默认经 `Auth:AllowPublicActivation`（默认 `true`）**全部匿名放行**以兼容现有 SDK；生产环境应显式置为 `false` 强制 ApiKey/Hmac/Bearer 认证。该开关为已知技术债务（代码含 TODO 标注），待客户端 SDK 全面改造为 ApiKey/Hmac 后移除。
- 401 响应体格式统一：`{ success: false, error: { code, message }, requestId, timestamp }`。

### 2.2 JWT 认证（Bearer）

**令牌签发**：`JwtIssuer.Issue(subject, secret, validity, extraClaims, role)` 手工实现，不依赖 `System.IdentityModel.Tokens.Jwt`。

| 字段 | 来源 | 说明 |
| --- | --- | --- |
| `alg` | 固定 `HS256` | HMAC-SHA256 |
| `typ` | 固定 `JWT` | - |
| `sub` | `subject` 参数 | 通常是用户 ID |
| `iat` | 当前 UTC 时间（秒） | 签发时间 |
| `exp` | `iat + validity`（秒） | 过期时间，默认 1 小时；`AuthController.Login` 实际签发有效期为 **8 小时** |
| `iss` | 固定 `license-manager` | 签发者 |
| `aud` | 固定 `license-manager-api` | 受众 |
| `role` | `role` 参数（可选） | 角色 claim，写入 `payload["role"]` |
| 其他 | `extraClaims` 字典（可选） | `AuthController` 写入 `email`、`name` |

签名计算：`HMAC-SHA256(secret, base64url(header) + "." + base64url(payload))`，结果以 base64url 编码追加为第三段。

**令牌校验**：`AuthenticationMiddleware.AuthenticateJwt` 实现：

1. 拆分三段（不足三段直接拒绝）。
2. base64url 解码 header / payload / signature。
3. 使用配置密钥重算 HMAC-SHA256，**`CryptographicOperations.FixedTimeEquals`** 常量时间比较，防止时序攻击。
4. 校验 `sub` 存在且非空。
5. 校验 `exp`：若存在且已过期，记录 Information 日志并返回 `null`。
6. 任何异常被捕获并记录 Warning 日志，返回 `null`（不向调用方泄露内部错误）。

**密钥管理**：`JwtSecretResolver.Resolve` 集中处理：

- 优先读取 `Jwt:Secret` 或 `JwtSecret` 配置键。
- 拒绝默认占位符 `LicenseManager-Default-Jwt-Secret-CHANGE-ME`。
- **生产环境**：未配置或使用占位符 → 抛出 `InvalidOperationException`，应用启动失败。
- **开发环境**：使用 `RandomNumberGenerator.GetBytes(48)` 生成进程级随机密钥并缓存，记录 Critical 日志告警；进程重启后旧令牌失效。

**密码存储**：用户登录密码由 `PasswordHasher` 以 PBKDF2-HMACSHA256 哈希（600,000 次迭代、16 字节随机盐、32 字节派生密钥），存储格式为 `pbkdf2$600000$base64(salt)$base64(hash)`；兼容存量 `salt:hash`（单轮 SHA256）与历史低迭代（210,000）PBKDF2 格式，登录成功后透明重哈希升级为当前标准；校验使用固定时间比较，防止时序攻击。

### 2.3 API Key 认证（ApiKey）

**请求格式**：`Authorization: ApiKey <keyId>:<secret>`

**校验流程**（`ApiKeyService.ValidateAsync`）：

1. 从 `Authorization` 头解析 `keyId` 与 `secret`。
2. 通过仓储查找 `KeyId` 匹配的 `ApiKey` 实体。
3. 状态校验：`Status` 必须为 `Active`（已吊销的密钥立即拒绝）。
4. 过期校验：`ExpiresAt` 若设置且早于当前时间则拒绝。
5. 哈希校验：计算 `HashUtility.SHA256Hash(secret)`，与存储的 `KeyHash`（hex 字符串）解码为字节后使用 `CryptographicOperations.FixedTimeEquals` 比较。
6. 校验通过后刷新 `LastUsedAt` 并持久化。
7. 返回的 `userId` 取 `ApiKey.UserId`（为空时回退到 `KeyId`）。

**密钥存储**：明文 secret 仅在创建时返回一次（`ApiKeyDto.Secret`），仓储仅保留 SHA256 hex 哈希值。`KeyId` 前缀为 `lm_live_`。

### 2.4 HMAC 签名认证（Hmac）

**安全修复背景**：旧方案 `Authorization: Hmac keyId:secret:signature` 在传输层即可泄露 secret，签名形同虚设。当前实现将 secret 从 `Authorization` 头移除。

**请求格式**：

| 头部 | 内容 |
| --- | --- |
| `Authorization` | `Hmac <keyId>:<signature>` |
| `X-LicenseManager-Api-Key` | 调用方 secret（仅用于服务端密钥校验与 HMAC 密钥，不参与签名输入） |
| `X-LicenseManager-Timestamp` | Unix 秒级时间戳 |
| 请求体 | 任意，参与签名 |

**校验流程**（`AuthenticateHmacAsync`）：

1. 从 `Authorization` 头解析 `keyId` 与 `signature`（以第一个冒号分隔）。
2. 从 `X-LicenseManager-Api-Key` 头读取 `secret`；缺失则记录 Information 日志并拒绝。
3. 从 `X-LicenseManager-Timestamp` 头读取时间戳；缺失或非数字则拒绝。
4. **时间戳容差**：`Math.Abs((UtcNow - tsOffset).TotalSeconds) > 300` 时拒绝（防重放，±300 秒）。
5. 通过 `IApiKeyService.ValidateAsync(keyId, secret)` 完成 secret 哈希校验与状态校验（与 ApiKey 方案共用同一密钥体系）。
6. 启用请求体缓冲（`EnableBuffering`），读取完整 body 后重置位置以供下游使用。
7. 签名计算：`HMAC-SHA256(secret, timestamp + "\n" + body)`，结果以 **hex 小写** 编码。
8. 使用 `CryptographicOperations.FixedTimeEquals` 常量时间比较期望签名与请求签名（均先转为小写 hex 字节）。
9. 返回 `ApiKey.UserId` 或 `KeyId` 作为用户标识。

**为什么 secret 不参与签名输入**：避免与"签名密钥"角色混淆——secret 仅作为 HMAC 计算密钥，签名输入是 `timestamp + body`，攻击者即使拿到签名也无法反推 secret。

---

## 3. 授权机制

### 3.1 授权中间件

`AuthorizationMiddleware` 在 `AuthenticationMiddleware` 之后执行，作为**纵深防御**的二次校验层。不依赖 ASP.NET Core `[Authorize]` 特性，以兼容基于 `WebApplicationFactory` 的集成测试。

**处理逻辑**：

```
请求进入 → IsPublicPath? → 是: 放行
          ↓ 否
          路径以 /v1/admin/ 开头?
          ↓ 是
          HttpContext.Items["user"] 存在?
          ↓ 否
          返回 403 { code: "forbidden", message: "Forbidden." }
          ↓ 是
          第一级：角色非 admin 且命中 IsAdminOnlyPath → 403 { code: "forbidden", message: "Insufficient role." }
          第二级：角色为 support 且命中 IsOperatorUpPath → 403 { code: "forbidden", message: "Insufficient role." }
          ↓ 均通过
          放行
```

**两级角色策略**（与前端路由 `meta.roles` 声明保持一致）：

- **第一级 `IsAdminOnlyPath`**（admin 专属，10 个前缀）：`users`、`roles`、`api-keys`、`tenants`、`system-keys`、`sso-providers`、`integrations`、`imports`、`system-status`、`telemetry`。携带角色但非 `admin` 的认证用户访问即返回 403。
- **第二级 `IsOperatorUpPath`**（operator 及以上，17 个前缀拒绝 support）：`audit`、`webhook`、`webhooks`、`reports`、`pat`、`login-security`、`notification`、`legal-documents`、`eula`、`eulas`、`support-tiers`、`plans`、`incidents`、`data-subject-requests`、`batch`、`import-export`、`exports`。`support` 角色访问即返回 403。
- **权限点校验**：`AuthorizationMiddleware.HasPermission` 供控制器做按钮级权限点校验——ApiKey/Hmac/system 系统级认证自动放行；`admin` 角色自动拥有全部权限点；其余用户依据 JWT `permissions` claim 判定。
- **系统级信任**：ApiKey 与 Hmac 认证方案不携带 `role` claim（视为系统级信任），不受上述角色限制，以保持与现有 SDK/集成的兼容性；仅 Bearer JWT 认证的用户受角色约束。
- 前缀匹配采用段边界判定（`HasPathPrefix`：前缀后须紧跟 `/`、`-` 或字符串结尾），避免 `/pat` 误匹配 `/patterns` 之类的子串误伤。

### 3.2 角色授权

| 角色 | 来源 | 授权方式 |
| --- | --- | --- |
| `admin` | JWT `role` claim（`AuthController.Login` 取 `user.RoleIds[0]`，默认 `admin`） | 后端：`AuthorizationMiddleware` 两级角色策略强制；前端：路由 `meta.roles` 数组（如 `['admin']`）+ `auth.hasAnyRole()` |
| `operator` | JWT `role` claim | 同上（运营页面声明 `meta.roles: ['admin','operator']`） |
| `support` | JWT `role` claim | 同上（仅可访问未声明角色限制或允许 support 的页面） |
| `customer` | 客户端令牌（规划） | 同上 |

**前端路由守卫**（`web/admin/src/router/index.ts`）：

```ts
router.beforeEach((to) => {
  const auth = useAuthStore()
  if (!to.meta.public && !auth.isAuthenticated) return { name: 'Login' }
  if (to.name === 'Login' && auth.isAuthenticated) return { name: 'Dashboard' }
  // 基于角色的访问控制：meta.roles 数组定义允许访问的角色
  if (to.meta.roles && to.meta.roles.length > 0 && !auth.hasAnyRole(to.meta.roles)) {
    ElMessage.error(i18n.global.t('common.forbidden'))
    return { name: 'Dashboard' }
  }
})
```

`userRole` 从 JWT payload 客户端解析（仅读取 claim，不校验签名——签名校验由后端负责）。

### 3.3 资源级授权

当前资源级授权由控制器层负责：通过 `HttpContext.Items["user"]` 获取已认证用户 ID，结合业务逻辑（如租户隔离、所有权校验）判定。角色级检查已由 `AuthorizationMiddleware` 两级角色策略承担（见 3.1 节）；基于资源所有者的细粒度检查为未来扩展点。

---

## 4. 请求安全

### 4.1 速率限制（RateLimitingMiddleware）

**算法**：固定窗口。已认证请求按 JWT `sub`（每个账号独立配额）、匿名请求按客户端 IP 每分钟限流。

**配置**：`RateLimit:PerMinute`（代码默认 100，仓库 `appsettings.json` 配置为 600）；`RateLimit:Distributed`（默认 `false`，分布式全局计数开关）。

**实现要点**：

- 限流主体：已认证用户从 JWT 解析 `sub` claim（回退 `NameIdentifier`），未认证按 IP 限速。
- 客户端 IP 解析：默认使用 `Connection.RemoteIpAddress`（无法解析时回退 `"unknown"`）；仅当 `RateLimit:TrustXForwardedFor=true`（默认 `false`，反向代理部署时显式开启）才信任 `X-Forwarded-For` 首段，避免直连场景下客户端伪造该头绕过按 IP 限流。
- 计数器键：已认证 `"rl:sub:{sub}:{minute}"`，匿名 `"rl:ip:{ip}:{minute}"`（minute = `UnixSeconds / 60`）。
- **默认（`RateLimit:Distributed=false`）**：计数器使用 `IMemoryCache` 承载，配合 `ConcurrentDictionary` 风格的 `Counter` 类（`Interlocked.Increment`）保证线程安全。
- **分布式可选（`RateLimit:Distributed=true`）**：经 `IDistributedCache`（Redis）做**全局**计数——键首次写入以 90 秒绝对过期定窗，已存在时不带过期项更新（保留原 TTL，维持固定窗口而非滑动窗口）。**多实例部署必须开启**，否则各实例独立计数，限流配额被实例数放大。Redis 未配置（未注册 `IDistributedCache`）时回退内存限流并 `LogWarning` 一次（`RateLimit:Distributed=true but IDistributedCache is not registered...`），不阻断请求。
- **过期策略**：`AbsoluteExpirationRelativeToNow = 90 秒`（略大于 60 秒窗口，确保跨分钟读取仍可用并由 GC 自动回收，避免内存泄漏）。
- 跳过路径：`PublicPathRegistry` 公开路径全集（`/swagger*`、`/v1/healthz`、`/v1/readyz`、`/v1/status`、`/v1/error-codes`、`/v1/metrics`、`/v1/auth/login`、`/v1/config/features`、`/v1/auth/sms`、`/v1/portal/auth/sms`、`/v1/auth/sso`、`/v1/portal/auth/sso`、`/v1/keys`、`/v1/portal/auth/login`、`/v1/portal/auth/forgot-password`、`/scim`、`/v1/partner`、`/v1/sandbox`；原 `/v1/webhooks/payment` 已随商业化裁剪移除）。
- 超限响应：HTTP 429，`Retry-After: 60`，body 包含 `limit` / `window_seconds` / `retry_after_seconds` 详情。

**已知限制**：`Distributed=true` 时的 Redis 计数为非原子 get/set 固定窗口（`IDistributedCache` 无原子自增原语），极端并发下计数偏差数个请求，不影响整体防护语义；单实例/未开启分布式时计数仅在本实例内生效。

### 4.2 幂等性（IdempotencyMiddleware）

**触发条件**：

- HTTP 方法为 `POST` / `PUT` / `PATCH`；
- 请求头包含非空 `Idempotency-Key`。

**实现要点**：

- 缓存键：`"idem:{identity}:{Idempotency-Key}"`，其中 `identity` 取认证后 `HttpContext.Items["user"]`（用户/keyId），匿名请求统一为 `"anonymous"`，不同调用方互不可见对方的幂等缓存。
- 同一幂等键携带不同请求体（body SHA256 hex 小写不一致）时拒绝回放，返回 409 `{ code: "idempotency_conflict" }`。
- 缓存载体：`IMemoryCache`，TTL 由 `Idempotency:CacheHours` 配置（默认 24 小时）。
- **仅缓存 2xx 响应**（4xx/5xx 不缓存，允许客户端重试）。
- 命中缓存时回放：恢复 `StatusCode`、`ContentType`、`Content-*` 与 `Location` 响应头，并追加 `Idempotent-Replay: true` 头标识。
- 响应捕获：通过临时替换 `HttpContext.Response.Body` 为 `MemoryStream`，写回原始流后缓存副本。
- 仅捕获"回放安全"的响应头（`Content-*` 与 `Location`），避免回放 `Set-Cookie` 等敏感头。

### 4.3 CORS 配置

`Program.cs` 中注册默认 CORS 策略：

| 环境 | 策略 |
| --- | --- |
| Development | `AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()` |
| 生产环境且 `Cors:AllowedOrigins` 非空 | `WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()` |
| 生产环境且 `Cors:AllowedOrigins` 为空 | 回退到 `AllowAnyOrigin`（**生产部署应显式配置白名单**） |

CORS 中间件位于 `HttpsRedirection` 之后、其他业务中间件之前，保证预检请求被正确处理。

### 4.4 HSTS 与 HTTPS 重定向

| 中间件 | 环境 | 作用 |
| --- | --- | --- |
| `UseHttpsRedirection` | 全部 | HTTP → HTTPS 重定向 |
| `UseHsts` | 仅 Production | 强制浏览器使用 HTTPS（Strict-Transport-Security 头） |
| `UseForwardedHeaders` | 仅 Production | 处理 `X-Forwarded-For` 与 `X-Forwarded-Proto`，还原反向代理后的客户端 IP 与协议 |

Swagger 仅在 Development 环境启用，生产环境不暴露接口文档以减少攻击面。

### 4.5 出站 SSRF 防护（Webhook 投递）

出站 Webhook 投递（`WebhookDeliveryWorker` → `webhook` 命名 HttpClient）实施双层 SSRF 防护：

- **注册期校验**：`WebhookEndpointService.ValidateWebhookUrl` 在端点创建/更新时校验 URL 字面量（生产强制 HTTPS、拒绝 `localhost`/`::1`、拒绝私有/环回 IP 段）。
- **连接时校验（防 DNS rebinding）**：`SocketsHttpHandler.ConnectCallback` 在 **DNS 解析后、TCP 连接前**校验解析出的实际 IP——注册期校验只看 URL 字面量，攻击者可在通过校验后把域名重新解析到内网地址；连接期以解析结果为准，全部解析地址命中私有段时拒绝连接（记为投递失败）。
- **私有段判定**（`WebhookEndpointService.IsPrivateOrLoopback`）覆盖：IPv4 `127.0.0.0/8`、`10.0.0.0/8`、`169.254.0.0/16`（链路本地）、`172.16.0.0/12`、`192.168.0.0/16`、`0.0.0.0/8`；IPv6 环回、链路本地、站点本地、**唯一本地 `fc00::/7`（首字节 `0xfc`/`0xfd`）**；**IPv4-mapped IPv6（`::ffff:10.x.x.x` 等）先 `MapToIPv4` 还原为 IPv4 再判定**，防止借 IPv6 映射形式绕过私有段检查。
- **禁止自动重定向**：`AllowAutoRedirect = false`，防目标端点以 30x 重定向至内网地址。

---

## 5. 数据安全

### 5.1 凭据加密（SecretProtector）

`LicenseManager.Platform.Services.SecretProtector` 是统一的凭据加密服务，使用 **AES-GCM** 对称加密。

**密钥管理**：

| 项 | 说明 |
| --- | --- |
| 算法 | AES-GCM（`AesGcm`） |
| 密钥长度 | 32 字节（256 位） |
| Nonce 长度 | 12 字节，每次加密随机生成（`RandomNumberGenerator.GetBytes`） |
| Tag 长度 | 16 字节 |
| 密钥来源 | 环境变量 `LICENSEMANAGER_DATA_PROTECTION_KEY`（base64 编码的 32 字节） |
| 备用来源 | `IConfiguration[KeyEnvironmentVariable]` 同名键 |
| 生产环境 | 未设置环境变量 → `Initialize` 抛 `InvalidOperationException`，启动失败 |
| 开发环境 | 未设置时使用进程级随机密钥，记录 Warning：进程重启后无法解密旧数据 |
| 自动初始化 | `EnsureKey` 在未显式 `Initialize` 时使用随机密钥（不设置 `_isInitialized`，便于服务层判断是否激活加密） |

**密文格式**：`v1:{nonceBase64}:{ciphertextBase64}:{tagBase64}`

- `IsEncrypted(value)` 通过 `v1:` 前缀判定是否已加密。
- `Decrypt` 检测 `v1:` 前缀走 AES-GCM；旧数据无前缀走兼容的 XOR + base64 解码（迁移期保留）。
- 既非 `v1:` 前缀也非合法 base64 的输入原样返回（视为明文，向后兼容）。

**典型应用**：Webhook 端点 secret 加密（`WebhookEndpointService.EncryptSecret`）（原 PSP 凭据加密场景已随商业化裁剪移除）。

### 5.2 API Key 哈希存储

`ApiKeyService` 创建 API Key 时：

1. 生成 `lm_live_` 前缀的 `KeyId`。
2. 生成随机 `secret`，明文仅在 `ApiKeyDto` 返回一次。
3. 存储 `HashUtility.SHA256Hash(secret)` 的 hex 字符串到 `KeyHash` 字段。
4. 校验时通过 `CryptographicOperations.FixedTimeEquals` 常量时间比较哈希字节，防止时序攻击。

### 5.3 敏感数据脱敏（异常处理中间件）

`ExceptionHandlingMiddleware` 防止异常详情泄露内部实现：

| 异常类型 | HTTP 状态 | 错误码 | 返回给客户端的 message |
| --- | --- | --- | --- |
| `LicenseInvalidOperationException` | 409 Conflict | `license.InvalidOperation` | 保留原 Message（业务异常） |
| `LicenseException`（派生） | 422 Unprocessable Entity | `license.<TypeName>` | 保留原 Message |
| `ArgumentException` | 400 Bad Request | `invalid_argument` | **脱敏为** `"Invalid request."` |
| `InvalidOperationException` | 400 Bad Request | `invalid_operation` | **脱敏为** `"Operation invalid."` |
| `UnauthorizedAccessException` | 401 Unauthorized | `unauthorized` | `"Unauthorized."` |
| 其他未处理异常 | 500 Internal Server Error | `internal_error` | **脱敏为** `"An unexpected error occurred."` |

**关键设计**：

- 仅 `LicenseException` 派生类的 Message 透传（这些是设计上要给用户看的业务异常）。
- 系统异常（含 SQL、堆栈、字段名等）的详细 Message 通过 `_logger.LogError` 写入日志，但**不返回给客户端**。
- **不再返回 `InnerException` 详情**，避免泄露内部异常链。
- 响应体统一包含 `requestId`（`HttpContext.TraceIdentifier`）与 `timestamp`，便于关联日志。

### 5.4 数据库连接加密

- 数据库连接字符串通过 `appsettings.json` / `appsettings.Local.json` / 环境变量配置，仓库中提交的 `appsettings.json` 连接串为空。
- `appsettings.Local.json` 已加入 `.gitignore`，用于本地开发凭据隔离。
- 生产环境建议在连接串中启用 `SslMode=Require`（MySQL）或 `SSL Mode=Require`（PostgreSQL），由部署方在连接串中显式配置。
- 默认数据库 Provider 为 `MySql`，可通过 `Database:Provider` 切换至 PostgreSQL 或 InMemory（测试环境）。

---

## 6. 输入验证与输出编码

### 6.1 模型验证

- 所有控制器使用 `[ApiController]` 特性，自动触发模型验证：`ModelStateInvalidFilter` 在动作执行前校验 `[FromBody]` / `[FromQuery]` / `[FromRoute]` 等参数，校验失败返回 400。
- 请求 DTO 通过 `DataAnnotations`（`[Required]`、`[Range]` 等）或自定义校验逻辑约束输入。
- 前端表单通过 Element Plus `FormRules` 做客户端预校验（如登录页空值与邮箱格式校验）。

### 6.2 HTML 转义（模板变量）

`NotificationService.ReplaceVariables` 在替换通知模板变量时强制 HTML 转义：

```csharp
private static string ReplaceVariables(string template, Dictionary<string, string> variables)
{
    var result = template;
    foreach (var (key, value) in variables)
    {
        var escapedValue = System.Net.WebUtility.HtmlEncode(value);
        result = result.Replace($"{{{{{key}}}}}", escapedValue);
    }
    return result;
}
```

防止客户名、许可证键等用户可控数据注入到邮件 HTML 模板中导致 XSS 或邮件内容篡改。

### 6.3 客户端 HTML 转义

`web/widget/license-widget.js` 在 Shadow DOM 内动态渲染 API 返回数据时统一调用 `escapeHtml`：

```js
escapeHtml(str) {
  if (str == null) return ''
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
}
```

转义覆盖 `&`、`<`、`>`、`"`、`'` 五类字符，应用于 `productName`、`status`、错误消息等动态字段。

### 6.4 SQL 注入防护

- 数据访问层基于 **EF Core**，所有查询使用 LINQ 或参数化 SQL，ORM 自动参数化避免字符串拼接。
- `LicenseManagerDbContext` 注册时绑定 MySQL / PostgreSQL / InMemory Provider，仓储通过 `IInMemoryRepository<T>` 抽象，生产实现基于 EF Core。
- 测试代码 `TestDbContext.OnModelCreating` 显式忽略 jsonb 集合属性，避免 InMemory Provider 映射失败，不引入原生 SQL 拼接路径。

### 6.5 Vue 模板安全

- Vue 3 默认对 `{{ }}` 插值自动 HTML 转义。
- 编码规范禁止使用 `v-html` 渲染用户可控内容；确需渲染 HTML 时必须先经过 DOMPurify 净化。
- 生产环境建议启用 Content-Security-Policy 头，禁止 inline script（`unsafe-inline`）。

---

## 7. 审计与合规

### 7.1 审计日志服务（AuditService）

`LicenseManager.Platform.Services.AuditService` 实现 `IAuditService`，负责持久化系统操作产生的审计事件。

**异步写入路径**：`RecordAsync` 仅将条目写入内存队列（`Channel<AuditLog>`，容量 10000）即返回，避免审计 I/O 阻塞业务请求；实际持久化由 `AuditLogBackgroundWorker`（单例 HostedService）后台批量完成——满 50 条或通道暂无更多数据时落库，单批失败降级为错误日志不阻断消费。队列已满时丢弃新条目并记录告警（审计不反向阻塞业务）。`FlushAsync` 提供同步刷写（测试断言与需要审计强一致的管理操作），与后台工作器并发消费安全。

**`AuditEntry` 字段**：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `Action` | string | 操作类型，如 `role.create`、`role.delete`、`lease.acquire`、`batch.revoke` |
| `ResourceType` | string | 资源类型，如 `role`、`lease`、`batch_operation` |
| `ResourceId` | Guid | 资源主键 |
| `ActorType` | string | 操作者类型：`user` / `system` / `admin` / `api_key` / `webhook` |
| `ActorId` | Guid? | 操作者主键；可空 |
| `OccurredAt` | DateTimeOffset | 发生时间（UTC） |
| `Before` | Dictionary<string, object>? | 变更前快照 |
| `After` | Dictionary<string, object>? | 变更后快照 |

**Actor 信息捕获**：

- `AuditService` 注入 `IHttpContextAccessor?`，在 `ActorId` 缺失时从 `HttpContext.Items["user"]` 回填当前用户。
- `HttpContextAccessor` 在 `Program.cs` 通过 `builder.Services.AddHttpContextAccessor()` 注册，暴露 `HttpContext` 给单例服务与后台服务使用。
- 客户端 IP 与 User-Agent 通过 `AuditActorSnapshotService` 单独捕获。

**典型审计调用**（示例）：

```csharp
await _audit.RecordAsync(new AuditEntry(
    Action: "role.create",
    ResourceType: "role",
    ResourceId: role.Id,
    ActorType: "admin",
    ActorId: null,
    OccurredAt: DateTimeOffset.UtcNow,
    After: new Dictionary<string, object>
    {
        ["name"] = role.Name,
        ["permissions"] = string.Join(",", role.Permissions)
    }), ct);
```

### 7.2 操作者快照（AuditActorSnapshotService）

`IAuditActorSnapshotService` 为每条审计日志捕获独立的操作者上下文快照，固化操作发生时的环境信息：

| 字段 | 说明 |
| --- | --- |
| `AuditLogId` | 关联的审计日志主键 |
| `ActorType` | `user` / `api_key` / `system` / `webhook` |
| `ActorId` | 操作者主键；可空 |
| `ActorEmail` | 操作者邮箱；可空 |
| `ActorName` | 操作者显示名；可空 |
| `IpAddress` | 请求来源 IP；可空 |
| `UserAgent` | 请求 User-Agent；可空 |
| `CapturedAt` | 快照捕获时间（UTC） |

`PlatformOperationsAdminController` 提供 `/v1/admin/audit/actor-snapshots` 系列端点用于捕获与查询快照。

### 7.3 日志保留策略

- 应用日志通过 `ILogger<T>` 输出到控制台 / 文件 / 外部日志系统（Loki / ELK），由部署方配置保留策略。
- 审计日志与操作者快照存储于业务数据库（`AuditLog` / `AuditActorSnapshot` 实体），保留策略由数据库运维决定。
- 异常处理中间件对每条未处理异常记录 `LogError`，包含 `Method` 与 `Path`，便于关联 `requestId`。

---

## 8. 前端安全

### 8.1 Token 存储

| 前端应用 | Token 存储 | 关键键名 | 说明 |
| --- | --- | --- | --- |
| `web/admin`（管理后台） | `sessionStorage` | `admin_token`、`admin_email` | 关闭浏览器标签即失效，降低 XSS 后 token 被窃取的窗口期 |
| `web/portal`（客户门户） | `localStorage` | `portal_token`、`portal_user` | 跨会话保留，便于客户长期使用 |
| `web/partner`（合作伙伴门户） | `localStorage` | `partner_token`、`partner_user` | 同上 |

**已知差异**：`admin` 使用 `sessionStorage`（更严格），`portal` / `partner` 使用 `localStorage`（更便利）。这是因为管理员权限更高，需更短的 token 暴露窗口；客户与合作伙伴使用场景更倾向于长期登录。

### 8.2 路由守卫

`web/admin/src/router/index.ts` 的 `router.beforeEach` 守卫实现三层校验：

1. **未认证访问受保护页面** → 重定向到 `/login`。
2. **已认证访问 `/login`** → 重定向到 `/dashboard`。
3. **路由声明了 `meta.roles` 数组但当前用户不具其中任一角色**（`auth.hasAnyRole(to.meta.roles)` 判定失败）→ `ElMessage.error(i18n.global.t('common.forbidden'))` 并重定向到 `/dashboard`。

admin-only 路由远不止 5 个：`meta.roles: ['admin']` 覆盖 `/users`、`/roles`、`/api-keys`、`/tenants`、`/system-keys`、`/sso-providers`、`/integrations` 等大量管理页面（原 `/currencies`、`/tax-rules`、`/invoice-templates` 等商业路由已随商业化裁剪移除）；`meta.roles: ['admin','operator']` 的运营页面（审计、报表、Webhook 等）拒绝 support 角色。

### 8.3 401 自动登出

`web/admin/src/api/client.ts` 的 axios 响应拦截器处理认证失效：

```ts
if (status === 401) {
  Promise.all([
    import('@/stores/auth'),
    import('@/router'),
  ]).then(([{ useAuthStore }, { default: router }]) => {
    const auth = useAuthStore()
    auth.logout()  // 清空 sessionStorage 与响应式状态
    if (router.currentRoute.value.path !== '/login') {
      router.push('/login')
    }
  })
  return Promise.reject(error)
}
```

- 动态导入 `stores/auth` 与 `router` 避免循环依赖（`client.ts ← stores/auth ← api/auth ← client.ts`）。
- 401 不弹全局错误消息（避免冗余），仅清空认证状态并跳转登录页。
- 403 弹 `ElMessage.error('无权限访问')`。
- 422 表单校验错误不弹全局消息，交由表单组件内联处理。

### 8.4 JWT 客户端解析

`web/admin/src/stores/auth.ts` 的 `parseJwtPayload` 客户端解析 JWT payload（仅读取 claim，不校验签名——签名校验由后端负责）：

- `userRole`：从 `payload.role` 读取，支持字符串与数组（取首元素）。
- `tokenExpiresAt`：从 `payload.exp` 读取（Unix 秒）。
- `isAuthenticated`：token 与 email 非空，且 `exp` 未过期（无 `exp` 时兼容旧 token 不做过期校验）。

### 8.5 XSS 防护

| 防护层 | 实现 |
| --- | --- |
| Vue 默认转义 | `{{ }}` 插值自动 HTML 转义 |
| `v-html` 禁用 | 编码规范禁止对用户可控内容使用 `v-html`，确需使用必须经 DOMPurify 净化 |
| Web Widget | `license-widget.js` 的 `escapeHtml` 转义 5 类字符，Shadow DOM 隔离样式 |
| 邮件模板 | `NotificationService.ReplaceVariables` 使用 `WebUtility.HtmlEncode` 转义变量 |
| CSP | 生产环境建议启用 Content-Security-Policy 头，禁止 inline script |
| Cookie 安全 | 编码规范要求会话 Cookie 设置 `HttpOnly`、`Secure`、`SameSite=Lax`（当前 API 默认使用 Bearer Token，不依赖 Cookie） |

### 8.6 CSRF 防护

- API 默认使用 Bearer Token（`Authorization` 头），不依赖 Cookie 自动携带，天然免疫 CSRF。
- 如使用 Cookie 鉴权须配置 AntiForgeryToken（编码规范要求）。

---

## 9. 安全配置清单

### 9.1 环境变量

| 变量名 | 用途 | 必配环境 | 格式要求 |
| --- | --- | --- | --- |
| `LICENSEMANAGER_DATA_PROTECTION_KEY` | AES-GCM 凭据加密密钥 | **生产环境必配**（缺失则启动失败） | base64 编码的 32 字节 |
| `Jwt:Secret` | JWT 签名密钥 | **生产环境必配**（缺失则启动失败） | 至少 32 字节的强随机值 |
| `Seed:AdminPassword` | 默认管理员密码 | **生产环境必配**（缺失则启动失败） | 强密码，禁用 `Admin@123456` |
| `Seed:AdminEmail` | 默认管理员邮箱 | 可选 | 邮箱格式，默认 `admin@licensemanager.cn` |
| `Auth:Enabled` | 是否启用认证 | 可选 | `true`（默认）/ `false` |
| `RateLimit:PerMinute` | 每分钟请求上限（已认证按 JWT sub，匿名按 IP） | 可选 | 整数，代码默认 `100`，仓库 `appsettings.json` 配置为 `600` |
| `RateLimit:TrustXForwardedFor` | 匿名限流是否信任 `X-Forwarded-For` 首段作为客户端 IP | 可选 | 布尔，默认 `false`；反向代理部署时显式置 `true` |
| `RateLimit:Distributed` | 限流是否经 `IDistributedCache`（Redis）全局计数 | **多实例部署必配** | 布尔，默认 `false`；多实例必须置 `true`，否则各实例独立计数、配额被实例数放大；Redis 未配置时回退内存限流并告警一次 |
| `Idempotency:CacheHours` | 幂等响应缓存时长（小时） | 可选 | 整数，默认 `24` |
| `Cors:AllowedOrigins` | CORS 白名单源 | **生产环境必配** | 字符串数组，如 `["https://admin.example.com"]` |
| `Database:Provider` | 数据库 Provider | 可选 | `MySql`（默认）/ `PostgreSQL` / `SqlServer` / `Sqlite` / `Oracle` |
| `ConnectionStrings:MySql` | MySQL 连接串 | 生产环境必配 | 含 `SslMode=Require` 建议 |
| `ConnectionStrings:Redis` | Redis 连接串 | HA 部署必配 | - |
| `Smtp:Host` / `Smtp:Username` / `Smtp:Password` | SMTP 邮件发送 | 启用邮件通知时必配 | - |

### 9.2 生产环境必配项

| 配置项 | 缺失后果 | 配置方式 |
| --- | --- | --- |
| `LICENSEMANAGER_DATA_PROTECTION_KEY` | `SecretProtector.Initialize` 抛异常，应用启动失败 | 环境变量 |
| `Jwt:Secret` | `JwtSecretResolver.Resolve` 抛异常，应用启动失败 | 环境变量或 `appsettings.Local.json` |
| `Seed:AdminPassword` | `SeedService.ResolveAdminPassword` 抛异常，应用启动失败 | 环境变量或 `appsettings.Local.json` |
| `Cors:AllowedOrigins` 白名单 | CORS 回退到 `AllowAnyOrigin`，存在跨域攻击风险 | `appsettings.json` |
| 数据库连接串 | 数据持久化失败 | 环境变量或 `appsettings.Local.json` |

### 9.3 开发环境默认值

| 配置项 | 开发环境默认值 | 风险说明 |
| --- | --- | --- |
| `LICENSEMANAGER_DATA_PROTECTION_KEY` | 进程级随机密钥 | 进程重启后旧密文无法解密，仅适用于本地调试 |
| `Jwt:Secret` | 进程级随机密钥（48 字节，base64 编码） | 进程重启后旧 JWT 失效；记录 Critical 日志告警 |
| `Seed:AdminPassword` | `Admin@123456` | 占位密码，仅用于本地调试 |
| `Seed:AdminEmail` | `admin@licensemanager.cn` | 占位邮箱 |
| `RateLimit:PerMinute` | 代码默认 `100`（仓库 `appsettings.json` 配置为 `600`） | 适合本地单实例 |
| `RateLimit:TrustXForwardedFor` | `false` | 不信任 `X-Forwarded-For`，按直连 IP 限流；反向代理部署需显式开启 |
| `RateLimit:Distributed` | `false` | 本地单实例走内存限流；多实例部署必须开启（需配置 Redis） |
| CORS | `AllowAnyOrigin` | 开发环境允许任意源 |
| Swagger | 启用 | 仅 Development 环境 |
| HSTS | 未启用 | 仅 Production 环境启用 |
| `ForwardedHeaders` | 未启用 | 仅 Production 环境启用 |

### 9.4 本地配置覆盖

`Program.cs` 在启动时加载 `appsettings.Local.json`（`optional: true, reloadOnChange: true`），该文件已加入 `.gitignore`，用于本地开发凭据隔离：

```json
{
  "Seed": {
    "AdminEmail": "admin@licensemanager.cn",
    "AdminPassword": "YourLocalStrongPassword"
  },
  "Jwt": {
    "Secret": "YourLocalJwtSecretAtLeast32Bytes"
  },
  "ConnectionStrings": {
    "MySql": "Server=localhost;Port=3306;Database=license_manager;User=root;Password=...;SslMode=None"
  }
}
```

---

### 9.1 本版新增安全机制（2026-10）

- **短信验证码登录**：`send` 端点防枚举（手机号无论是否绑定均返回 `{sent:true}`，格式非法同样受理，格式校验结果不外泄）；验证码 5 分钟 TTL、单次消费、60 秒同号冷却、同号每小时 5 条 / 同 IP 每小时 30 条频控、连续 5 次错误作废（全部经 `IMemoryCache` 过期策略实现，无内存泄漏风险）；登录失败尝试接入既有登录锁定；`Sms:Provider` 非 `dev` 时启动 fail-fast。
- **SCIM 2.0 目录同步**：`/scim/v2` 走 `PublicPathRegistry` 白名单放行（IdP 无法携带管理员 JWT），控制器内 `IActionFilter` fail-closed 校验独立 `Scim:BearerToken`（SHA-256 摘要 + `FixedTimeEquals` 常量时间比较，未配置与令牌错误统一 401 文案避免信息泄露）；`userName` 不可变；`active=false` 与 DELETE 均为软操作（可恢复、可审计）。
- **特性开关**：`GET /v1/config/features` 为匿名端点但仅返回非敏感展示开关；管理端 PUT 校验键必须在内置目录内（防任意键写入），操作者身份随更新记录留痕。

## 10. 已知限制与改进计划

### 10.1 已知限制

| 编号 | 限制 | 影响 | 当前缓解 |
| --- | --- | --- | --- |
| L01 | （已修复）`AuthorizationMiddleware` 曾对 `/v1/admin/` 敏感操作仅要求已认证、未强制角色检查 | 已消除 | 已实现两级角色策略：`IsAdminOnlyPath`（18 个 admin-only 前缀）+ `IsOperatorUpPath`（25 个前缀拒绝 support），并支持 JWT `permissions` claim 权限点校验；ApiKey/Hmac 为系统级信任不受角色限制 |
| L02 | `Auth:AllowPublicActivation` 默认 `true`，激活/验证/心跳/反激活/refresh-file 生命周期端点公开访问 | 未携带凭据的客户端可调用上述生命周期端点 | 生产环境显式置 `false` 强制 ApiKey/Hmac/Bearer 认证；代码含 TODO 标注，待客户端 SDK 改造为 ApiKey/Hmac 后移除该开关 |
| L03 | 限流默认基于 `IMemoryCache`，单实例；`RateLimit:Distributed=true` 可切 Redis 全局计数（非原子 get/set 固定窗口） | 默认配置下多实例限流不互通；分布式模式极端并发下计数偏差数个请求 | 多实例部署显式配置 `RateLimit:Distributed=true` + Redis（未配置 Redis 时回退内存限流并告警一次）；幂等缓存 L04 仍为单实例 |
| L04 | 幂等缓存基于 `IMemoryCache`，单实例 | 多实例部署时相同 `Idempotency-Key` 在不同实例无法命中 | 限流已有 `RateLimit:Distributed` 分布式开关（L03），幂等缓存尚未提供对应开关，待引入 Redis 实现 |
| L05 | JWT 使用 HMAC-SHA256 对称签名 | 无法实现非对称密钥分离（签发与校验共用密钥） | 适用于当前架构；如需分离签发/校验权限可迁移到 RS256/ES256 |
| L06 | JWT 无内置密钥轮换机制 | 轮换 `Jwt:Secret` 会导致所有已签发令牌立即失效 | 通过 `SystemKeyService` 管理许可证签名密钥（RSA-2048）支持版本化轮换，但 JWT 本身未对接 |
| L07 | HMAC 认证的 `secret` 通过 `X-LicenseManager-Api-Key` 头传输 | 依赖 HTTPS 提供传输层保护；中间件层不加密 | 必须在反向代理层强制 HTTPS（`UseHsts` + `UseHttpsRedirection` 已启用） |
| L08 | `SecretProtector` 开发环境随机密钥进程级缓存 | 进程重启后旧密文无法解密 | 文档与日志 Warning 明确告知；测试环境容许重新加密 |
| L09 | `web/portal` 与 `web/partner` 使用 `localStorage` 存储 token | XSS 攻击后 token 暴露窗口较长 | Vue 默认转义 + 编码规范禁止 `v-html`；未来可切换到 `sessionStorage` 或引入刷新令牌机制 |
| L10 | `InMemoryRepository<T>` 用于多个生产路径的实体（`LoginAttempt`、`IpWhitelistEntry` 等） | 进程重启数据丢失 | 集成测试环境通过 EF Core InMemory Provider 持久化关键实体；生产部署需替换为 EF Core 关系型实现 |
| L11 | 登录失败锁定基于内存仓储 | 多实例部署时锁定状态不互通，可被绕过 | 同 L03，待替换为分布式存储 |
| L12 | `LoginSecurityService.IsLockedAsync` 每次列出全部 `LoginAttempt` 再过滤 | 数据量大时性能下降 | 当前数据量可控；未来应改为按 identifier 索引查询 |

### 10.2 改进计划

| 优先级 | 改进项 | 关联限制 |
| --- | --- | --- |
| P0 | 在 `AuthorizationMiddleware` 中实现基于角色与资源所有者的细粒度授权检查（角色级两级策略已完成，资源所有者检查待扩展） | L01（角色部分已修复） |
| P0 | 改造客户端 SDK 为 ApiKey/Hmac 认证，随后移除 `Auth:AllowPublicActivation` 开关（代码含 TODO 标注） | L02 |
| P1 | 限流已完成 Redis 分布式可选项（`RateLimit:Distributed`，见 §4.1）；幂等缓存仍待引入 Redis 分布式实现 | L03（限流部分已落地）、L04 |
| P1 | 分布式限流升级为原子计数（Redis `INCR` 或 Lua 脚本），消除非原子 get/set 的计数竞争 | L03 |
| P1 | JWT 支持密钥轮换与 kid（Key ID）头声明，对接 `SystemKeyService` | L05、L06 |
| P2 | 评估 `web/portal` 与 `web/partner` 切换到 `sessionStorage` 或引入刷新令牌机制 | L09 |
| P2 | `LoginSecurityService` 改为按 identifier 索引查询，并迁移到分布式存储 | L11、L12 |
| P2 | 引入 Content-Security-Policy 响应头（生产环境），禁止 inline script | 第 8.5 节 |
| P3 | 评估迁移到 ASP.NET Core 内置 `Microsoft.AspNetCore.Authentication.JwtBearer`，保留自定义中间件作为 fallback | L05、L06 |

---

## 11. 附录

### 11.1 中间件管道顺序

`Program.cs` 中间件注册顺序（从外到内）：

```
HttpsRedirection
  → CORS
    → MetricsCollectionMiddleware        // 指标采集
      → ExceptionHandlingMiddleware      // 异常屏蔽
        → RateLimitingMiddleware         // 限流
          → AuthenticationMiddleware   // 认证
            → AuthorizationMiddleware  // 授权
              → IdempotencyMiddleware        // 幂等（必须位于认证/授权之后，缓存键并入认证身份，防绕过）
                → ApiResponseWrapperMiddleware  // 响应包装
                  → Controllers
```

生产环境在 `HttpsRedirection` 之前还会启用 `UseHsts` 与 `UseForwardedHeaders`。

### 11.2 关键文件清单

| 模块 | 文件路径 |
| --- | --- |
| 中间件管道入口 | `src/LicenseManager.Api/Program.cs` |
| 认证中间件 | `src/LicenseManager.Api/Middleware/AuthenticationMiddleware.cs` |
| 授权中间件 | `src/LicenseManager.Api/Middleware/AuthorizationMiddleware.cs` |
| 异常处理 | `src/LicenseManager.Api/Middleware/ExceptionHandlingMiddleware.cs` |
| 限流 | `src/LicenseManager.Api/Middleware/RateLimitingMiddleware.cs` |
| 幂等 | `src/LicenseManager.Api/Middleware/IdempotencyMiddleware.cs` |
| JWT 签发 | `src/LicenseManager.Api/Services/JwtIssuer.cs` |
| JWT 密钥解析 | `src/LicenseManager.Api/Services/JwtSecretResolver.cs` |
| 凭据加密 | `src/LicenseManager.Platform/Services/SecretProtector.cs` |
| API Key 服务 | `src/LicenseManager.Platform/Services/ApiKeyService.cs` |
| 登录安全 | `src/LicenseManager.Platform/Services/LoginSecurityService.cs` |
| 审计服务 | `src/LicenseManager.Platform/Services/AuditService.cs` |
| 审计后台落库 | `src/LicenseManager.Platform/Services/AuditLogBackgroundWorker.cs` |
| 审计快照 | `src/LicenseManager.Platform/Services/AuditActorSnapshotService.cs` |
| 公开路径白名单 | `src/LicenseManager.Api/Middleware/PublicPathRegistry.cs` |
| 通知模板转义 | `src/LicenseManager.Platform/Services/NotificationService.cs` |
| 系统密钥轮换 | `src/LicenseManager.Platform/Services/SystemKeyService.cs` |
| 凭证轮换 | `src/LicenseManager.Platform/Services/CredentialRotationService.cs` |
| 默认账号种子 | `src/LicenseManager.Api/Services/SeedService.cs` |
| 登录控制器 | `src/LicenseManager.Api/Controllers/AuthController.cs` |
| Admin 前端认证 | `web/admin/src/stores/auth.ts` |
| Admin 前端路由守卫 | `web/admin/src/router/index.ts` |
| Admin 前端 HTTP 客户端 | `web/admin/src/api/client.ts` |
| Web Widget XSS 防护 | `web/widget/license-widget.js` |
| 应用配置 | `src/LicenseManager.Api/appsettings.json` |
| 本地配置覆盖 | `src/LicenseManager.Api/appsettings.Local.json`（已 gitignore） |

### 11.3 安全相关配置键汇总

| 配置键 | 默认值 | 说明 |
| --- | --- | --- |
| `Auth:Enabled` | `true` | 是否启用认证 |
| `Jwt:Secret` | （空） | JWT 签名密钥 |
| `RateLimit:PerMinute` | 代码默认 `100`（仓库配置 `600`） | 每分钟请求上限（已认证按 JWT sub，匿名按 IP） |
| `RateLimit:TrustXForwardedFor` | `false` | 匿名限流是否信任 `X-Forwarded-For` 首段作为客户端 IP |
| `Idempotency:CacheHours` | `24` | 幂等响应缓存时长（小时） |
| `Cors:AllowedOrigins` | `[]` | CORS 白名单源数组 |
| `Seed:AdminEmail` | `admin@licensemanager.cn` | 默认管理员邮箱 |
| `Seed:AdminPassword` | （开发环境 `Admin@123456`，生产环境必配） | 默认管理员密码 |
| `Database:Provider` | `MySql` | 数据库 Provider |
| `ConnectionStrings:MySql` | （空） | MySQL 连接串 |
| `ConnectionStrings:Redis` | （空） | Redis 连接串 |
| `Maintenance:Enabled` | `false` | 维护模式开关 |
| `Maintenance:Message` | `Scheduled maintenance.` | 维护模式提示信息 |
| `LICENSEMANAGER_DATA_PROTECTION_KEY` | （环境变量） | AES-GCM 加密密钥（base64 32 字节） |
