feat: gate overseas receiving and harden phase4 workflows
This commit is contained in:
@@ -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);
|
||||
|
||||
+25
@@ -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("/+$", "");
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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 访问域不一致");
|
||||
|
||||
+46
@@ -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) { }
|
||||
}
|
||||
+10
@@ -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) { }
|
||||
}
|
||||
+92
@@ -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/**
|
||||
|
||||
Reference in New Issue
Block a user