From efccb786fe65adad7d51016426f70031b39d91e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Tue, 22 Sep 2026 03:08:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(open-platform):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E5=B9=B3=E5=8F=B0=E7=8B=AC=E7=AB=8B=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=8F=8A=E8=AE=A2=E5=8D=95=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增开放平台配置块 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 单元测试,覆盖手机号脱敏、空列表处理等场景,提高可靠性 --- docs/OPEN_PLATFORM_INTEGRATION.md | 209 ++++++++++++++++++ pom.xml | 6 + .../common/core/config/MybatisPlusConfig.java | 7 + .../security/JwtAuthenticationFilter.java | 13 ++ .../common/core/security/SecurityConfig.java | 2 + .../system/controller/TenantController.java | 15 +- .../common/system/mapper/xml/OrderMapper.xml | 6 + .../config/OpenPlatformJwtConfig.java | 69 ++++++ .../config/OpenPlatformProperties.java | 49 ++++ .../config/OpenPlatformSecurityConfig.java | 76 +++++++ .../config/OpenPlatformWebMvcConfig.java | 31 +++ .../openplatform/constant/OpenScopes.java | 18 ++ .../openplatform/context/OpenCaller.java | 83 +++++++ .../context/OpenTenantContext.java | 39 ++++ .../controller/OpenOrderController.java | 90 ++++++++ .../param/OpenOrderPageParam.java | 52 +++++ .../openplatform/vo/OpenOrderVO.java | 127 +++++++++++ .../openplatform/web/OpenPageResult.java | 45 ++++ .../web/OpenPlatformAccessDeniedHandler.java | 27 +++ .../OpenPlatformAuthenticationEntryPoint.java | 31 +++ .../web/OpenPlatformExceptionAdvice.java | 44 ++++ .../web/OpenTenantInterceptor.java | 46 ++++ src/main/resources/application.yml | 16 ++ .../openplatform/OpenOrderVOTest.java | 69 ++++++ .../OpenPlatformJwtValidationTest.java | 119 ++++++++++ .../openplatform/OpenTenantBindingTest.java | 111 ++++++++++ 26 files changed, 1395 insertions(+), 5 deletions(-) create mode 100644 docs/OPEN_PLATFORM_INTEGRATION.md create mode 100644 src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformJwtConfig.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformProperties.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformSecurityConfig.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformWebMvcConfig.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/constant/OpenScopes.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/context/OpenCaller.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/context/OpenTenantContext.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/param/OpenOrderPageParam.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/vo/OpenOrderVO.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/web/OpenPageResult.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAccessDeniedHandler.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAuthenticationEntryPoint.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformExceptionAdvice.java create mode 100644 src/main/java/com/gxwebsoft/openplatform/web/OpenTenantInterceptor.java create mode 100644 src/test/java/com/gxwebsoft/openplatform/OpenOrderVOTest.java create mode 100644 src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java create mode 100644 src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java diff --git a/docs/OPEN_PLATFORM_INTEGRATION.md b/docs/OPEN_PLATFORM_INTEGRATION.md new file mode 100644 index 0000000..583d5fa --- /dev/null +++ b/docs/OPEN_PLATFORM_INTEGRATION.md @@ -0,0 +1,209 @@ +# 开放平台对接说明(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 + + AND a.tenant_id = #{param.tenantId} + +``` + +`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 ` | +| 权限 | 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。 diff --git a/pom.xml b/pom.xml index 220a8ff..6f34d31 100644 --- a/pom.xml +++ b/pom.xml @@ -203,6 +203,12 @@ spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + io.jsonwebtoken diff --git a/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java b/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java index 2259142..7acc552 100644 --- a/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java @@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import com.gxwebsoft.common.core.utils.RedisUtil; +import com.gxwebsoft.openplatform.context.OpenTenantContext; import com.gxwebsoft.common.system.entity.User; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.LongValue; @@ -40,6 +41,12 @@ public class MybatisPlusConfig { @Override public Expression getTenantId() { + // 0 开放平台链路:租户只来自令牌,优先级最高 + // 必须放在最前面,否则第三方可以伪造 tenantId 请求头越过数据隔离 + Integer openTenantId = OpenTenantContext.getTenantId(); + if (openTenantId != null) { + return new LongValue(openTenantId); + } String tenantId; // 从请求头拿ID tenantId = request.getHeader("tenantId"); diff --git a/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java b/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java index 58f118e..9d23389 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java @@ -47,6 +47,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Resource private LoginRecordService loginRecordService; + /** + * 开放平台接口走独立的安全链与 RS256 验签,不能被本过滤器用内部 HS256 密钥解析。 + * + *

否则请求会在这里抛出 + * {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures} + * 并被包装成「请退出重新登录」。

