暂存提交
This commit is contained in:
@@ -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 }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user