否则请求会在这里抛出 + * {@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不要使用 {@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业务服务不共享密钥,只通过 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; + +/** + * 开放平台接口的独立安全链。 + * + *必须与内部控制台的安全链分开,原因有两点:
+ *本链排在前面(@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与既有的「请求头 tenantId → Domain → 登录用户」取值顺序不同:开放链路只认令牌里的租户, + * 由 {@code OpenTenantInterceptor} 在请求进入 controller 之前写入、请求结束后清理。 + * {@code MybatisPlusConfig} 会优先读取这里,从而彻底屏蔽第三方通过请求头指定租户的可能。
+ * + * @author WebSoft + */ +public final class OpenTenantContext { + + private static final ThreadLocal路径带版本号 {@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刻意不复用内部的 {@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刻意不复用内部的 {@code PageResult}(字段是 {@code count}):对外接口是长期契约, + * 用独立结构可以避免以后内部字段调整波及第三方。
+ * + * @author WebSoft + */ +@Data +@Schema(name = "OpenPageResult", description = "开放接口分页结果") +public class OpenPageResult与内部链路共用 {@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用本地生成的 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()); + } + +}