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
@@ -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; }
}
}