feat: gate overseas receiving and harden phase4 workflows

This commit is contained in:
2026-08-25 10:31:04 +08:00
parent cb78bc6b41
commit 52b9fc382f
93 changed files with 2779 additions and 201 deletions
+1
View File
@@ -13,6 +13,7 @@
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
| Audience 校验 | `config/AudienceJwtValidator.java` |
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed |
| 安全配置项 | `config/GatewaySecurityProperties.java` |
| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` |
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
@@ -24,7 +24,7 @@ import java.util.Map;
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
*/
@Configuration
@EnableConfigurationProperties(GatewaySecurityProperties.class)
@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class})
public class GatewaySecurityConfig {
@Bean
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
@@ -63,7 +63,7 @@ public class GatewaySecurityConfig {
return decoder;
}
static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
public static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
ObjectMapper mapper, HttpStatus status, String code, String message) {
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
@@ -0,0 +1,25 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/** 海外 PDA 实时会话校验配置;URL 与服务令牌只能由环境提供。 */
@ConfigurationProperties(prefix = "baiyue.security.pda-session-validation")
public record PdaSessionValidationProperties(String authUrl,
String internalServiceToken,
Duration timeout) {
public PdaSessionValidationProperties {
if (authUrl == null || authUrl.isBlank()) {
throw new IllegalArgumentException("必须配置 AUTH_SESSION_VALIDATION_URL");
}
if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
}
timeout = timeout == null ? Duration.ofSeconds(2) : timeout;
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("PDA_SESSION_VALIDATION_TIMEOUT 必须大于 0");
}
authUrl = authUrl.replaceAll("/+$", "");
}
}
@@ -12,7 +12,7 @@ import java.util.Locale;
/** Validates the identity and authorization claims required by overseas APIs. */
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
private static final List<String> REQUIRED_CLAIMS = List.of(
"sub", "tenant_id", "app_code", "permission_version", "authorities");
"sub", "tenant_id", "app_code", "token_version", "permission_version", "authorities");
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
@@ -26,6 +26,7 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
Long subject = longValue(jwt.getSubject());
Long tenantId = longValue(jwt.getClaims().get("tenant_id"));
Long permissionVersion = longValue(jwt.getClaims().get("permission_version"));
Long tokenVersion = longValue(jwt.getClaims().get("token_version"));
String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT);
if (subject == null || subject <= 0) {
return failure("JWT sub 必须是正整数用户 ID");
@@ -36,6 +37,9 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
if (permissionVersion == null || permissionVersion < 0) {
return failure("JWT permission_version 必须是非负整数");
}
if (tokenVersion == null || tokenVersion < 0) {
return failure("JWT token_version 必须是非负整数");
}
if (!("PLATFORM".equals(appCode) && tenantId == 0L)
&& !("MERCHANT".equals(appCode) && tenantId > 0L)) {
return failure("JWT app_code 与 tenant_id 访问域不一致");
@@ -0,0 +1,46 @@
package com.baiyue.gateway.filter;
import com.baiyue.gateway.config.PdaSessionValidationProperties;
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;
/** 调用 Auth 内部实时校验端点;任何传输或响应异常均交由 Gateway fail-closed。 */
@Component
public class HttpPdaSessionValidationClient implements PdaSessionValidationClient {
private static final String PATH = "/internal/auth/accounts/overseas-session/validate";
private final WebClient webClient;
private final PdaSessionValidationProperties properties;
public HttpPdaSessionValidationClient(WebClient.Builder builder, PdaSessionValidationProperties properties) {
this.webClient = builder.build();
this.properties = properties;
}
@Override
public Mono<Result> validate(long userId, int tokenVersion, long overseasWarehouseId) {
return webClient.post()
.uri(properties.authUrl() + PATH)
.contentType(MediaType.APPLICATION_JSON)
.header("X-Internal-Service-Token", properties.internalServiceToken())
.bodyValue(new Request(userId, tokenVersion, overseasWarehouseId))
.retrieve()
.bodyToMono(JsonNode.class)
.timeout(properties.timeout())
.flatMap(this::decode);
}
private Mono<Result> decode(JsonNode envelope) {
JsonNode data = envelope == null ? null : envelope.path("data");
if (data == null || data.isMissingNode() || data.isNull() || !data.has("valid")) {
return Mono.error(new IllegalStateException("Auth overseas session validation response is invalid"));
}
boolean valid = data.path("valid").asBoolean(false);
String reason = data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null);
return Mono.just(new Result(valid, reason));
}
private record Request(long userId, int tokenVersion, long overseasWarehouseId) { }
}
@@ -0,0 +1,10 @@
package com.baiyue.gateway.filter;
import reactor.core.publisher.Mono;
/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */
public interface PdaSessionValidationClient {
Mono<Result> validate(long userId, int tokenVersion, long overseasWarehouseId);
record Result(boolean valid, String failureReason) { }
}
@@ -0,0 +1,92 @@
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;
/** 仅对海外 PDA API 执行无缓存的账号版本与仓绑定实时校验。 */
@Component
public class PdaSessionValidationFilter implements GlobalFilter, Ordered {
private static final String PDA_PREFIX = "/api/pda/";
private final PdaSessionValidationClient client;
private final ObjectMapper objectMapper;
public PdaSessionValidationFilter(PdaSessionValidationClient client, ObjectMapper objectMapper) {
this.client = client;
this.objectMapper = objectMapper;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (!exchange.getRequest().getURI().getPath().startsWith(PDA_PREFIX)) {
return chain.filter(exchange);
}
return exchange.getPrincipal().cast(Authentication.class)
.filter(Authentication::isAuthenticated)
.ofType(JwtAuthenticationToken.class)
.switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "未登录或令牌已失效")))
.flatMap(authentication -> validate(exchange, chain, authentication))
.onErrorResume(InvalidPdaSession.class, failure -> write(exchange, failure.code, failure.getMessage()))
.onErrorResume(failure -> write(exchange, "A0401", "海外PDA会话校验不可用"));
}
private Mono<Void> validate(ServerWebExchange exchange, GatewayFilterChain chain,
JwtAuthenticationToken authentication) {
Long userId = longValue(authentication.getToken().getSubject());
Integer tokenVersion = intClaim(authentication, "token_version");
Long warehouseId = longClaim(authentication, "overseas_warehouse_id");
if (userId == null || tokenVersion == null) {
return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌版本声明缺失"));
}
if (warehouseId == null) {
return Mono.error(new InvalidPdaSession("A0403", "海外PDA账号未绑定仓库"));
}
return client.validate(userId, tokenVersion, warehouseId)
.switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "海外PDA会话校验不可用")))
.flatMap(result -> {
if (result.valid()) return chain.filter(exchange);
if ("WAREHOUSE_UNBOUND".equals(result.failureReason())
|| "WAREHOUSE_MISMATCH".equals(result.failureReason())) {
return Mono.error(new InvalidPdaSession("A0403", "海外PDA仓库绑定已变化"));
}
return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌已失效"));
});
}
private Mono<Void> write(ServerWebExchange exchange, String code, String message) {
HttpStatus status = "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED;
return GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, message);
}
private static Integer intClaim(JwtAuthenticationToken authentication, String name) {
Object value = authentication.getToken().getClaims().get(name);
if (value instanceof Number number) return number.intValue();
try { return value == null ? null : Integer.valueOf(value.toString()); }
catch (NumberFormatException ignored) { return null; }
}
private static Long longClaim(JwtAuthenticationToken authentication, String name) {
return longValue(authentication.getToken().getClaims().get(name));
}
private static Long longValue(Object value) {
if (value instanceof Number number) return number.longValue();
try { return value == null ? null : Long.valueOf(value.toString()); }
catch (NumberFormatException ignored) { return null; }
}
@Override public int getOrder() { return -90; }
private static final class InvalidPdaSession extends RuntimeException {
private final String code;
private InvalidPdaSession(String code, String message) { super(message); this.code = code; }
}
}
@@ -42,6 +42,10 @@ baiyue:
issuer: ${JWT_ISSUER:https://auth.example.com}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
pda-session-validation:
auth-url: ${AUTH_SESSION_VALIDATION_URL}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
permit-paths:
- /actuator/health
- /swagger-ui/**
@@ -0,0 +1,43 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PdaSessionValidationDeploymentContractTest {
@Test
void nacosComposeAndJenkinsDeploymentRequireOnlyEnvironmentBackedAuthCoordinates() throws Exception {
String application = read("gateway-overseas/src/main/resources/application.yml");
String nacos = read("config/nacos/overseas/baiyue-common.yml");
String compose = read("deploy/docker-compose.overseas.yml");
String deployServices = read("deploy/scripts/deploy-services.sh");
String deployBuiltService = read("deploy/scripts/deploy-built-service.sh");
String jenkins = read("Jenkinsfile");
for (String source : new String[]{application, nacos, compose}) {
assertThat(source).contains("AUTH_SESSION_VALIDATION_URL", "INTERNAL_SERVICE_TOKEN",
"PDA_SESSION_VALIDATION_TIMEOUT");
}
assertThat(deployServices).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
assertThat(deployBuiltService).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
assertThat(jenkins).contains("'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'", "'auth': 'SERVICE_AUTH'",
"'iam': 'SERVICE_IAM'");
assertThat(application).doesNotContain("/internal/auth/accounts/overseas-session/validate");
assertThat(read("gateway-overseas/src/main/resources/application-overseas.yml"))
.doesNotContain("/internal/auth/**", "/internal/auth/accounts/overseas-session/validate");
assertThat(read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java"))
.contains("new Request(userId, tokenVersion, overseasWarehouseId)",
".header(\"X-Internal-Service-Token\"")
.doesNotContain("Authorization", "accessToken", "rawToken", "getTokenValue");
}
private static String read(String relative) throws Exception {
Path path = Path.of(relative);
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
return Files.readString(path, StandardCharsets.UTF_8);
}
}
@@ -21,11 +21,22 @@ class RequiredClaimsJwtValidatorTest {
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
}
@Test
void rejectsMissingAccountTokenVersion() {
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "0").claim("app_code", "PLATFORM")
.claim("permission_version", 3L).claim("authorities", List.of())
.build();
assertThat(validator.validate(value).hasErrors()).isTrue();
}
@Test
void rejectsMissingAuthorities() {
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", 3L)
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1)
.claim("permission_version", 3L)
.build();
assertThat(validator.validate(value).hasErrors()).isTrue();
}
@@ -54,6 +65,7 @@ class RequiredClaimsJwtValidatorTest {
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L)
.claim("token_version", 1)
.claim("authorities", List.of()).build();
assertThat(validator.validate(value).hasErrors()).isTrue();
}
@@ -72,6 +84,7 @@ class RequiredClaimsJwtValidatorTest {
.expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", tenantId)
.claim("app_code", appCode)
.claim("token_version", 1)
.claim("authorities", authorities);
if (includePermissionVersion) {
builder.claim("permission_version", 3L);
@@ -0,0 +1,119 @@
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.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
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.Map;
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 PdaSessionValidationFilterTest {
private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class);
private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper());
@Test
void oldTokenIsRejectedImmediatelyAfterWarehouseChange() {
when(client.validate(42L, 7, 900L)).thenReturn(Mono.just(
new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH")));
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L));
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(body(exchange)).contains("A0401", "令牌已失效");
}
@Test
void newlyIssuedTokenWithCurrentWarehousePasses() {
when(client.validate(42L, 8, 901L)).thenReturn(Mono.just(
new PdaSessionValidationClient.Result(true, null)));
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
AtomicBoolean routed = new AtomicBoolean();
StepVerifier.create(filter.filter(exchange, ignored -> {
routed.set(true);
return Mono.empty();
})).verifyComplete();
assertThat(routed).isTrue();
assertThat(exchange.getResponse().getStatusCode()).isNull();
}
@Test
void unboundWarehouseIsDeniedWithoutCallingAuth() {
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null));
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(body(exchange)).contains("A0403", "未绑定仓库");
verifyNoInteractions(client);
}
@Test
void authUnavailableFailsClosed() {
when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout")));
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(body(exchange)).contains("A0401", "校验不可用");
}
@Test
void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() {
for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) {
var exchange = exchange(path, claims(7, 900L));
AtomicBoolean routed = new AtomicBoolean();
StepVerifier.create(filter.filter(exchange, ignored -> {
routed.set(true);
return Mono.empty();
})).verifyComplete();
assertThat(routed).isTrue();
}
verifyNoInteractions(client);
}
private static ServerWebExchange exchange(String path, Map<String, Object> claims) {
Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server")
.header("alg", "RS256")
.subject("42")
.issuedAt(Instant.now().minusSeconds(5))
.expiresAt(Instant.now().plusSeconds(300));
claims.forEach(jwt::claim);
MockServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get(path).build());
return exchange.mutate()
.principal(Mono.just(new JwtAuthenticationToken(jwt.build(),
List.of(new SimpleGrantedAuthority("ROLE_WAREHOUSE_PICKER")))))
.build();
}
private static Map<String, Object> claims(int tokenVersion, Long warehouseId) {
var claims = new java.util.LinkedHashMap<String, Object>();
claims.put("token_version", tokenVersion);
if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId);
return claims;
}
private static String body(ServerWebExchange exchange) {
return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse())
.getBodyAsString().block();
}
}