Initial commit
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.lastleg</groupId>
|
||||
<artifactId>baiyue-lastleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>gateway</artifactId>
|
||||
<name>gateway</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-gateway</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
@@ -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,74 @@
|
||||
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.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(GatewaySecurityProperties.class)
|
||||
public class GatewaySecurityConfig {
|
||||
@Bean
|
||||
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
|
||||
GatewaySecurityProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
var exchanges = http.authorizeExchange();
|
||||
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.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })
|
||||
.authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper,
|
||||
HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效")))
|
||||
.exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) ->
|
||||
writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源")))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
|
||||
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
|
||||
.withJwkSetUri(properties.jwkSetUri()).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 exception) {
|
||||
return exchange.getResponse().setComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 海外网关安全配置,只保存验签所需的签发方和公钥集地址。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record GatewaySecurityProperties(String jwkSetUri, String issuer, List<String> permitPaths) {
|
||||
public GatewaySecurityProperties {
|
||||
if (jwkSetUri == null || jwkSetUri.isBlank()) {
|
||||
throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI");
|
||||
}
|
||||
if (issuer == null || issuer.isBlank()) {
|
||||
throw new IllegalArgumentException("必须配置 JWT_ISSUER");
|
||||
}
|
||||
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
|
||||
}
|
||||
}
|
||||
@@ -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,69 @@
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: overseas
|
||||
cloud:
|
||||
gateway:
|
||||
discovery:
|
||||
locator:
|
||||
enabled: false
|
||||
routes:
|
||||
- id: lastleg-service
|
||||
uri: lb://lastleg-service
|
||||
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"]
|
||||
- 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: lastleg-openapi
|
||||
uri: lb://lastleg-service
|
||||
predicates: [Path=/lastleg-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:OVERSEAS}
|
||||
cell: ${DEPLOYMENT_CELL:prod-overseas}
|
||||
data-residency: ${DATA_RESIDENCY:OVERSEAS}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
urls:
|
||||
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
|
||||
- { name: warehouse-merchant, url: /warehouse-service/v3/api-docs/merchant }
|
||||
- { 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: print-merchant, url: /print-service/v3/api-docs/merchant }
|
||||
- { 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,66 @@
|
||||
server:
|
||||
port: ${GATEWAY_PORT:9300}
|
||||
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6380}
|
||||
application:
|
||||
name: gateway
|
||||
profiles:
|
||||
default: overseas
|
||||
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
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
|
||||
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: false
|
||||
|
||||
baiyue:
|
||||
security:
|
||||
issuer: ${JWT_ISSUER:https://auth.example.com}
|
||||
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
|
||||
permit-paths:
|
||||
- /actuator/health
|
||||
- /swagger-ui/**
|
||||
- /webjars/**
|
||||
- /v3/api-docs/**
|
||||
- /lastleg-service/v3/api-docs/**
|
||||
- /warehouse-service/v3/api-docs/**
|
||||
- /file-service/v3/api-docs/**
|
||||
- /print-service/v3/api-docs/**
|
||||
- /audit-service/v3/api-docs/**
|
||||
- /integration-service/v3/api-docs/**
|
||||
- /event-bridge-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}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user