优化
This commit is contained in:
@@ -24,7 +24,8 @@ import java.util.Map;
|
||||
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class})
|
||||
@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class,
|
||||
OverseasSecurityProjectionProperties.class})
|
||||
public class GatewaySecurityConfig {
|
||||
@Bean
|
||||
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@ConfigurationProperties(prefix = "baiyue.security.overseas-projection")
|
||||
public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) {
|
||||
public OverseasSecurityProjectionProperties {
|
||||
if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 OVERSEAS_SECURITY_SERVICE_URL");
|
||||
if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||
}
|
||||
timeout = timeout == null || timeout.isNegative() || timeout.isZero() ? Duration.ofSeconds(2) : timeout;
|
||||
serviceUrl = serviceUrl.replaceAll("/+$", "");
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient {
|
||||
private static final String PATH = "/internal/overseas-security/v1/validate";
|
||||
private final WebClient client;
|
||||
private final OverseasSecurityProjectionProperties properties;
|
||||
|
||||
public HttpOverseasSecurityValidationClient(WebClient.Builder builder, OverseasSecurityProjectionProperties properties) {
|
||||
this.client = builder.build();
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Result> validate(long userId, long tenantId, long tokenVersion, long permissionVersion) {
|
||||
return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Internal-Service-Token", properties.internalServiceToken())
|
||||
.bodyValue(new Request(userId, tenantId, tokenVersion, permissionVersion))
|
||||
.retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> {
|
||||
JsonNode data = body == null ? null : body.path("data");
|
||||
if (data == null || data.isMissingNode() || !data.has("valid")) {
|
||||
return Mono.error(new IllegalStateException("海外安全投影响应无效"));
|
||||
}
|
||||
return Mono.just(new Result(data.path("valid").asBoolean(false),
|
||||
data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null)));
|
||||
});
|
||||
}
|
||||
|
||||
private record Request(long userId, long tenantId, long tokenVersion, long permissionVersion) { }
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.baiyue.gateway.config.GatewaySecurityConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
|
||||
private final OverseasSecurityValidationClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) {
|
||||
this.client = client;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange);
|
||||
return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated)
|
||||
.ofType(JwtAuthenticationToken.class)
|
||||
.switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效")))
|
||||
.flatMap(authentication -> validate(exchange, chain, authentication))
|
||||
.onErrorResume(SecurityProjectionFailure.class,
|
||||
failure -> write(exchange, failure.code, failure.getMessage()))
|
||||
.onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用"));
|
||||
}
|
||||
|
||||
private Mono<Void> validate(ServerWebExchange exchange, GatewayFilterChain chain,
|
||||
JwtAuthenticationToken authentication) {
|
||||
Long userId = number(authentication.getToken().getSubject());
|
||||
Long tenantId = number(authentication.getToken().getClaim("tenant_id"));
|
||||
Long tokenVersion = number(authentication.getToken().getClaim("token_version"));
|
||||
Long permissionVersion = number(authentication.getToken().getClaim("permission_version"));
|
||||
if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) {
|
||||
return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失"));
|
||||
}
|
||||
return client.validate(userId, tenantId, tokenVersion, permissionVersion)
|
||||
.switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用")))
|
||||
.flatMap(result -> {
|
||||
if (result.valid()) return chain.filter(exchange);
|
||||
if ("USER_DISABLED".equals(result.failureReason())) {
|
||||
return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用"));
|
||||
}
|
||||
return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化"));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> write(ServerWebExchange exchange, String code, String message) {
|
||||
return GatewaySecurityConfig.writeError(exchange, objectMapper,
|
||||
"A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message);
|
||||
}
|
||||
|
||||
private static Long number(Object value) {
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
try { return value == null ? null : Long.valueOf(value.toString()); }
|
||||
catch (NumberFormatException exception) { return null; }
|
||||
}
|
||||
|
||||
@Override public int getOrder() { return -95; }
|
||||
|
||||
private static final class SecurityProjectionFailure extends RuntimeException {
|
||||
private final String code;
|
||||
private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; }
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface OverseasSecurityValidationClient {
|
||||
Mono<Result> validate(long userId, long tenantId, long tokenVersion, long permissionVersion);
|
||||
record Result(boolean valid, String failureReason) { }
|
||||
}
|
||||
@@ -46,6 +46,10 @@ baiyue:
|
||||
auth-url: ${AUTH_SESSION_VALIDATION_URL}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
|
||||
overseas-projection:
|
||||
service-url: ${OVERSEAS_SECURITY_SERVICE_URL}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||
timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s}
|
||||
permit-paths:
|
||||
- /actuator/health
|
||||
- /swagger-ui/**
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class OverseasSecurityProjectionFilterTest {
|
||||
private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class);
|
||||
private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper());
|
||||
|
||||
@Test void disabledUserFailsWithA0401() {
|
||||
when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(false, "USER_DISABLED")));
|
||||
var exchange = exchange("/api/platform/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(body(exchange)).contains("A0401");
|
||||
}
|
||||
|
||||
@Test void disabledTenantAndStalePermissionFailWithA0403() {
|
||||
for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) {
|
||||
when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(false, reason)));
|
||||
var exchange = exchange("/api/merchant/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(body(exchange)).contains("A0403");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void projectionUnavailableFailsClosed() {
|
||||
when(client.validate(42, 0, 3, 8)).thenReturn(Mono.error(new IllegalStateException("down")));
|
||||
var exchange = exchange("/api/platform/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() {
|
||||
when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(true, null)));
|
||||
AtomicBoolean routed = new AtomicBoolean();
|
||||
StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> {
|
||||
routed.set(true); return Mono.empty();
|
||||
})).verifyComplete();
|
||||
assertThat(routed).isTrue();
|
||||
|
||||
var isolatedClient = mock(OverseasSecurityValidationClient.class);
|
||||
var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper());
|
||||
StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete();
|
||||
verifyNoInteractions(isolatedClient);
|
||||
}
|
||||
|
||||
private static ServerWebExchange exchange(String path) {
|
||||
Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build());
|
||||
return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build();
|
||||
}
|
||||
|
||||
private static String body(ServerWebExchange exchange) {
|
||||
return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse())
|
||||
.getBodyAsString().block();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user