diff --git a/docs/OPEN_PLATFORM_INTEGRATION.md b/docs/OPEN_PLATFORM_INTEGRATION.md index 591b838..b990590 100644 --- a/docs/OPEN_PLATFORM_INTEGRATION.md +++ b/docs/OPEN_PLATFORM_INTEGRATION.md @@ -59,7 +59,19 @@ ## 3. 代码结构 ``` +本服务的业务侧(留在本仓库) com.gxwebsoft.openplatform +├─ constant/OpenScopes.java scope 常量 +├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId) +├─ param/OpenUserPageParam.java 用户查询入参白名单 +├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏 +├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段 +├─ controller/OpenOrderController.java /api/open/v1/order/page +└─ controller/OpenUserController.java /api/open/v1/user/page + +平台层(已迁出到独立仓库 websoft-platform,作为构件依赖引入) +com.gxwebsoft.platform.openapi +├─ OpenApi 标记注解,异常处理器按它匹配 ├─ config/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏 ├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器 ├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/** @@ -71,15 +83,20 @@ com.gxwebsoft.openplatform ├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段) ├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息 ├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit} -├─ constant/OpenScopes.java scope 常量 -├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId) -├─ param/OpenUserPageParam.java 用户查询入参白名单 -├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏 -├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段 -├─ controller/OpenOrderController.java /api/open/v1/order/page -└─ controller/OpenUserController.java /api/open/v1/user/page +└─ web/JsonResponseWriter.java 响应写出(不依赖业务侧 CommonUtil) ``` +> **迁移说明(2026-09-22)**:上述平台层文件原先以源码形式放在本仓库的 +> `com.gxwebsoft.openplatform.{config,context,web}` 下,现已抽到独立仓库 +> `websoft-platform` 并作为 `websoft-platform-openapi` 构件依赖引入。 +> 内核 `Constants` / `ApiResult` / `PageResult` / `BusinessException` 同样已迁移, +> 但**包名保持不变**,所以本服务其余代码一行 import 都不用改。 +> +> 对外开放的 controller 必须加 `@OpenApi` 注解——平台层的异常处理器按注解匹配, +> 漏加会导致第三方收到带 `error` 字段(内部异常信息)的响应。 +> +> 详见 `websoft-platform/docs/PLATFORM_LAYER.md`。 + 既有文件改动: | 文件 | 改动 | diff --git a/pom.xml b/pom.xml index 1a63160..d0428a0 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,8 @@ 17 UTF-8 UTF-8 + + 1.0.0 @@ -203,10 +205,19 @@ spring-boot-starter-security - + - org.springframework.boot - spring-boot-starter-oauth2-resource-server + com.gxwebsoft + websoft-platform-openapi + ${websoft-platform.version} diff --git a/src/main/java/com/gxwebsoft/common/core/Constants.java b/src/main/java/com/gxwebsoft/common/core/Constants.java deleted file mode 100644 index be48387..0000000 --- a/src/main/java/com/gxwebsoft/common/core/Constants.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.gxwebsoft.common.core; - -/** - * 系统常量 - * Created by WebSoft on 2019-10-29 15:55 - */ -public class Constants { - /** - * 默认成功码 - */ - public static final int RESULT_OK_CODE = 0; - - /** - * 默认失败码 - */ - public static final int RESULT_ERROR_CODE = 1; - - /** - * 默认成功信息 - */ - public static final String RESULT_OK_MSG = "操作成功"; - - /** - * 默认失败信息 - */ - public static final String RESULT_ERROR_MSG = "操作失败"; - - /** - * 无权限错误码 - */ - public static final int UNAUTHORIZED_CODE = 403; - - /** - * 无权限提示信息 - */ - public static final String UNAUTHORIZED_MSG = "没有访问权限"; - - /** - * 未认证错误码 - */ - public static final int UNAUTHENTICATED_CODE = 401; - - /** - * 未认证提示信息 - */ - public static final String UNAUTHENTICATED_MSG = "请先登录"; - - /** - * 登录过期错误码 - */ - public static final int TOKEN_EXPIRED_CODE = 401; - - /** - * 登录过期提示信息 - */ - public static final String TOKEN_EXPIRED_MSG = "登录已过期"; - - /** - * 非法token错误码 - */ - public static final int BAD_CREDENTIALS_CODE = 401; - - /** - * 非法token提示信息 - */ - public static final String BAD_CREDENTIALS_MSG = "请退出重新登录"; - - /** - * 表示升序的值 - */ - public static final String ORDER_ASC_VALUE = "asc"; - - /** - * 表示降序的值 - */ - public static final String ORDER_DESC_VALUE = "desc"; - - /** - * token通过header传递的名称 - */ - public static final String TOKEN_HEADER_NAME = "Authorization"; - - /** - * token通过参数传递的名称 - */ - public static final String TOKEN_PARAM_NAME = "access_token"; - - /** - * token认证类型 - */ - public static final String TOKEN_TYPE = "Bearer"; - -} 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 7acc552..adfea12 100644 --- a/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/config/MybatisPlusConfig.java @@ -7,7 +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.platform.openapi.context.OpenTenantContext; import com.gxwebsoft.common.system.entity.User; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.LongValue; diff --git a/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java b/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java deleted file mode 100644 index 8e10e82..0000000 --- a/src/main/java/com/gxwebsoft/common/core/exception/BusinessException.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.gxwebsoft.common.core.exception; - -import com.gxwebsoft.common.core.Constants; - -/** - * 自定义业务异常 - * - * @author WebSoft - * @since 2018-02-22 11:29:28 - */ -public class BusinessException extends RuntimeException { - private static final long serialVersionUID = 1L; - - private Integer code; - - public BusinessException() { - this(Constants.RESULT_ERROR_MSG); - } - - public BusinessException(String message) { - this(Constants.RESULT_ERROR_CODE, message); - } - - public BusinessException(Integer code, String message) { - super(message); - this.code = code; - } - - public BusinessException(Integer code, String message, Throwable cause) { - super(message, cause); - this.code = code; - } - - public BusinessException(Integer code, String message, Throwable cause, - boolean enableSuppression, boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); - this.code = code; - } - - public Integer getCode() { - return code; - } - - public void setCode(Integer code) { - this.code = code; - } - -} diff --git a/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java b/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java deleted file mode 100644 index 0313839..0000000 --- a/src/main/java/com/gxwebsoft/common/core/web/ApiResult.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.gxwebsoft.common.core.web; - -import com.fasterxml.jackson.annotation.JsonInclude; -import io.swagger.v3.oas.annotations.media.Schema; - -import java.io.Serializable; - -/** - * 返回结果 - * - * @author WebSoft - * @since 2017-06-10 10:10:50 - */ -@JsonInclude(JsonInclude.Include.NON_NULL) -public class ApiResult implements Serializable { - private static final long serialVersionUID = 1L; - - @Schema(description = "状态码") - private Integer code; - - @Schema(description = "状态信息") - private String message; - - @Schema(description = "返回数据") - private T data; - - @Schema(description = "错误信息") - private String error; - - public ApiResult() {} - - public ApiResult(Integer code) { - this(code, null); - } - - public ApiResult(Integer code, String message) { - this(code, message, null); - } - - public ApiResult(Integer code, String message, T data) { - this(code, message, data, null); - } - - public ApiResult(Integer code, String message, T data, String error) { - setCode(code); - setMessage(message); - setData(data); - setError(error); - } - - public Integer getCode() { - return this.code; - } - - public ApiResult setCode(Integer code) { - this.code = code; - return this; - } - - public String getMessage() { - return this.message; - } - - public ApiResult setMessage(String message) { - this.message = message; - return this; - } - - public T getData() { - return this.data; - } - - public ApiResult setData(T data) { - this.data = data; - return this; - } - - public String getError() { - return this.error; - } - - public ApiResult setError(String error) { - this.error = error; - return this; - } - -} diff --git a/src/main/java/com/gxwebsoft/common/core/web/PageResult.java b/src/main/java/com/gxwebsoft/common/core/web/PageResult.java deleted file mode 100644 index a9bc057..0000000 --- a/src/main/java/com/gxwebsoft/common/core/web/PageResult.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.gxwebsoft.common.core.web; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.io.Serializable; -import java.util.List; - -/** - * 分页查询返回结果 - * - * @author WebSoft - * @since 2017-06-10 10:10:02 - */ -public class PageResult implements Serializable { - private static final long serialVersionUID = 1L; - - @Schema(description = "当前页数据") - private List list; - - @Schema(description = "总数量") - private Long count; - - public PageResult() { - } - - public PageResult(List list) { - this(list, null); - } - - public PageResult(List list, Long count) { - setList(list); - setCount(count); - } - - public List getList() { - return this.list; - } - - public void setList(List list) { - this.list = list; - } - - public Long getCount() { - return this.count; - } - - public void setCount(Long count) { - this.count = count; - } - -} diff --git a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformJwtConfig.java b/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformJwtConfig.java deleted file mode 100644 index 1ad2cc4..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformJwtConfig.java +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 47ba515..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformProperties.java +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 8b6aa2f..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformSecurityConfig.java +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 44d5973..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/config/OpenPlatformWebMvcConfig.java +++ /dev/null @@ -1,31 +0,0 @@ -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/context/OpenCaller.java b/src/main/java/com/gxwebsoft/openplatform/context/OpenCaller.java deleted file mode 100644 index 49e7cab..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/context/OpenCaller.java +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 8da269f..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/context/OpenTenantContext.java +++ /dev/null @@ -1,39 +0,0 @@ -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 index 2bc393f..17f89cc 100644 --- a/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java +++ b/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java @@ -7,12 +7,13 @@ 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.platform.openapi.OpenApi; +import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties; import com.gxwebsoft.openplatform.constant.OpenScopes; -import com.gxwebsoft.openplatform.context.OpenTenantContext; +import com.gxwebsoft.platform.openapi.context.OpenTenantContext; import com.gxwebsoft.openplatform.param.OpenOrderPageParam; import com.gxwebsoft.openplatform.vo.OpenOrderVO; -import com.gxwebsoft.openplatform.web.OpenPageResult; +import com.gxwebsoft.platform.openapi.web.OpenPageResult; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -40,6 +41,7 @@ import java.util.List; * @author WebSoft */ @Tag(name = "开放接口-订单") +@OpenApi @RestController @ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) @RequestMapping("/api/open/v1/order") diff --git a/src/main/java/com/gxwebsoft/openplatform/controller/OpenUserController.java b/src/main/java/com/gxwebsoft/openplatform/controller/OpenUserController.java index 0c86b25..72a8923 100644 --- a/src/main/java/com/gxwebsoft/openplatform/controller/OpenUserController.java +++ b/src/main/java/com/gxwebsoft/openplatform/controller/OpenUserController.java @@ -7,12 +7,13 @@ import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.param.UserParam; import com.gxwebsoft.common.system.service.UserService; -import com.gxwebsoft.openplatform.config.OpenPlatformProperties; +import com.gxwebsoft.platform.openapi.OpenApi; +import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties; import com.gxwebsoft.openplatform.constant.OpenScopes; -import com.gxwebsoft.openplatform.context.OpenTenantContext; +import com.gxwebsoft.platform.openapi.context.OpenTenantContext; import com.gxwebsoft.openplatform.param.OpenUserPageParam; import com.gxwebsoft.openplatform.vo.OpenUserVO; -import com.gxwebsoft.openplatform.web.OpenPageResult; +import com.gxwebsoft.platform.openapi.web.OpenPageResult; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -34,6 +35,7 @@ import java.util.List; * @author WebSoft */ @Tag(name = "开放接口-用户") +@OpenApi @RestController @ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) @RequestMapping("/api/open/v1/user") diff --git a/src/main/java/com/gxwebsoft/openplatform/web/OpenPageResult.java b/src/main/java/com/gxwebsoft/openplatform/web/OpenPageResult.java deleted file mode 100644 index 93b3ca6..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/web/OpenPageResult.java +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 8b95c44..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAccessDeniedHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 636b0f5..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformAuthenticationEntryPoint.java +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 06372e4..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/web/OpenPlatformExceptionAdvice.java +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 94a4f55..0000000 --- a/src/main/java/com/gxwebsoft/openplatform/web/OpenTenantInterceptor.java +++ /dev/null @@ -1,46 +0,0 @@ -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/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java b/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java deleted file mode 100644 index 47fd0b5..0000000 --- a/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java +++ /dev/null @@ -1,119 +0,0 @@ -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", "shop:shopOrder:list") - .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("shop:shopOrder:list", 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 deleted file mode 100644 index 1c2217e..0000000 --- a/src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java +++ /dev/null @@ -1,112 +0,0 @@ -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", "shop:shopOrder:list shop:shopOrder:save"); - 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("shop:shopOrder:list", "shop:shopOrder:save"), - 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()); - } - -}