This commit is contained in:
2026-09-29 17:55:54 +08:00
parent 41d8387cd5
commit 54ad26c607
111 changed files with 1837 additions and 962 deletions
@@ -9,7 +9,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
@@ -17,8 +17,9 @@ import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.web.cors.reactive.CorsConfigurationSource;
import reactor.core.publisher.Mono;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import java.util.List;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -44,6 +45,9 @@ public class GatewaySecurityConfig {
.access(new AppScopeAuthorizationManager("MERCHANT"));
exchanges.pathMatchers("/api/platform/**")
.access(new AppScopeAuthorizationManager("PLATFORM"));
exchanges.pathMatchers("/api/pda/**")
.access(new AppScopeAuthorizationManager("PLATFORM"));
exchanges.pathMatchers("/internal/**").denyAll();
exchanges.anyExchange().authenticated();
return http.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(ServerHttpSecurity.CsrfSpec::disable)
@@ -59,12 +63,11 @@ public class GatewaySecurityConfig {
@Bean
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
byte[] key = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (key.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withSecretKey(new SecretKeySpec(key, "HmacSHA256"))
.macAlgorithm(MacAlgorithm.HS256).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer()));
.withJwkSetUri(properties.jwkSetUri()).jwsAlgorithm(SignatureAlgorithm.RS256).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(properties.issuer()),
new JwtClaimValidator<List<String>>("aud", values -> values != null && values.contains(properties.audience()))));
return decoder;
}
@@ -8,10 +8,11 @@ import java.util.List;
* 网关安全配置模型,集中承载 JWT 密钥、签发方、Redis 令牌键前缀和匿名放行路径。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record GatewaySecurityProperties(String jwtSecret, String issuer, String tokenKeyPrefix,
public record GatewaySecurityProperties(String jwkSetUri, String issuer, String audience, String tokenKeyPrefix,
List<String> permitPaths) {
public GatewaySecurityProperties {
issuer = issuer == null || issuer.isBlank() ? "auth-service" : issuer;
audience = audience == null || audience.isBlank() ? "lastleg-api" : audience;
tokenKeyPrefix = normalize(tokenKeyPrefix);
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
}
+29 -1
View File
@@ -42,6 +42,14 @@ spring:
enabled: false
lower-case-service-id: true
routes:
- id: lastleg-service
uri: lb://lastleg-service
predicates:
- Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**
- id: warehouse-overseas-service
uri: lb://warehouse-overseas-service
predicates:
- Path=/api/platform/v1/overseas-warehouse-resources/**
- id: auth-service
uri: lb://auth-service
predicates:
@@ -83,6 +91,18 @@ spring:
predicates:
- Path=/api/platform/finance/v1/**,/api/client/finance/v1/**
# Swagger 文档聚合路由。
- id: lastleg-openapi
uri: lb://lastleg-service
predicates:
- Path=/lastleg-service/v3/api-docs/**
filters:
- StripPrefix=1
- id: warehouse-overseas-openapi
uri: lb://warehouse-overseas-service
predicates:
- Path=/warehouse-overseas-service/v3/api-docs/**
filters:
- StripPrefix=1
- id: auth-openapi
uri: lb://auth-service
predicates:
@@ -158,7 +178,9 @@ baiyue:
allow-credentials: ${CORS_ALLOW_CREDENTIALS:false}
max-age: ${CORS_MAX_AGE:3600}
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
permit-paths:
- /api/pda-updates/**
@@ -183,6 +205,8 @@ baiyue:
- /integration-service/v3/api-docs/**
- /event-bridge-service/v3/api-docs/**
- /finance-service/v3/api-docs/**
- /lastleg-service/v3/api-docs/**
- /warehouse-overseas-service/v3/api-docs/**
management:
endpoints:
@@ -231,3 +255,7 @@ springdoc:
url: /event-bridge-service/v3/api-docs
- name: finance
url: /finance-service/v3/api-docs
- name: lastleg
url: /lastleg-service/v3/api-docs
- name: warehouse-overseas
url: /warehouse-overseas-service/v3/api-docs
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
"spring.cloud.nacos.discovery.register-enabled=false",
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.gateway.discovery.locator.enabled=false",
"baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long"
"baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks"
})
class CustomerTransitStatisticsRouteContractTest {
@Autowired private GatewayProperties gatewayProperties;
@@ -18,7 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
"spring.cloud.nacos.discovery.register-enabled=false",
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.gateway.discovery.locator.enabled=false",
"baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long",
"baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks",
"baiyue.cors.allowed-origins=http://localhost:5173,http://localhost:5174"
})
@AutoConfigureWebTestClient
@@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat;
"spring.cloud.nacos.discovery.register-enabled=false",
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.gateway.discovery.locator.enabled=false",
"baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long"
"baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks"
})
class GatewayFileRouteConfigurationTest {
@Autowired
@@ -0,0 +1,55 @@
package com.baiyue.gateway.config;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.JwtException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class UnifiedJwtDecoderTest {
@Test
void acceptsUnifiedRsaTokenAndRejectsWrongAudienceIssuerAndExpiry() throws Exception {
var key = new RSAKeyGenerator(2048).keyID("current").generate();
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/jwks", exchange -> {
byte[] body = new JWKSet(key.toPublicJWK()).toString().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
try (var output = exchange.getResponseBody()) { output.write(body); }
});
server.start();
try {
var properties = new GatewaySecurityProperties("http://127.0.0.1:" + server.getAddress().getPort() + "/jwks",
"auth-service", "lastleg-api", "test:auth", List.of());
var decoder = new GatewaySecurityConfig().reactiveJwtDecoder(properties);
var signer = new RSASSASigner(key);
String valid = token(signer, "auth-service", "lastleg-api", 120);
assertThat(decoder.decode(valid).block(Duration.ofSeconds(5)).getSubject()).isEqualTo("42");
for (String invalid : List.of(token(signer, "other", "lastleg-api", 120),
token(signer, "auth-service", "other", 120), token(signer, "auth-service", "lastleg-api", -120))) {
assertThatThrownBy(() -> decoder.decode(invalid).block(Duration.ofSeconds(5)))
.isInstanceOf(JwtException.class);
}
} finally { server.stop(0); }
}
private String token(RSASSASigner signer, String issuer, String audience, int ttl) throws Exception {
var jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("current").build(),
new JWTClaimsSet.Builder().subject("42").issuer(issuer).audience(audience)
.issueTime(Date.from(Instant.now().minusSeconds(300)))
.expirationTime(Date.from(Instant.now().plusSeconds(ttl))).build());
jwt.sign(signer);
return jwt.serialize();
}
}