暂存提交

This commit is contained in:
2026-07-30 18:01:36 +08:00
commit 2f4e159356
368 changed files with 20370 additions and 0 deletions
@@ -0,0 +1,14 @@
package com.baiyue.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
*/
@SpringBootApplication
public class BaiyueGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueGatewayApplication.class, args);
}
}
@@ -0,0 +1,32 @@
package com.baiyue.gateway.config;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import reactor.core.publisher.Mono;
/**
* 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。
*/
final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private final String expectedAppCode;
AppScopeAuthorizationManager(String expectedAppCode) {
this.expectedAppCode = expectedAppCode;
}
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
AuthorizationContext context) {
return authentication.filter(Authentication::isAuthenticated)
.map(this::hasExpectedScope).map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
private boolean hasExpectedScope(Authentication authentication) {
return authentication instanceof JwtAuthenticationToken jwt
&& expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"));
}
}
@@ -0,0 +1,30 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
/**
* 统一生成网关响应式跨域规则,预检和实际请求均由同一份白名单约束。
*/
@Configuration
@EnableConfigurationProperties(GatewayCorsProperties.class)
public class GatewayCorsConfiguration {
@Bean
CorsConfigurationSource corsConfigurationSource(GatewayCorsProperties properties) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(properties.allowedOrigins());
configuration.setAllowedMethods(properties.allowedMethods());
configuration.setAllowedHeaders(properties.allowedHeaders());
configuration.setExposedHeaders(properties.exposedHeaders());
configuration.setAllowCredentials(properties.allowCredentials());
configuration.setMaxAge(properties.maxAge());
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,39 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
/**
* 网关跨域白名单配置,只允许明确登记的商户端和后台端来源访问。
*/
@ConfigurationProperties(prefix = "baiyue.cors")
public record GatewayCorsProperties(List<String> allowedOrigins,
List<String> allowedMethods,
List<String> allowedHeaders,
List<String> exposedHeaders,
boolean allowCredentials,
long maxAge) {
public GatewayCorsProperties {
allowedOrigins = normalize(allowedOrigins);
allowedMethods = normalize(allowedMethods);
allowedHeaders = normalize(allowedHeaders);
exposedHeaders = normalize(exposedHeaders);
maxAge = maxAge <= 0 ? 3600 : maxAge;
if (allowedOrigins.isEmpty()) {
throw new IllegalArgumentException("至少需要配置一个 CORS 允许来源");
}
if (allowedOrigins.contains("*")) {
throw new IllegalArgumentException("CORS 禁止使用通配符来源,请配置明确的前端地址");
}
}
private static List<String> normalize(List<String> values) {
if (values == null) return List.of();
return values.stream()
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.distinct()
.toList();
}
}
@@ -0,0 +1,79 @@
package com.baiyue.gateway.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
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 java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 网关响应式安全配置,建立 JWT 解码、双端应用范围授权规则及统一认证失败响应。
*/
@Configuration
@EnableConfigurationProperties(GatewaySecurityProperties.class)
public class GatewaySecurityConfig {
@Bean
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
GatewaySecurityProperties properties,
ObjectMapper objectMapper,
CorsConfigurationSource corsConfigurationSource) {
var exchanges = http.authorizeExchange();
exchanges.pathMatchers(HttpMethod.OPTIONS, "/**").permitAll();
properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll());
exchanges.pathMatchers("/api/merchant/**")
.access(new AppScopeAuthorizationManager("MERCHANT"));
exchanges.pathMatchers("/api/platform/**")
.access(new AppScopeAuthorizationManager("PLATFORM"));
exchanges.anyExchange().authenticated();
return http.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })
.authenticationEntryPoint((exchange, ex) -> writeError(exchange, objectMapper,
HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效")))
.exceptionHandling(errors -> errors.accessDeniedHandler((exchange, ex) ->
writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源")))
.build();
}
@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()));
return decoder;
}
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);
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", code); body.put("message", message); body.put("data", null);
body.put("requestId", exchange.getRequest().getId()); body.put("timestamp", Instant.now().toString());
try {
byte[] bytes = mapper.writeValueAsBytes(body);
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
} catch (Exception e) { return exchange.getResponse().setComplete(); }
}
}
@@ -0,0 +1,27 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
/**
* 网关安全配置模型,集中承载 JWT 密钥、签发方、Redis 令牌键前缀和匿名放行路径。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record GatewaySecurityProperties(String jwtSecret, String issuer, String tokenKeyPrefix,
List<String> permitPaths) {
public GatewaySecurityProperties {
issuer = issuer == null || issuer.isBlank() ? "auth-service" : issuer;
tokenKeyPrefix = normalize(tokenKeyPrefix);
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
}
public String accessTokenKey(String jwtId) {
return tokenKeyPrefix + ":access:" + jwtId;
}
private static String normalize(String prefix) {
String value = prefix == null || prefix.isBlank() ? "baiyue:auth" : prefix.trim();
return value.endsWith(":") ? value.substring(0, value.length() - 1) : value;
}
}
@@ -0,0 +1,53 @@
package com.baiyue.gateway.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
/**
* 网关访问令牌会话过滤器,通过 Redis 校验 JWT 会话仍然有效,以支持退出登录和令牌即时失效。
*/
@Component
public class RedisTokenSessionWebFilter implements WebFilter {
private final ReactiveStringRedisTemplate redisTemplate;
private final GatewaySecurityProperties properties;
private final ObjectMapper objectMapper;
public RedisTokenSessionWebFilter(ReactiveStringRedisTemplate redisTemplate,
GatewaySecurityProperties properties,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.properties = properties;
this.objectMapper = objectMapper;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return exchange.getPrincipal()
.ofType(JwtAuthenticationToken.class)
.flatMap(jwt -> validate(jwt, exchange, chain).thenReturn(Boolean.TRUE))
.switchIfEmpty(Mono.defer(() -> chain.filter(exchange).thenReturn(Boolean.TRUE)))
.then();
}
private Mono<Void> validate(JwtAuthenticationToken jwt, ServerWebExchange exchange,
WebFilterChain chain) {
String jwtId = jwt.getToken().getId();
if (jwtId == null || jwtId.isBlank()) return unauthorized(exchange);
return redisTemplate.hasKey(properties.accessTokenKey(jwtId))
.onErrorReturn(false)
.flatMap(active -> Boolean.TRUE.equals(active)
? chain.filter(exchange) : unauthorized(exchange));
}
private Mono<Void> unauthorized(ServerWebExchange exchange) {
return GatewaySecurityConfig.writeError(exchange, objectMapper, HttpStatus.UNAUTHORIZED,
"A0401", "登录会话已过期、已退出或 Redis 不可用");
}
}
@@ -0,0 +1,73 @@
package com.baiyue.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
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;
import java.util.List;
/**
* 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。
*/
@Component
public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
private static final List<String> TRUSTED_HEADERS = List.of(
"X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id",
"X-App-Code", "X-Client-Type"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return exchange.getPrincipal()
.cast(Authentication.class)
.defaultIfEmpty(new AnonymousAuthentication())
.flatMap(authentication -> {
var requestBuilder = exchange.getRequest().mutate();
requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove));
if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) {
put(requestBuilder, "X-User-Id", jwt.getToken().getSubject());
put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id"));
put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id"));
put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id"));
put(requestBuilder, "X-App-Code", claim(jwt, "app_code"));
put(requestBuilder, "X-Client-Type", claim(jwt, "client_type"));
}
return chain.filter(exchange.mutate().request(requestBuilder.build()).build());
});
}
private static String claim(JwtAuthenticationToken jwt, String name) {
Object value = jwt.getToken().getClaims().get(name);
return value == null ? null : value.toString();
}
private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder,
String name,
String value) {
if (value != null && !value.isBlank()) {
builder.header(name, value);
}
}
@Override
public int getOrder() {
return -100;
}
private static final class AnonymousAuthentication implements Authentication {
@Override public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority> getAuthorities() { return List.of(); }
@Override public Object getCredentials() { return ""; }
@Override public Object getDetails() { return null; }
@Override public Object getPrincipal() { return "anonymous"; }
@Override public boolean isAuthenticated() { return false; }
@Override public void setAuthenticated(boolean isAuthenticated) { }
@Override public String getName() { return "anonymous"; }
}
}
@@ -0,0 +1,95 @@
spring:
config:
activate:
on-profile: cn
cloud:
gateway:
discovery:
locator:
enabled: false
routes:
- id: auth-service
uri: lb://auth-service
predicates: [Path=/api/auth/**]
- id: iam-service
uri: lb://iam-service
predicates: ["Path=/api/iam/**,/api/platform/v1/iam/**,/api/merchant/v1/iam/**"]
- id: order-service
uri: lb://order-service
predicates: ["Path=/api/merchant/v1/orders/**,/api/platform/v1/orders/**"]
- id: firstleg-service
uri: lb://firstleg-service
predicates: ["Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**"]
- id: warehouse-service
uri: lb://warehouse-service
predicates: ["Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
- id: file-service
uri: lb://file-service
predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
- id: print-service
uri: lb://print-service
predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"]
- id: auth-openapi
uri: lb://auth-service
predicates: [Path=/auth-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: iam-openapi
uri: lb://iam-service
predicates: [Path=/iam-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: order-openapi
uri: lb://order-service
predicates: [Path=/order-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: firstleg-openapi
uri: lb://firstleg-service
predicates: [Path=/firstleg-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: warehouse-openapi
uri: lb://warehouse-service
predicates: [Path=/warehouse-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: file-openapi
uri: lb://file-service
predicates: [Path=/file-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: print-openapi
uri: lb://print-service
predicates: [Path=/print-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: audit-openapi
uri: lb://audit-service
predicates: [Path=/audit-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: integration-openapi
uri: lb://integration-service
predicates: [Path=/integration-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: event-bridge-openapi
uri: lb://event-bridge-service
predicates: [Path=/event-bridge-service/v3/api-docs/**]
filters: [StripPrefix=1]
baiyue:
deployment:
region-code: ${REGION_CODE:CN}
cell: ${DEPLOYMENT_CELL:prod-cn}
data-residency: ${DATA_RESIDENCY:CN}
springdoc:
swagger-ui:
urls:
- { name: auth-platform, url: /auth-service/v3/api-docs/platform }
- { name: iam-platform, url: /iam-service/v3/api-docs/platform }
- { name: iam-merchant, url: /iam-service/v3/api-docs/merchant }
- { name: order-platform, url: /order-service/v3/api-docs/platform }
- { name: order-merchant, url: /order-service/v3/api-docs/merchant }
- { name: firstleg-platform, url: /firstleg-service/v3/api-docs/platform }
- { name: warehouse-merchant, url: /warehouse-service/v3/api-docs/merchant }
- { name: warehouse-platform, url: /warehouse-service/v3/api-docs/platform }
- { name: file-platform, url: /file-service/v3/api-docs/platform }
- { name: file-merchant, url: /file-service/v3/api-docs/merchant }
- { name: print-platform, url: /print-service/v3/api-docs/platform }
- { name: audit, url: /audit-service/v3/api-docs }
- { name: integration, url: /integration-service/v3/api-docs }
- { name: event-bridge, url: /event-bridge-service/v3/api-docs }
+133
View File
@@ -0,0 +1,133 @@
server:
port: ${GATEWAY_PORT:9000}
spring:
profiles:
default: cn
application:
name: gateway
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s}
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
namespace: ${NACOS_NAMESPACE:public}
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
routes:
- id: auth-service
uri: lb://auth-service
predicates:
- Path=/api/auth/**
- id: iam-service
uri: lb://iam-service
predicates:
- Path=/api/iam/**,/api/platform/v1/iam/**,/api/merchant/v1/iam/**
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/merchant/v1/orders/**,/api/platform/v1/orders/**
- id: warehouse-service
uri: lb://warehouse-service
predicates:
- Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**
- id: file-service
uri: lb://file-service
predicates:
- Path=/api/merchant/v1/files/**,/api/platform/v1/files/**
- id: print-service
uri: lb://print-service
predicates:
- Path=/api/merchant/v1/print/**,/api/platform/v1/print/**
- id: firstleg-service
uri: lb://firstleg-service
predicates:
- Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**
baiyue:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174}
allowed-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
allowed-headers: Authorization,Content-Type,Accept,X-Requested-With,X-Request-Id,Idempotency-Key
exposed-headers: X-Request-Id,X-Trace-Id,Location,Content-Disposition
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}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
permit-paths:
- /api/auth/login
- /api/auth/register
- /api/auth/refresh
- /actuator/health
- /swagger-ui/**
- /swagger-ui.html
- /webjars/**
- /v3/api-docs/**
- /auth-service/v3/api-docs/**
- /iam-service/v3/api-docs/**
- /order-service/v3/api-docs/**
- /warehouse-service/v3/api-docs/**
- /file-service/v3/api-docs/**
- /print-service/v3/api-docs/**
- /firstleg-service/v3/api-docs/**
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui:
enabled: ${SWAGGER_ENABLED:false}
urls:
- name: auth-platform
url: /auth-service/v3/api-docs/platform
- name: iam-platform
url: /iam-service/v3/api-docs/platform
- name: iam-merchant
url: /iam-service/v3/api-docs/merchant
- name: order-platform
url: /order-service/v3/api-docs/platform
- name: order-merchant
url: /order-service/v3/api-docs/merchant
- name: warehouse-merchant
url: /warehouse-service/v3/api-docs/merchant
- name: warehouse-platform
url: /warehouse-service/v3/api-docs/platform
- name: file-platform
url: /file-service/v3/api-docs/platform
- name: file-merchant
url: /file-service/v3/api-docs/merchant
- name: print-platform
url: /print-service/v3/api-docs/platform
- name: firstleg-platform
url: /firstleg-service/v3/api-docs/platform
@@ -0,0 +1,55 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
class AppScopeAuthorizationManagerTest {
@Test
void acceptsMatchingApplicationScope() {
var manager = new AppScopeAuthorizationManager("MERCHANT");
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
.expectNextMatches(decision -> decision.isGranted())
.verifyComplete();
}
@Test
void rejectsDifferentApplicationScope() {
var manager = new AppScopeAuthorizationManager("PLATFORM");
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
.expectNextMatches(decision -> !decision.isGranted())
.verifyComplete();
}
@Test
void rejectsMissingApplicationScope() {
var manager = new AppScopeAuthorizationManager("PLATFORM");
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("1")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.build();
StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken(
jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null))
.expectNextMatches(decision -> !decision.isGranted())
.verifyComplete();
}
private JwtAuthenticationToken authentication(String appCode) {
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("1")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.claim("app_code", appCode)
.build();
return new JwtAuthenticationToken(jwt,
java.util.List.of(new SimpleGrantedAuthority("authenticated")));
}
}
@@ -0,0 +1,65 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"spring.cloud.nacos.discovery.enabled=false",
"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.cors.allowed-origins=http://localhost:5173"
})
@AutoConfigureWebTestClient
class GatewayCorsConfigurationTest {
private static final String PROFILE_URL =
"http://localhost/api/merchant/v1/iam/tenant/profile";
@Autowired
private WebTestClient webTestClient;
@Test
void allowedOriginCanCompletePutPreflightWithoutToken() {
webTestClient.options()
.uri(PROFILE_URL)
.header(HttpHeaders.ORIGIN, "http://localhost:5173")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.PUT.name())
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
.exchange()
.expectStatus().isOk()
.expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:5173")
.expectHeader().value(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS,
value -> org.assertj.core.api.Assertions.assertThat(value).contains("PUT"))
.expectHeader().value(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS,
value -> org.assertj.core.api.Assertions.assertThat(value.toLowerCase())
.contains("authorization", "content-type"))
.expectHeader().doesNotExist(HttpHeaders.WWW_AUTHENTICATE);
}
@Test
void rejectedOriginDoesNotReceiveCorsPermission() {
webTestClient.options()
.uri(PROFILE_URL)
.header(HttpHeaders.ORIGIN, "https://untrusted.example.com")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.PUT.name())
.exchange()
.expectStatus().isForbidden()
.expectHeader().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
@Test
void corsDoesNotMakeProtectedActualRequestAnonymous() {
webTestClient.get()
.uri(PROFILE_URL)
.header(HttpHeaders.ORIGIN, "http://localhost:5173")
.exchange()
.expectStatus().isUnauthorized()
.expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:5173");
}
}