This commit is contained in:
2026-08-25 14:46:06 +08:00
parent 5abc7543ae
commit f325d57fc1
106 changed files with 3682 additions and 450 deletions
@@ -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,
@@ -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("/+$", "");
}
}
@@ -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) { }
}
@@ -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; }
}
}
@@ -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) { }
}