不要使用 {@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 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; - -/** - * 开放平台接口的独立安全链。 - * - *必须与内部控制台的安全链分开,原因有两点:
- *本链排在前面(@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与既有的「请求头 tenantId → Domain → 登录用户」取值顺序不同:开放链路只认令牌里的租户, - * 由 {@code OpenTenantInterceptor} 在请求进入 controller 之前写入、请求结束后清理。 - * {@code MybatisPlusConfig} 会优先读取这里,从而彻底屏蔽第三方通过请求头指定租户的可能。
- * - * @author WebSoft - */ -public final class OpenTenantContext { - - private static final ThreadLocal刻意不复用内部的 {@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 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()); - } - -}