refactor(platform): 移除旧版开放平台模块并迁移至独立仓库
- 删除api返回结果封装(ApiResult)、业务异常(BusinessException)、系统常量(Constants)等基础模块代码 - 移除开放平台上下文(OpenCaller)、租户上下文(OpenTenantContext)及相关拦截器等实现 - 删除开放平台安全配置、安全异常处理、JWT校验配置和相关测试用例 - 调整 MybatisPlusConfig 中对 OpenTenantContext 包路径的引用 - 将开放平台controller、参数、视图、配置、拦截器等迁移至新的独立仓库包名 com.gxwebsoft.platform.openapi - 开放接口controller添加 @OpenApi 注解用于异常处理匹配 - 更新文档,补充开放平台模块移除和迁移说明,指导后续开发维护 - 保持包名兼容,确保遗留代码引用不变,无需做import改动
This commit is contained in:
@@ -59,7 +59,19 @@
|
|||||||
## 3. 代码结构
|
## 3. 代码结构
|
||||||
|
|
||||||
```
|
```
|
||||||
|
本服务的业务侧(留在本仓库)
|
||||||
com.gxwebsoft.openplatform
|
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/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏
|
||||||
├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器
|
├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器
|
||||||
├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/**
|
├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/**
|
||||||
@@ -71,15 +83,20 @@ com.gxwebsoft.openplatform
|
|||||||
├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段)
|
├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段)
|
||||||
├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息
|
├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息
|
||||||
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
|
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
|
||||||
├─ constant/OpenScopes.java scope 常量
|
└─ web/JsonResponseWriter.java 响应写出(不依赖业务侧 CommonUtil)
|
||||||
├─ 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **迁移说明(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`。
|
||||||
|
|
||||||
既有文件改动:
|
既有文件改动:
|
||||||
|
|
||||||
| 文件 | 改动 |
|
| 文件 | 改动 |
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
<maven.compiler.target>17</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<!-- 平台层版本:升级平台能力只改这一处 -->
|
||||||
|
<websoft-platform.version>1.0.0</websoft-platform.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -203,10 +205,19 @@
|
|||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- 开放平台资源服务:校验 base-api 签发的 RS256 令牌(JWKS 取公钥,不共享密钥) -->
|
<!--
|
||||||
|
平台层:内核(统一返回/状态码/业务异常)+ 开放平台资源服务能力。
|
||||||
|
|
||||||
|
这些类原先以源码形式散在本仓库里,每个业务仓库各复制一份,改一处要改 40 处。
|
||||||
|
现在改为依赖构件,升级只要改下面的版本号。
|
||||||
|
|
||||||
|
配套约定:BOM 只管理 com.gxwebsoft 的构件,不接管 Spring Boot 版本,
|
||||||
|
所以本服务仍可自行决定 Boot 版本。
|
||||||
|
-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>com.gxwebsoft</groupId>
|
||||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
<artifactId>websoft-platform-openapi</artifactId>
|
||||||
|
<version>${websoft-platform.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- jjwt - 升级到安全版本 -->
|
<!-- jjwt - 升级到安全版本 -->
|
||||||
|
|||||||
@@ -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";
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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.PaginationInnerInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
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 com.gxwebsoft.common.system.entity.User;
|
||||||
import net.sf.jsqlparser.expression.Expression;
|
import net.sf.jsqlparser.expression.Expression;
|
||||||
import net.sf.jsqlparser.expression.LongValue;
|
import net.sf.jsqlparser.expression.LongValue;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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<T> 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<T> setCode(Integer code) {
|
|
||||||
this.code = code;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMessage() {
|
|
||||||
return this.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setMessage(String message) {
|
|
||||||
this.message = message;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public T getData() {
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setData(T data) {
|
|
||||||
this.data = data;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getError() {
|
|
||||||
return this.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setError(String error) {
|
|
||||||
this.error = error;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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<T> implements Serializable {
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@Schema(description = "当前页数据")
|
|
||||||
private List<T> list;
|
|
||||||
|
|
||||||
@Schema(description = "总数量")
|
|
||||||
private Long count;
|
|
||||||
|
|
||||||
public PageResult() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public PageResult(List<T> list) {
|
|
||||||
this(list, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PageResult(List<T> list, Long count) {
|
|
||||||
setList(list);
|
|
||||||
setCount(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> getList() {
|
|
||||||
return this.list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setList(List<T> list) {
|
|
||||||
this.list = list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getCount() {
|
|
||||||
return this.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCount(Long count) {
|
|
||||||
this.count = count;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放平台令牌校验配置。
|
|
||||||
*
|
|
||||||
* <p><b>不要使用 {@code spring.security.oauth2.resourceserver.jwt.issuer-uri}</b>:配置该项后
|
|
||||||
* Spring Security 会在启动时请求 {@code {issuer}/.well-known/openid-configuration} 做 OIDC 发现,
|
|
||||||
* 而 base-api 并未提供该文档(会返回「请先登录」),启动会直接失败。这里只配置 JWKS 地址,
|
|
||||||
* issuer 与 audience 用显式校验器声明。</p>
|
|
||||||
*
|
|
||||||
* @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 需要显式声明。
|
|
||||||
*
|
|
||||||
* <p>公开出来是为了让单元测试可以直接用本地生成的 RSA 密钥验证校验规则,
|
|
||||||
* 不必依赖 base-api 在线。</p>
|
|
||||||
*/
|
|
||||||
public OAuth2TokenValidator<Jwt> buildTokenValidator() {
|
|
||||||
return new DelegatingOAuth2TokenValidator<>(
|
|
||||||
JwtValidators.createDefaultWithIssuer(properties.getIssuer()),
|
|
||||||
audienceValidator());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* aud 必须命中配置的受众,避免拿到其它系统的令牌来调业务接口。
|
|
||||||
*/
|
|
||||||
private OAuth2TokenValidator<Jwt> audienceValidator() {
|
|
||||||
return jwt -> {
|
|
||||||
if (jwt.getAudience() != null && jwt.getAudience().contains(properties.getAudience())) {
|
|
||||||
return OAuth2TokenValidatorResult.success();
|
|
||||||
}
|
|
||||||
return OAuth2TokenValidatorResult.failure(
|
|
||||||
new OAuth2Error("invalid_token", "令牌受众不正确", 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)对接配置。
|
|
||||||
*
|
|
||||||
* <p>业务服务不共享密钥,只通过 JWKS 拉取公钥本地验签,因此这里只有地址与校验参数。</p>
|
|
||||||
*
|
|
||||||
* @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;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放平台接口的独立安全链。
|
|
||||||
*
|
|
||||||
* <p>必须与内部控制台的安全链分开,原因有两点:</p>
|
|
||||||
* <ol>
|
|
||||||
* <li>内部控制台用 HS256 的对称密钥,开放平台用 RS256 的非对称密钥,
|
|
||||||
* 同一条链上会互相干扰(表现为
|
|
||||||
* {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures});</li>
|
|
||||||
* <li>控制台的 {@code SecurityConfig} 为了兼容前台站点,把「所有 GET」放行了,
|
|
||||||
* 对外开放接口绝不能继承这个默认放行。</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p>本链排在前面(@Order(1)),只匹配 {@code /api/open/**},其余请求仍由原有链路处理。</p>
|
|
||||||
*
|
|
||||||
* @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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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() + "/**");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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。
|
|
||||||
*
|
|
||||||
* <p>租户、用户、权限一律以令牌为准,不接受任何请求参数或请求头覆盖。</p>
|
|
||||||
*
|
|
||||||
* @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<String> 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<String> parseScopes(String scope) {
|
|
||||||
if (scope == null || scope.trim().isEmpty()) {
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
List<String> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package com.gxwebsoft.openplatform.context;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放接口链路的租户上下文。
|
|
||||||
*
|
|
||||||
* <p>与既有的「请求头 tenantId → Domain → 登录用户」取值顺序不同:开放链路只认令牌里的租户,
|
|
||||||
* 由 {@code OpenTenantInterceptor} 在请求进入 controller 之前写入、请求结束后清理。
|
|
||||||
* {@code MybatisPlusConfig} 会优先读取这里,从而彻底屏蔽第三方通过请求头指定租户的可能。</p>
|
|
||||||
*
|
|
||||||
* @author WebSoft
|
|
||||||
*/
|
|
||||||
public final class OpenTenantContext {
|
|
||||||
|
|
||||||
private static final ThreadLocal<OpenCaller> 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,13 @@ import com.gxwebsoft.common.core.web.PageResult;
|
|||||||
import com.gxwebsoft.common.system.entity.Order;
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
import com.gxwebsoft.common.system.param.OrderParam;
|
import com.gxwebsoft.common.system.param.OrderParam;
|
||||||
import com.gxwebsoft.common.system.service.OrderService;
|
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.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.param.OpenOrderPageParam;
|
||||||
import com.gxwebsoft.openplatform.vo.OpenOrderVO;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
@@ -40,6 +41,7 @@ import java.util.List;
|
|||||||
* @author WebSoft
|
* @author WebSoft
|
||||||
*/
|
*/
|
||||||
@Tag(name = "开放接口-订单")
|
@Tag(name = "开放接口-订单")
|
||||||
|
@OpenApi
|
||||||
@RestController
|
@RestController
|
||||||
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
@RequestMapping("/api/open/v1/order")
|
@RequestMapping("/api/open/v1/order")
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import com.gxwebsoft.common.core.web.PageResult;
|
|||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import com.gxwebsoft.common.system.param.UserParam;
|
import com.gxwebsoft.common.system.param.UserParam;
|
||||||
import com.gxwebsoft.common.system.service.UserService;
|
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.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.param.OpenUserPageParam;
|
||||||
import com.gxwebsoft.openplatform.vo.OpenUserVO;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
@@ -34,6 +35,7 @@ import java.util.List;
|
|||||||
* @author WebSoft
|
* @author WebSoft
|
||||||
*/
|
*/
|
||||||
@Tag(name = "开放接口-用户")
|
@Tag(name = "开放接口-用户")
|
||||||
|
@OpenApi
|
||||||
@RestController
|
@RestController
|
||||||
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
@RequestMapping("/api/open/v1/user")
|
@RequestMapping("/api/open/v1/user")
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放接口的分页返回结构。
|
|
||||||
*
|
|
||||||
* <p>刻意不复用内部的 {@code PageResult}(字段是 {@code count}):对外接口是长期契约,
|
|
||||||
* 用独立结构可以避免以后内部字段调整波及第三方。</p>
|
|
||||||
*
|
|
||||||
* @author WebSoft
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Schema(name = "OpenPageResult", description = "开放接口分页结果")
|
|
||||||
public class OpenPageResult<T> implements Serializable {
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@Schema(description = "当前页数据")
|
|
||||||
private List<T> list = Collections.emptyList();
|
|
||||||
|
|
||||||
@Schema(description = "总记录数")
|
|
||||||
private Long total;
|
|
||||||
|
|
||||||
@Schema(description = "当前页码,从 1 开始")
|
|
||||||
private Long page;
|
|
||||||
|
|
||||||
@Schema(description = "每页数量")
|
|
||||||
private Long limit;
|
|
||||||
|
|
||||||
public OpenPageResult() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public OpenPageResult(List<T> list, Long total, Long page, Long limit) {
|
|
||||||
this.list = list == null ? Collections.emptyList() : list;
|
|
||||||
this.total = total;
|
|
||||||
this.page = page;
|
|
||||||
this.limit = limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
-31
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放接口未认证响应。
|
|
||||||
*
|
|
||||||
* <p>与内部链路共用 {@code JwtAuthenticationEntryPoint} 的差别只有一个:不返回 {@code error} 字段。
|
|
||||||
* 该字段承载的是异常 {@code toString()},会把内部类名与解析细节暴露给第三方。HTTP 状态码保持 200,
|
|
||||||
* 与 base-api 一致,调用方以 body 里的 {@code code} 判断成败。</p>
|
|
||||||
*
|
|
||||||
* @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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放接口专用异常处理。
|
|
||||||
*
|
|
||||||
* <p>只作用于 {@code com.gxwebsoft.openplatform} 下的 controller,且优先级高于全局的
|
|
||||||
* {@code GlobalExceptionHandler},目的是<b>不把 {@code error} 字段(异常 toString)返回给第三方</b>。</p>
|
|
||||||
*
|
|
||||||
* @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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放接口的租户绑定拦截器。
|
|
||||||
*
|
|
||||||
* <p>令牌验签由 Spring Security 的 resource server 完成,这里只负责把 {@code tenant_id}
|
|
||||||
* 落到线程上下文,供 ORM 租户插件与业务代码使用。令牌没有租户时直接拒绝,
|
|
||||||
* 不回退到请求头,避免第三方通过 {@code tenantId} 头越权访问其它租户数据。</p>
|
|
||||||
*
|
|
||||||
* @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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放平台令牌校验规则测试。
|
|
||||||
*
|
|
||||||
* <p>用本地生成的 RSA 密钥自行签发令牌,验证 iss / aud / exp 三条规则是否真的生效,
|
|
||||||
* 不依赖 base-api 在线(JWKS 地址在这里不会被访问)。</p>
|
|
||||||
*
|
|
||||||
* @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));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开放接口的租户绑定与上下文清理测试。
|
|
||||||
*
|
|
||||||
* <p>租户隔离是这套改造的核心,这里覆盖三件事:从令牌取租户、令牌缺租户时拒绝、
|
|
||||||
* 请求结束后必须清理 ThreadLocal(否则线程复用会把租户串到下一个请求)。</p>
|
|
||||||
*
|
|
||||||
* @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());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user