暂存提交
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.gateway;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class BaiyueGatewayOverseasApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BaiyueGatewayOverseasApplication.class, args);
|
||||
}
|
||||
}
|
||||
+32
@@ -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,23 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
final class AudienceJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private final String requiredAudience;
|
||||
|
||||
AudienceJwtValidator(String requiredAudience) {
|
||||
this.requiredAudience = requiredAudience;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
if (jwt.getAudience().contains(requiredAudience)) {
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
|
||||
"invalid_token", "JWT audience 不允许访问海外 API", null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
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())
|
||||
.jwsAlgorithms(algorithms -> {
|
||||
algorithms.add(SignatureAlgorithm.RS256);
|
||||
algorithms.add(SignatureAlgorithm.ES256);
|
||||
})
|
||||
.build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
JwtValidators.createDefaultWithIssuer(properties.issuer()),
|
||||
new AudienceJwtValidator(properties.audience()),
|
||||
new RequiredClaimsJwtValidator()));
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
String audience,
|
||||
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");
|
||||
}
|
||||
if (audience == null || audience.isBlank()) {
|
||||
throw new IllegalArgumentException("必须配置 JWT_AUDIENCE");
|
||||
}
|
||||
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Validates the identity and authorization claims required by overseas APIs. */
|
||||
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private static final List<String> REQUIRED_CLAIMS = List.of(
|
||||
"sub", "tenant_id", "app_code", "permission_version");
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
for (String claim : REQUIRED_CLAIMS) {
|
||||
Object value = jwt.getClaims().get(claim);
|
||||
if (value == null || value.toString().isBlank()) {
|
||||
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
|
||||
"invalid_token", "JWT 缺少必要声明: " + claim, null));
|
||||
}
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
}
|
||||
+73
@@ -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"; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user