efccb786fe
- 新增开放平台配置块 application.yml,配置 JWKS、公钥地址、issuer、audience 及接口前缀 - 新增 OpenPlatformSecurityConfig,独立安全链路匹配 /api/open/** 请求 - JwtAuthenticationFilter 增加 shouldNotFilter 跳过 /api/open/**,避免 RS256 验签异常 - 新增 OpenTenantContext 与拦截器,令牌中 tenant_id 作为唯一租户来源,防止伪造请求头 - MybatisPlusConfig 优先使用 OpenTenantContext 里的租户 ID,确保数据隔离正确 - sys_order 表显式在 OrderMapper.xml 中使用 tenant_id 条件过滤,支持开放链路多租户隔离 - 新增开放平台订单分页查询接口 /api/open/v1/order/page,使用独立参数与返回值结构 - 出参 OpenOrderVO 支持手机号脱敏,保障数据安全,且只暴露必要字段 - 调整 SecurityConfig 加 @Order(2),让位给开放链路的 SecurityConfig @Order(1) - TenantController.page 增加安全修复,未登录状态拒绝访问,防止匿名读取全部租户数据 - 新增开放平台专用异常处理及认证、权限拒绝响应,避免泄露内部异常信息 - 新增 pom 依赖 spring-boot-starter-oauth2-resource-server,用于 RS256 JWT 验签 - 完整开放平台对接文档 docs/OPEN_PLATFORM_INTEGRATION.md,涵盖设计理念与接口契约 - 增加 OpenOrderVO 单元测试,覆盖手机号脱敏、空列表处理等场景,提高可靠性
210 lines
9.2 KiB
Markdown
210 lines
9.2 KiB
Markdown
# 开放平台对接说明(server-api / gxwebsoft_core)
|
||
|
||
> 记录日期:2026-09-22 | 分支:`codex/open-platform-integration`
|
||
> 上游控制面:`base-api`(开放平台,负责应用、凭证、权限与令牌签发)
|
||
|
||
## 1. 背景
|
||
|
||
开放平台(`base-api`)只负责签发令牌,业务数据仍在业务服务里。第三方应用拿着
|
||
`base-api` 签发的 RS256 access token 调用业务接口时,业务服务需要自行完成:
|
||
|
||
1. 用 JWKS 公钥验签,校验 `iss` / `aud` / `exp`;
|
||
2. 校验令牌的 `scope` 是否包含接口所需权限;
|
||
3. 从令牌的 `tenant_id` 取租户,作为数据隔离条件。
|
||
|
||
本服务(`server.websoft.top`)在此之上新增了一条独立的**开放接口链路**,
|
||
对内控制台链路零影响。
|
||
|
||
## 2. 关键设计
|
||
|
||
### 2.1 两条安全链互不干扰
|
||
|
||
本服务原有 `SecurityConfig` 有两个特点,都不能被开放接口继承:
|
||
|
||
| 原有行为 | 位置 | 对开放接口的风险 |
|
||
| --- | --- | --- |
|
||
| `antMatchers(GET, "/**").permitAll()`,即所有 GET 在框架层放行 | `SecurityConfig` | 开放接口挂上去默认就是公开的 |
|
||
| 内部令牌是 HS256 对称密钥,由全局 `JwtAuthenticationFilter` 解析 | `JwtAuthenticationFilter` | 用内部密钥解析 RS256 令牌会抛 `UnsupportedJwtException` |
|
||
|
||
因此新增 `OpenPlatformSecurityConfig`(`@Order(1)`),只匹配 `/api/open/**`,
|
||
`anyRequest().authenticated()` 且无任何 `permitAll`;原 `SecurityConfig` 降为 `@Order(2)`。
|
||
`JwtAuthenticationFilter` 增加 `shouldNotFilter` 跳过 `/api/open/**`
|
||
(该过滤器是 `@Component`,会被 Spring Boot 额外注册为普通 Servlet Filter,
|
||
仅靠安全链隔离并不够)。
|
||
|
||
### 2.2 租户只来自令牌
|
||
|
||
原有租户取值顺序是「请求头 `tenantId` → 请求头 `Domain` → 登录用户」
|
||
(见 `MybatisPlusConfig` 与 `BaseController.getTenantId`),
|
||
意味着客户端可以用一个请求头指定租户。开放链路必须堵死这一点:
|
||
|
||
`OpenTenantInterceptor` 在请求进入 controller 前,从已验签的 JWT 取 `tenant_id`
|
||
写入 `OpenTenantContext`;`MybatisPlusConfig.getTenantId()` 以它作为**最高优先级**,
|
||
不再读取请求头。令牌没有 `tenant_id` 时直接返回 403,不回退。
|
||
|
||
### 2.3 订单表需要显式租户条件
|
||
|
||
`sys_order` 在 `MybatisPlusConfig` 的 `ignoreTable` 白名单里,**不受多租户插件管辖**,
|
||
内部接口是靠「非平台租户强制 `userId = 登录用户`」实现隔离的(按人而非按租户)。
|
||
因此 `OrderMapper.xml` 补了一条显式条件:
|
||
|
||
```xml
|
||
<if test="param.tenantId != null">
|
||
AND a.tenant_id = #{param.tenantId}
|
||
</if>
|
||
```
|
||
|
||
`PageParam` 会跳过名为 `tenantId` 的字段,该参数此前无人设置,所以对既有调用无影响。
|
||
|
||
## 3. 代码结构
|
||
|
||
```
|
||
com.gxwebsoft.openplatform
|
||
├─ config/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏
|
||
├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器
|
||
├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/**
|
||
├─ config/OpenPlatformWebMvcConfig.java 给 /api/open/** 挂租户拦截器
|
||
├─ context/OpenCaller.java 调用方身份(全部来自令牌)
|
||
├─ context/OpenTenantContext.java 开放链路租户 ThreadLocal
|
||
├─ web/OpenTenantInterceptor.java 绑定与清理租户上下文
|
||
├─ web/OpenPlatformAuthenticationEntryPoint.java 未认证响应(不含 error 字段)
|
||
├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段)
|
||
├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息
|
||
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
|
||
├─ constant/OpenScopes.java scope 常量
|
||
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
|
||
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
|
||
└─ controller/OpenOrderController.java /api/open/v1/order/page
|
||
```
|
||
|
||
既有文件改动:
|
||
|
||
| 文件 | 改动 |
|
||
| --- | --- |
|
||
| `pom.xml` | 新增 `spring-boot-starter-oauth2-resource-server` |
|
||
| `SecurityConfig` | 加 `@Order(2)` 让位给开放链 |
|
||
| `JwtAuthenticationFilter` | 加 `shouldNotFilter`,跳过 `/api/open/**` |
|
||
| `MybatisPlusConfig` | 租户取值优先读 `OpenTenantContext` |
|
||
| `OrderMapper.xml` | 补 `tenant_id` 查询条件 |
|
||
| `TenantController.page` | **安全修复**:未登录直接拒绝,见第 6 节 |
|
||
| `application.yml` | 新增 `open-platform.*` 配置块 |
|
||
|
||
## 4. 配置项
|
||
|
||
```yaml
|
||
open-platform:
|
||
enabled: true
|
||
jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks
|
||
issuer: https://base-api.websoft.top/api
|
||
audience: websoft-open-platform
|
||
path-prefix: /api/open
|
||
mask-sensitive: true
|
||
```
|
||
|
||
> **不要改用 `spring.security.oauth2.resourceserver.jwt.issuer-uri`**。
|
||
> 配置该项后 Spring Security 会在启动时请求 `{issuer}/.well-known/openid-configuration`
|
||
> 做 OIDC 发现,而 `base-api` 未提供该文档(实测返回 `{"code":401,"message":"请先登录"}`),
|
||
> 会导致启动直接失败。这里只配 JWKS 地址,`iss` / `aud` 用显式校验器声明。
|
||
|
||
本地联调时可临时指向自建 JWKS:
|
||
|
||
```bash
|
||
./mvnw spring-boot:run -Dspring-boot.run.arguments="\
|
||
--open-platform.jwk-set-uri=http://127.0.0.1:18999/jwks.json"
|
||
```
|
||
|
||
## 5. 接口契约
|
||
|
||
`GET /api/open/v1/order/page`
|
||
|
||
| 项 | 说明 |
|
||
| --- | --- |
|
||
| 认证 | `Authorization: Bearer <base-api accessToken>` |
|
||
| 权限 | scope 含 `order:read`,否则 403 |
|
||
| 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** |
|
||
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) |
|
||
| 过滤 | `orderNo`(模糊)、`type`、`orderStatus`、`payStatus`、`payType`、`createTimeStart`、`createTimeEnd` |
|
||
|
||
响应沿用平台约定(**HTTP 状态码固定 200**,业务结果看 `code`;不返回 `error` 字段):
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "操作成功",
|
||
"data": {
|
||
"list": [
|
||
{
|
||
"orderId": 1,
|
||
"orderNo": "1856321928276439040",
|
||
"orderStatus": 1,
|
||
"payStatus": true,
|
||
"payPrice": 100.00,
|
||
"phone": "138****0316",
|
||
"createTime": "2026-09-01 10:00:00"
|
||
}
|
||
],
|
||
"total": 6,
|
||
"page": 1,
|
||
"limit": 20
|
||
}
|
||
}
|
||
```
|
||
|
||
典型错误:
|
||
|
||
| code | message | 触发条件 |
|
||
| --- | --- | --- |
|
||
| 401 | 令牌缺失或无效 | 未带令牌、签名错误、已过期、`iss`/`aud` 不匹配 |
|
||
| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `order:read` |
|
||
| 403 | 令牌缺少租户信息,无法确定数据范围 | 令牌没有 `tenant_id` |
|
||
|
||
## 6. 随本次改动修复的既有问题
|
||
|
||
`TenantController.page`(`GET /api/system/tenant/page`)原先没有 `@PreAuthorize`,
|
||
而 `SecurityConfig` 对所有 GET 放行;未登录且未指定 `userId` 时会走「无过滤」分支,
|
||
**匿名即可分页读取全部租户**(含租户名称、编码、手机号)。已补登录校验:
|
||
未登录返回 `{"code":401,"message":"请先登录"}`。
|
||
|
||
登录页的多租户选择来自登录响应里的 `tenants` 列表(`loginBySelectTenant` 流程),
|
||
不依赖该接口,因此不影响登录。
|
||
|
||
> 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把
|
||
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
|
||
> 影响面较大,建议单独排期。
|
||
|
||
## 7. 如何新增一个开放接口
|
||
|
||
1. 在 `OpenScopes` 增加 scope 常量,并确保 `base-api` 的权限字典里有同名 scope;
|
||
2. 在 `controller` 下新建对外 controller,路径以 `/api/open/v1/` 开头;
|
||
3. 方法上加 `@PreAuthorize("hasAuthority('SCOPE_xxx')")`;
|
||
4. 入参使用**独立的白名单 DTO**,不要直接暴露内部 `XxxParam`;
|
||
5. 需要租户时用 `OpenTenantContext.getTenantId()`,不要从参数或请求头取;
|
||
6. 出参使用独立的 VO,不要直接返回实体;
|
||
7. 若目标表在 `MybatisPlusConfig` 的 `ignoreTable` 名单里(如 `sys_order`),
|
||
必须在 Mapper 里显式加租户条件。
|
||
|
||
## 8. 验证方式
|
||
|
||
单元测试(不依赖 base-api 在线):
|
||
|
||
```bash
|
||
./mvnw -o test -Dtest='OpenPlatformJwtValidationTest,OpenTenantBindingTest,OpenOrderVOTest'
|
||
```
|
||
|
||
覆盖:`iss`/`aud`/`exp` 三条校验规则、租户只来自令牌(伪造 `tenantId` 请求头无效)、
|
||
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
|
||
|
||
端到端(本地自建 JWKS,无需 base-api 凭证):
|
||
|
||
签发 `tenant_id` 分别为 A / B / 不存在的租户三枚令牌调用接口,
|
||
预期返回条数与数据库 `SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?`
|
||
完全一致,且伪造 `tenantId` 请求头不改变结果。
|
||
|
||
## 9. 待办
|
||
|
||
- [ ] `base-api` 的权限字典补充 `order:read` 等业务 scope,并在应用管理页可勾选;
|
||
- [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发);
|
||
- [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路);
|
||
- [ ] `SecurityConfig` 的 GET `/**` 白名单收口;
|
||
- [ ] 第二个业务服务出现时,把 `openplatform` 配置与拦截器抽成共享 starter。
|