+ */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + return uri != null && uri.startsWith("/api/open/"); + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java index a586eec..3f906d3 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java @@ -2,6 +2,7 @@ package com.gxwebsoft.common.core.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -20,6 +21,7 @@ import javax.annotation.Resource; * @since 2020-03-23 18:04:52 */ @Configuration +@Order(2) @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { diff --git a/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java b/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java index 0c5fb04..ec69f10 100644 --- a/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java +++ b/src/main/java/com/gxwebsoft/common/system/controller/TenantController.java @@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.gxwebsoft.common.core.Constants; import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.common.core.utils.CommonUtil; @@ -58,13 +59,17 @@ public class TenantController extends BaseController { @Operation(summary = "分页查询租户") @GetMapping("/page") public ApiResult> page(TenantParam param) { + // 安全修复:SecurityConfig 对所有 GET 放行,本方法原先在未登录且未指定 userId 时 + // 会走「无过滤」分支返回全部租户(含租户名称、编码、手机号)。这里补上登录校验。 + // 登录页的多租户选择走登录响应里的 tenants 列表,不依赖本接口,因此不影响登录流程。 + final User loginUser = getLoginUser(); + if (loginUser == null) { + return new ApiResult<>(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG); + } // 如果传了 all=true,查询全部租户;否则自动用当前登录用户的 userId if (param.getAll() == null || !param.getAll()) { - if (param.getUserId() == null) { - final User loginUser = getLoginUser(); - if (loginUser != null && loginUser.getUserId() != null) { - param.setUserId(loginUser.getUserId()); - } + if (param.getUserId() == null && loginUser.getUserId() != null) { + param.setUserId(loginUser.getUserId()); } } PageResult result = tenantService.pageRel(param); diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/xml/OrderMapper.xml b/src/main/java/com/gxwebsoft/common/system/mapper/xml/OrderMapper.xml index 44d7d8c..fc66ef7 100644 --- a/src/main/java/com/gxwebsoft/common/system/mapper/xml/OrderMapper.xml +++ b/src/main/java/com/gxwebsoft/common/system/mapper/xml/OrderMapper.xml @@ -8,6 +8,12 @@ FROM sys_order a LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id + + + AND a.tenant_id = #{param.tenantId} + AND a.order_id = #{param.orderId} diff --git a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformJwtConfig.java b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformJwtConfig.java new file mode 100644 index 0000000..1ad2cc4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformJwtConfig.java @@ -0,0 +1,69 @@ +package com.gxwebsoft.openplatform.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +import javax.annotation.Resource; + +/** + * 开放平台令牌校验配置。 + * + *

不要使用 {@code spring.security.oauth2.resourceserver.jwt.issuer-uri}:配置该项后 + * Spring Security 会在启动时请求 {@code {issuer}/.well-known/openid-configuration} 做 OIDC 发现, + * 而 base-api 并未提供该文档(会返回「请先登录」),启动会直接失败。这里只配置 JWKS 地址, + * issuer 与 audience 用显式校验器声明。

+ * + * @author WebSoft + */ +@Configuration +@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) +public class OpenPlatformJwtConfig { + + @Resource + private OpenPlatformProperties properties; + + @Bean + public JwtDecoder openPlatformJwtDecoder() { + // 只配置 JWKS:公钥懒加载,启动时不依赖 base-api 可达 + NimbusJwtDecoder decoder = NimbusJwtDecoder + .withJwkSetUri(properties.getJwkSetUri()) + .build(); + decoder.setJwtValidator(buildTokenValidator()); + return decoder; + } + + /** + * 构造令牌校验器:iss / exp / nbf 由 Spring Security 负责,aud 需要显式声明。 + * + *

公开出来是为了让单元测试可以直接用本地生成的 RSA 密钥验证校验规则, + * 不必依赖 base-api 在线。

+ */ + public OAuth2TokenValidator buildTokenValidator() { + return new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.getIssuer()), + audienceValidator()); + } + + /** + * aud 必须命中配置的受众,避免拿到其它系统的令牌来调业务接口。 + */ + private OAuth2TokenValidator audienceValidator() { + return jwt -> { + if (jwt.getAudience() != null && jwt.getAudience().contains(properties.getAudience())) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure( + new OAuth2Error("invalid_token", "令牌受众不正确", null)); + }; + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformProperties.java b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformProperties.java new file mode 100644 index 0000000..47ba515 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformProperties.java @@ -0,0 +1,49 @@ +package com.gxwebsoft.openplatform.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 开放平台(base-api)对接配置。 + * + *

业务服务不共享密钥,只通过 JWKS 拉取公钥本地验签,因此这里只有地址与校验参数。

+ * + * @author WebSoft + */ +@Data +@Component +@ConfigurationProperties(prefix = "open-platform") +public class OpenPlatformProperties { + + /** + * 是否启用开放接口链路。关闭后 /api/open/** 不再放行(会退回 internal 鉴权链路) + */ + private boolean enabled = true; + + /** + * base-api 的 JWKS 地址,资源服务启动时拉取并缓存公钥 + */ + private String jwkSetUri = "https://base-api.websoft.top/api/v1/oauth/jwks"; + + /** + * 令牌签发方,必须与 base-api 的 iss 声明完全一致 + */ + private String issuer = "https://base-api.websoft.top/api"; + + /** + * 令牌受众,必须与 base-api 的 aud 声明一致 + */ + private String audience = "websoft-open-platform"; + + /** + * 对外接口路径前缀,默认与 base-api 的约定保持一致 + */ + private String pathPrefix = "/api/open"; + + /** + * 是否对手机号等敏感字段脱敏后再返回给第三方,默认开启 + */ + private boolean maskSensitive = true; + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformSecurityConfig.java b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformSecurityConfig.java new file mode 100644 index 0000000..8b6aa2f --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformSecurityConfig.java @@ -0,0 +1,76 @@ +package com.gxwebsoft.openplatform.config; + +import com.gxwebsoft.openplatform.web.OpenPlatformAccessDeniedHandler; +import com.gxwebsoft.openplatform.web.OpenPlatformAuthenticationEntryPoint; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import javax.annotation.Resource; + +/** + * 开放平台接口的独立安全链。 + * + *

必须与内部控制台的安全链分开,原因有两点:

+ *
    + *
  1. 内部控制台用 HS256 的对称密钥,开放平台用 RS256 的非对称密钥, + * 同一条链上会互相干扰(表现为 + * {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures});
  2. + *
  3. 控制台的 {@code SecurityConfig} 为了兼容前台站点,把「所有 GET」放行了, + * 对外开放接口绝不能继承这个默认放行。
  4. + *
+ * + *

本链排在前面(@Order(1)),只匹配 {@code /api/open/**},其余请求仍由原有链路处理。

+ * + * @author WebSoft + */ +@Configuration +@Order(1) +@EnableWebSecurity +@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) +public class OpenPlatformSecurityConfig extends WebSecurityConfigurerAdapter { + + @Resource + private JwtDecoder openPlatformJwtDecoder; + @Resource + private OpenPlatformAccessDeniedHandler openPlatformAccessDeniedHandler; + @Resource + private OpenPlatformAuthenticationEntryPoint openPlatformAuthenticationEntryPoint; + @Resource + private OpenPlatformProperties properties; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.requestMatcher(new AntPathRequestMatcher(properties.getPathPrefix() + "/**")) + .authorizeRequests() + // 开放接口一律需要令牌,没有任何免登录路径 + .anyRequest().authenticated() + .and() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .csrf().disable() + .cors() + .and() + .logout().disable() + .headers().frameOptions().disable() + .and() + .exceptionHandling() + .accessDeniedHandler(openPlatformAccessDeniedHandler) + .authenticationEntryPoint(openPlatformAuthenticationEntryPoint) + .and() + .oauth2ResourceServer() + // 令牌解析失败(签名错误、已过期、格式不对)由 BearerTokenAuthenticationFilter + // 直接短路,不会经过上面的 exceptionHandling,必须在资源服务器上再配一次, + // 否则调用方收到的是「HTTP 401 + 空 body」,与平台约定的 200 + code 不一致。 + .authenticationEntryPoint(openPlatformAuthenticationEntryPoint) + .accessDeniedHandler(openPlatformAccessDeniedHandler) + .jwt().decoder(openPlatformJwtDecoder); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformWebMvcConfig.java b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformWebMvcConfig.java new file mode 100644 index 0000000..44d5973 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformWebMvcConfig.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.openplatform.config; + +import com.gxwebsoft.openplatform.web.OpenTenantInterceptor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.annotation.Resource; + +/** + * 开放接口的 WebMvc 配置:只给 /api/open/** 挂租户绑定拦截器。 + * + * @author WebSoft + */ +@Configuration +@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) +public class OpenPlatformWebMvcConfig implements WebMvcConfigurer { + + @Resource + private OpenPlatformProperties properties; + @Resource + private OpenTenantInterceptor openTenantInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(openTenantInterceptor) + .addPathPatterns(properties.getPathPrefix() + "/**"); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/constant/OpenScopes.java b/src/main/java/com/gxwebsoft/openplatform/constant/OpenScopes.java new file mode 100644 index 0000000..df7913c --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/constant/OpenScopes.java @@ -0,0 +1,18 @@ +package com.gxwebsoft.openplatform.constant; + +/** + * 开放平台权限标识(scope)。 + * + *

与 base-api 的权限字典、以及在管理后台勾选的权限一一对应。

+ * + * @author WebSoft + */ +public final class OpenScopes { + + /** 查询订单 */ + public static final String ORDER_READ = "order:read"; + + private OpenScopes() { + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/context/OpenCaller.java b/src/main/java/com/gxwebsoft/openplatform/context/OpenCaller.java new file mode 100644 index 0000000..49e7cab --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/context/OpenCaller.java @@ -0,0 +1,83 @@ +package com.gxwebsoft.openplatform.context; + +import lombok.Data; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 开放接口的调用方身份,全部来自 base-api 签发的 access token。 + * + *

租户、用户、权限一律以令牌为准,不接受任何请求参数或请求头覆盖。

+ * + * @author WebSoft + */ +@Data +public class OpenCaller implements Serializable { + private static final long serialVersionUID = 1L; + + /** 应用标识(client_id) */ + private String clientId; + + /** 应用所属租户,数据隔离的唯一依据 */ + private Integer tenantId; + + /** 终端用户 id,仅授权码模式携带 */ + private Long userId; + + /** 终端用户账号,仅授权码模式携带 */ + private String username; + + /** 授权模式:client_credentials / authorization_code */ + private String grantType; + + /** 已授予的权限 */ + private List scopes = Collections.emptyList(); + + /** + * 从 Spring Security 的认证对象解析调用方;非开放平台令牌返回 null。 + */ + public static OpenCaller from(Authentication authentication) { + if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) { + return null; + } + Jwt jwt = (Jwt) authentication.getPrincipal(); + OpenCaller caller = new OpenCaller(); + caller.setClientId(jwt.getClaimAsString("client_id")); + caller.setTenantId(toInteger(jwt.getClaim("tenant_id"))); + caller.setUserId(toLong(jwt.getClaim("user_id"))); + caller.setUsername(jwt.getClaimAsString("username")); + caller.setGrantType(jwt.getClaimAsString("grant_type")); + caller.setScopes(parseScopes(jwt.getClaimAsString("scope"))); + return caller; + } + + /** + * 令牌的 scope 是空格分隔的字符串 + */ + private static List parseScopes(String scope) { + if (scope == null || scope.trim().isEmpty()) { + return Collections.emptyList(); + } + List scopes = new ArrayList<>(); + for (String item : scope.trim().split("\\s+")) { + if (!item.isEmpty()) { + scopes.add(item); + } + } + return scopes; + } + + private static Integer toInteger(Object value) { + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static Long toLong(Object value) { + return value instanceof Number ? ((Number) value).longValue() : null; + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/context/OpenTenantContext.java b/src/main/java/com/gxwebsoft/openplatform/context/OpenTenantContext.java new file mode 100644 index 0000000..8da269f --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/context/OpenTenantContext.java @@ -0,0 +1,39 @@ +package com.gxwebsoft.openplatform.context; + +/** + * 开放接口链路的租户上下文。 + * + *

与既有的「请求头 tenantId → Domain → 登录用户」取值顺序不同:开放链路只认令牌里的租户, + * 由 {@code OpenTenantInterceptor} 在请求进入 controller 之前写入、请求结束后清理。 + * {@code MybatisPlusConfig} 会优先读取这里,从而彻底屏蔽第三方通过请求头指定租户的可能。

+ * + * @author WebSoft + */ +public final class OpenTenantContext { + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private OpenTenantContext() { + } + + public static void set(OpenCaller caller) { + HOLDER.set(caller); + } + + public static OpenCaller get() { + return HOLDER.get(); + } + + /** + * 当前开放调用的租户;不在开放链路中时为 null + */ + public static Integer getTenantId() { + OpenCaller caller = HOLDER.get(); + return caller == null ? null : caller.getTenantId(); + } + + public static void clear() { + HOLDER.remove(); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java b/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java new file mode 100644 index 0000000..a4b2de1 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java @@ -0,0 +1,90 @@ +package com.gxwebsoft.openplatform.controller; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.Order; +import com.gxwebsoft.common.system.param.OrderParam; +import com.gxwebsoft.common.system.service.OrderService; +import com.gxwebsoft.openplatform.config.OpenPlatformProperties; +import com.gxwebsoft.openplatform.constant.OpenScopes; +import com.gxwebsoft.openplatform.context.OpenTenantContext; +import com.gxwebsoft.openplatform.param.OpenOrderPageParam; +import com.gxwebsoft.openplatform.vo.OpenOrderVO; +import com.gxwebsoft.openplatform.web.OpenPageResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 开放平台 · 订单接口。 + * + *

路径带版本号 {@code /api/open/v1/}:对外接口是长期契约,发布后只增不改。

+ * + *

鉴权分两层:框架层由 {@code OpenPlatformSecurityConfig} 校验 RS256 令牌的签名与 iss/aud/exp; + * 权限层由 {@code @PreAuthorize} 校验 scope。租户由 {@code OpenTenantInterceptor} 从令牌落到上下文。

+ * + *

随 {@code open-platform.enabled} 一起装配:开关关闭时接口整体下线, + * 而不是退化到内部控制台的安全链(那条链对所有 GET 放行)。

+ * + * @author WebSoft + */ +@Tag(name = "开放接口-订单") +@RestController +@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) +@RequestMapping("/api/open/v1/order") +public class OpenOrderController { + + @Resource + private OrderService orderService; + @Resource + private OpenPlatformProperties properties; + + @PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.ORDER_READ + "')") + @Operation(summary = "分页查询本租户订单") + @GetMapping("/page") + public ApiResult> page(@AuthenticationPrincipal Jwt jwt, + OpenOrderPageParam query) { + // 租户只认令牌:拦截器已校验过 tenant_id 存在,这里不会拿到请求头里的值 + Integer tenantId = OpenTenantContext.getTenantId(); + + OrderParam param = new OrderParam(); + param.setPage(normalizePage(query.getPage())); + param.setLimit(normalizeLimit(query.getLimit())); + param.setTenantId(tenantId); + param.setOrderNo(StrUtil.trimToNull(query.getOrderNo())); + param.setType(query.getType()); + param.setOrderStatus(query.getOrderStatus()); + param.setPayStatus(query.getPayStatus()); + param.setPayType(query.getPayType()); + param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart())); + param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd())); + + PageResult result = orderService.pageRel(param); + List list = OpenOrderVO.from(result.getList(), properties.isMaskSensitive()); + return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG, + new OpenPageResult<>(list, result.getCount(), param.getPage(), param.getLimit())); + } + + private Long normalizePage(Long page) { + return (page == null || page < 1L) ? 1L : page; + } + + private Long normalizeLimit(Long limit) { + if (limit == null || limit < 1L) { + return 20L; + } + return Math.min(limit, OpenOrderPageParam.MAX_LIMIT); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/param/OpenOrderPageParam.java b/src/main/java/com/gxwebsoft/openplatform/param/OpenOrderPageParam.java new file mode 100644 index 0000000..457231f --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/param/OpenOrderPageParam.java @@ -0,0 +1,52 @@ +package com.gxwebsoft.openplatform.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 开放接口订单分页查询入参。 + * + *

刻意不复用内部的 {@code OrderParam}:后者带着 {@code userId}、{@code keywords}、{@code deleted} + * 等可被构造的字段,直接暴露给第三方等于把内部查询能力整体开放。这里只保留白名单字段, + * 且不提供 tenantId——租户只来自令牌。

+ * + * @author WebSoft + */ +@Data +@Schema(name = "OpenOrderPageParam", description = "开放接口订单分页查询参数") +public class OpenOrderPageParam implements Serializable { + private static final long serialVersionUID = 1L; + + /** 每页最大条数,防止第三方一次拉全量 */ + public static final long MAX_LIMIT = 100L; + + @Schema(description = "页码,从 1 开始", example = "1") + private Long page = 1L; + + @Schema(description = "每页数量,最大 100", example = "20") + private Long limit = 20L; + + @Schema(description = "订单编号,模糊匹配") + private String orderNo; + + @Schema(description = "订单类型,0产品 1插件") + private Integer type; + + @Schema(description = "订单状态,0未完成 1已完成 2已取消 ...") + private Integer orderStatus; + + @Schema(description = "是否已付款") + private Boolean payStatus; + + @Schema(description = "支付方式") + private Integer payType; + + @Schema(description = "下单时间起始,闭区间,格式 yyyy-MM-dd HH:mm:ss") + private String createTimeStart; + + @Schema(description = "下单时间结束,闭区间,格式 yyyy-MM-dd HH:mm:ss") + private String createTimeEnd; + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/vo/OpenOrderVO.java b/src/main/java/com/gxwebsoft/openplatform/vo/OpenOrderVO.java new file mode 100644 index 0000000..eb573b8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/vo/OpenOrderVO.java @@ -0,0 +1,127 @@ +package com.gxwebsoft.openplatform.vo; + +import com.gxwebsoft.common.system.entity.Order; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +/** + * 开放接口的订单出参。 + * + *

只暴露第三方确实需要的字段,避免把内部实体(含 adminUrl、menuParam、deleted 等)整体吐出去。

+ * + * @author WebSoft + */ +@Data +@Schema(name = "OpenOrder", description = "开放接口订单") +public class OpenOrderVO implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "订单id") + private Integer orderId; + + @Schema(description = "订单编号") + private String orderNo; + + @Schema(description = "订单类型,0产品 1插件") + private Integer type; + + @Schema(description = "下单渠道,0网站 1小程序 2其他") + private Integer channel; + + @Schema(description = "订单总额") + private BigDecimal totalPrice; + + @Schema(description = "优惠金额") + private BigDecimal reducePrice; + + @Schema(description = "实际付款金额") + private BigDecimal payPrice; + + @Schema(description = "退款金额") + private BigDecimal refundMoney; + + @Schema(description = "购买数量") + private Integer totalNum; + + @Schema(description = "支付方式") + private Integer payType; + + @Schema(description = "是否已付款") + private Boolean payStatus; + + @Schema(description = "订单状态") + private Integer orderStatus; + + @Schema(description = "第三方支付订单号") + private String transactionId; + + @Schema(description = "下单人姓名") + private String realName; + + @Schema(description = "下单人手机号") + private String phone; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "支付时间") + private Date payTime; + + @Schema(description = "退款时间") + private Date refundTime; + + @Schema(description = "下单时间") + private Date createTime; + + /** + * 实体转出参。 + * + * @param orders 订单列表 + * @param maskSensitive 是否对手机号脱敏(默认开启,避免把终端用户信息直接交给第三方) + */ + public static List from(List orders, boolean maskSensitive) { + if (orders == null || orders.isEmpty()) { + return Collections.emptyList(); + } + List list = new ArrayList<>(orders.size()); + for (Order order : orders) { + OpenOrderVO vo = new OpenOrderVO(); + vo.setOrderId(order.getOrderId()); + vo.setOrderNo(order.getOrderNo()); + vo.setType(order.getType()); + vo.setChannel(order.getChannel()); + vo.setTotalPrice(order.getTotalPrice()); + vo.setReducePrice(order.getReducePrice()); + vo.setPayPrice(order.getPayPrice()); + vo.setRefundMoney(order.getRefundMoney()); + vo.setTotalNum(order.getTotalNum()); + vo.setPayType(order.getPayType()); + vo.setPayStatus(order.getPayStatus()); + vo.setOrderStatus(order.getOrderStatus()); + vo.setTransactionId(order.getTransactionId()); + vo.setRealName(order.getRealName()); + vo.setPhone(maskSensitive ? maskPhone(order.getPhone()) : order.getPhone()); + vo.setComments(order.getComments()); + vo.setPayTime(order.getPayTime()); + vo.setRefundTime(order.getRefundTime()); + vo.setCreateTime(order.getCreateTime()); + list.add(vo); + } + return list; + } + + private static String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return phone; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/web/OpenPageResult.java b/src/main/java/com/gxwebsoft/openplatform/web/OpenPageResult.java new file mode 100644 index 0000000..93b3ca6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/web/OpenPageResult.java @@ -0,0 +1,45 @@ +package com.gxwebsoft.openplatform.web; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * 开放接口的分页返回结构。 + * + *

刻意不复用内部的 {@code PageResult}(字段是 {@code count}):对外接口是长期契约, + * 用独立结构可以避免以后内部字段调整波及第三方。

+ * + * @author WebSoft + */ +@Data +@Schema(name = "OpenPageResult", description = "开放接口分页结果") +public class OpenPageResult implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "当前页数据") + private List list = Collections.emptyList(); + + @Schema(description = "总记录数") + private Long total; + + @Schema(description = "当前页码,从 1 开始") + private Long page; + + @Schema(description = "每页数量") + private Long limit; + + public OpenPageResult() { + } + + public OpenPageResult(List list, Long total, Long page, Long limit) { + this.list = list == null ? Collections.emptyList() : list; + this.total = total; + this.page = page; + this.limit = limit; + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAccessDeniedHandler.java b/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAccessDeniedHandler.java new file mode 100644 index 0000000..8b95c44 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAccessDeniedHandler.java @@ -0,0 +1,27 @@ +package com.gxwebsoft.openplatform.web; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.utils.CommonUtil; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 开放接口权限不足响应:不返回 {@code error} 字段,避免泄露内部信息。 + * + * @author WebSoft + */ +@Component +public class OpenPlatformAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) + throws IOException { + CommonUtil.responseError(response, Constants.UNAUTHORIZED_CODE, "权限不足,请确认应用已获得该接口权限", null); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAuthenticationEntryPoint.java b/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAuthenticationEntryPoint.java new file mode 100644 index 0000000..636b0f5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAuthenticationEntryPoint.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.openplatform.web; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.utils.CommonUtil; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 开放接口未认证响应。 + * + *

与内部链路共用 {@code JwtAuthenticationEntryPoint} 的差别只有一个:不返回 {@code error} 字段。 + * 该字段承载的是异常 {@code toString()},会把内部类名与解析细节暴露给第三方。HTTP 状态码保持 200, + * 与 base-api 一致,调用方以 body 里的 {@code code} 判断成败。

+ * + * @author WebSoft + */ +@Component +public class OpenPlatformAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) + throws IOException { + CommonUtil.responseError(response, Constants.UNAUTHENTICATED_CODE, "令牌缺失或无效", null); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformExceptionAdvice.java b/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformExceptionAdvice.java new file mode 100644 index 0000000..06372e4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformExceptionAdvice.java @@ -0,0 +1,44 @@ +package com.gxwebsoft.openplatform.web; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.ApiResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 开放接口专用异常处理。 + * + *

只作用于 {@code com.gxwebsoft.openplatform} 下的 controller,且优先级高于全局的 + * {@code GlobalExceptionHandler},目的是不把 {@code error} 字段(异常 toString)返回给第三方

+ * + * @author WebSoft + */ +@RestControllerAdvice(basePackages = "com.gxwebsoft.openplatform") +@Order(Ordered.HIGHEST_PRECEDENCE) +public class OpenPlatformExceptionAdvice { + + private static final Logger logger = LoggerFactory.getLogger(OpenPlatformExceptionAdvice.class); + + @ExceptionHandler(BusinessException.class) + public ApiResult businessExceptionHandler(BusinessException e) { + return new ApiResult<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ApiResult accessDeniedExceptionHandler(AccessDeniedException e) { + return new ApiResult<>(Constants.UNAUTHORIZED_CODE, "权限不足,请确认应用已获得该接口权限"); + } + + @ExceptionHandler(Throwable.class) + public ApiResult exceptionHandler(Throwable e) { + logger.error("开放接口处理失败: {}", e.getMessage(), e); + return new ApiResult<>(Constants.RESULT_ERROR_CODE, Constants.RESULT_ERROR_MSG); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/web/OpenTenantInterceptor.java b/src/main/java/com/gxwebsoft/openplatform/web/OpenTenantInterceptor.java new file mode 100644 index 0000000..94a4f55 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/web/OpenTenantInterceptor.java @@ -0,0 +1,46 @@ +package com.gxwebsoft.openplatform.web; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.openplatform.context.OpenCaller; +import com.gxwebsoft.openplatform.context.OpenTenantContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * 开放接口的租户绑定拦截器。 + * + *

令牌验签由 Spring Security 的 resource server 完成,这里只负责把 {@code tenant_id} + * 落到线程上下文,供 ORM 租户插件与业务代码使用。令牌没有租户时直接拒绝, + * 不回退到请求头,避免第三方通过 {@code tenantId} 头越权访问其它租户数据。

+ * + * @author WebSoft + */ +@Component +public class OpenTenantInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + OpenCaller caller = OpenCaller.from(SecurityContextHolder.getContext().getAuthentication()); + if (caller == null) { + throw new BusinessException(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG); + } + if (caller.getTenantId() == null) { + throw new BusinessException(Constants.UNAUTHORIZED_CODE, "令牌缺少租户信息,无法确定数据范围"); + } + OpenTenantContext.set(caller); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, + Exception ex) { + // 线程复用,必须清理,否则租户会串到下一个请求 + OpenTenantContext.clear(); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index feef8aa..b549463 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -168,3 +168,19 @@ certificate: alipay-cert-public-key-file: "alipayCertPublicKey.crt" alipay-root-cert-file: "alipayRootCert.crt" +# 开放平台(base-api)对接配置 +# 业务服务只做本地验签,不共享密钥;/api/open/** 走独立安全链。 +open-platform: + enabled: true + # base-api 的 JWKS 公钥地址 + # 注意:不要改用 spring.security.oauth2.resourceserver.jwt.issuer-uri, + # Spring 会去做 OIDC 发现(base-api 没有该文档)导致启动失败。 + jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks + # 必须与 base-api 令牌里的 iss 完全一致 + issuer: https://base-api.websoft.top/api + # 必须与 base-api 令牌里的 aud 一致 + audience: websoft-open-platform + # 对外接口前缀,版本号由 controller 的 @RequestMapping 决定 + path-prefix: /api/open + # 返回给第三方前是否对手机号脱敏 + mask-sensitive: true diff --git a/src/test/java/com/gxwebsoft/openplatform/OpenOrderVOTest.java b/src/test/java/com/gxwebsoft/openplatform/OpenOrderVOTest.java new file mode 100644 index 0000000..ec48e7e --- /dev/null +++ b/src/test/java/com/gxwebsoft/openplatform/OpenOrderVOTest.java @@ -0,0 +1,69 @@ +package com.gxwebsoft.openplatform; + +import com.gxwebsoft.common.system.entity.Order; +import com.gxwebsoft.openplatform.vo.OpenOrderVO; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 开放接口订单出参的字段裁剪与脱敏测试。 + * + * @author WebSoft + */ +class OpenOrderVOTest { + + private Order order() { + Order order = new Order(); + order.setOrderId(1); + order.setOrderNo("1234567890"); + order.setPhone("13800000009"); + order.setRealName("张三"); + order.setComments("内部备注"); + return order; + } + + @Test + @DisplayName("默认对手机号脱敏") + void masksPhoneByDefault() { + List list = OpenOrderVO.from(List.of(order()), true); + + assertEquals(1, list.size()); + assertEquals("138****0009", list.get(0).getPhone()); + assertEquals("张三", list.get(0).getRealName()); + } + + @Test + @DisplayName("关闭脱敏后返回原号码") + void keepsPhoneWhenMaskingDisabled() { + List list = OpenOrderVO.from(List.of(order()), false); + + assertEquals("13800000009", list.get(0).getPhone()); + } + + @Test + @DisplayName("手机号异常长度时原样返回,不抛异常") + void handlesShortPhone() { + Order order = order(); + order.setPhone("123"); + assertEquals("123", OpenOrderVO.from(List.of(order), true).get(0).getPhone()); + + order.setPhone(null); + assertNull(OpenOrderVO.from(List.of(order), true).get(0).getPhone()); + } + + @Test + @DisplayName("空列表返回空集合而不是 null") + void handlesEmptyList() { + assertTrue(OpenOrderVO.from(null, true).isEmpty()); + assertTrue(OpenOrderVO.from(List.of(), true).isEmpty()); + assertNotNull(OpenOrderVO.from(List.of(order()), true).get(0)); + } + +} diff --git a/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java b/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java new file mode 100644 index 0000000..a8bbcfa --- /dev/null +++ b/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java @@ -0,0 +1,119 @@ +package com.gxwebsoft.openplatform; + +import com.gxwebsoft.openplatform.config.OpenPlatformJwtConfig; +import com.gxwebsoft.openplatform.config.OpenPlatformProperties; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * 开放平台令牌校验规则测试。 + * + *

用本地生成的 RSA 密钥自行签发令牌,验证 iss / aud / exp 三条规则是否真的生效, + * 不依赖 base-api 在线(JWKS 地址在这里不会被访问)。

+ * + * @author WebSoft + */ +class OpenPlatformJwtValidationTest { + + private static final String ISSUER = "https://base-api.websoft.top/api"; + private static final String AUDIENCE = "websoft-open-platform"; + + private static KeyPair keyPair; + + @BeforeAll + static void generateKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + keyPair = generator.generateKeyPair(); + } + + private JwtDecoder decoder() { + OpenPlatformProperties properties = new OpenPlatformProperties(); + properties.setIssuer(ISSUER); + properties.setAudience(AUDIENCE); + OpenPlatformJwtConfig config = new OpenPlatformJwtConfig(); + ReflectionTestUtils.setField(config, "properties", properties); + + NimbusJwtDecoder decoder = NimbusJwtDecoder + .withPublicKey((RSAPublicKey) keyPair.getPublic()) + .build(); + decoder.setJwtValidator(config.buildTokenValidator()); + return decoder; + } + + private String sign(String issuer, String audience, Instant expiresAt) throws Exception { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(issuer) + .audience(audience) + .subject("demo-app") + .claim("client_id", "demo-app") + .claim("tenant_id", 1001) + .claim("scope", "order:read") + .issueTime(Date.from(Instant.now().minusSeconds(30))) + .expirationTime(Date.from(expiresAt)) + .build(); + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claims); + jwt.sign(new RSASSASigner((RSAPrivateKey) keyPair.getPrivate())); + return jwt.serialize(); + } + + @Test + @DisplayName("iss/aud/exp 都正确时验签通过,并保留业务声明") + void acceptsValidToken() throws Exception { + String token = sign(ISSUER, AUDIENCE, Instant.now().plusSeconds(300)); + + Jwt jwt = decoder().decode(token); + + assertNotNull(jwt); + assertEquals("demo-app", jwt.getClaimAsString("client_id")); + assertEquals(1001, ((Number) jwt.getClaim("tenant_id")).intValue()); + assertEquals("order:read", jwt.getClaimAsString("scope")); + } + + @Test + @DisplayName("签发方不一致时拒绝,防止拿其它系统的令牌调业务接口") + void rejectsWrongIssuer() throws Exception { + String token = sign("https://someone-else.example.com", AUDIENCE, Instant.now().plusSeconds(300)); + + assertThrows(JwtException.class, () -> decoder().decode(token)); + } + + @Test + @DisplayName("受众不一致时拒绝") + void rejectsWrongAudience() throws Exception { + String token = sign(ISSUER, "another-platform", Instant.now().plusSeconds(300)); + + assertThrows(JwtException.class, () -> decoder().decode(token)); + } + + @Test + @DisplayName("令牌过期后拒绝") + void rejectsExpiredToken() throws Exception { + String token = sign(ISSUER, AUDIENCE, Instant.now().minusSeconds(60)); + + assertThrows(JwtException.class, () -> decoder().decode(token)); + } + +} diff --git a/src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java b/src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java new file mode 100644 index 0000000..8fc93bb --- /dev/null +++ b/src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java @@ -0,0 +1,111 @@ +package com.gxwebsoft.openplatform; + +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.openplatform.context.OpenCaller; +import com.gxwebsoft.openplatform.context.OpenTenantContext; +import com.gxwebsoft.openplatform.web.OpenTenantInterceptor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 开放接口的租户绑定与上下文清理测试。 + * + *

租户隔离是这套改造的核心,这里覆盖三件事:从令牌取租户、令牌缺租户时拒绝、 + * 请求结束后必须清理 ThreadLocal(否则线程复用会把租户串到下一个请求)。

+ * + * @author WebSoft + */ +class OpenTenantBindingTest { + + private final OpenTenantInterceptor interceptor = new OpenTenantInterceptor(); + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + OpenTenantContext.clear(); + } + + private Jwt jwtWithTenant(Object tenantId) { + Jwt.Builder builder = Jwt.withTokenValue("test-token") + .header("alg", "RS256") + .claim("client_id", "demo-app") + .claim("scope", "order:read order:write"); + if (tenantId != null) { + builder.claim("tenant_id", tenantId); + } + return builder.build(); + } + + @Test + @DisplayName("绑定的租户来自令牌,而不是请求头") + void tenantComesFromTokenNotHeader() { + SecurityContextHolder.getContext() + .setAuthentication(new JwtAuthenticationToken(jwtWithTenant(1001))); + MockHttpServletRequest request = new MockHttpServletRequest(); + // 第三方伪造的租户头 + request.addHeader("tenantId", "9"); + + interceptor.preHandle(request, new MockHttpServletResponse(), new Object()); + + assertEquals(1001, OpenTenantContext.getTenantId()); + assertEquals("demo-app", OpenTenantContext.get().getClientId()); + assertEquals(List.of("order:read", "order:write"), OpenTenantContext.get().getScopes()); + } + + @Test + @DisplayName("令牌没有租户信息时直接拒绝,不回退到请求头") + void rejectsTokenWithoutTenant() { + SecurityContextHolder.getContext() + .setAuthentication(new JwtAuthenticationToken(jwtWithTenant(null))); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("tenantId", "9"); + + BusinessException e = assertThrows(BusinessException.class, + () -> interceptor.preHandle(request, new MockHttpServletResponse(), new Object())); + + assertEquals(403, e.getCode()); + assertNull(OpenTenantContext.getTenantId()); + } + + @Test + @DisplayName("请求结束后清理上下文,避免租户串到下一个请求") + void clearsContextAfterCompletion() { + SecurityContextHolder.getContext() + .setAuthentication(new JwtAuthenticationToken(jwtWithTenant(1001))); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + interceptor.preHandle(request, response, new Object()); + assertEquals(1001, OpenTenantContext.getTenantId()); + + interceptor.afterCompletion(request, response, new Object(), null); + + assertNull(OpenTenantContext.get()); + } + + @Test + @DisplayName("无认证对象时不构成调用方") + void noAuthenticationMeansNoCaller() { + assertNull(OpenCaller.from(null)); + } + + @Test + @DisplayName("scope 为空时不产生权限") + void parsesEmptyScopes() { + Jwt jwt = Jwt.withTokenValue("t").header("alg", "RS256").claim("scope", "").build(); + assertTrue(OpenCaller.from(new JwtAuthenticationToken(jwt)).getScopes().isEmpty()); + } + +}