提交
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Gateway 模块指引
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。
|
||||
- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。
|
||||
|
||||
## 快速索引
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
|
||||
| Audience 校验 | `config/AudienceJwtValidator.java` |
|
||||
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
|
||||
| 安全配置项 | `config/GatewaySecurityProperties.java` |
|
||||
| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` |
|
||||
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
|
||||
| 测试 | `src/test/java/com/baiyue/gateway/config/` |
|
||||
|
||||
## 安全规则
|
||||
|
||||
- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。
|
||||
- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。
|
||||
- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 定向验证:`mvn -pl gateway -am test`。
|
||||
- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ 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;
|
||||
@@ -48,8 +50,16 @@ public class GatewaySecurityConfig {
|
||||
@Bean
|
||||
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
|
||||
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
|
||||
.withJwkSetUri(properties.jwkSetUri()).build();
|
||||
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer()));
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import java.util.List;
|
||||
* 海外网关安全配置,只保存验签所需的签发方和公钥集地址。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record GatewaySecurityProperties(String jwkSetUri, String issuer, List<String> permitPaths) {
|
||||
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");
|
||||
@@ -16,6 +19,9 @@ public record GatewaySecurityProperties(String jwkSetUri, String issuer, List<St
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ spring:
|
||||
baiyue:
|
||||
security:
|
||||
issuer: ${JWT_ISSUER:https://auth.example.com}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
|
||||
permit-paths:
|
||||
- /actuator/health
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AudienceJwtValidatorTest {
|
||||
private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api");
|
||||
|
||||
@Test
|
||||
void acceptsTokenForLastlegApi() {
|
||||
assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTokenForAnotherApi() {
|
||||
assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
private Jwt jwt(List<String> audiences) {
|
||||
return Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.subject("1001")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(300))
|
||||
.audience(audiences)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RequiredClaimsJwtValidatorTest {
|
||||
private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator();
|
||||
|
||||
@Test
|
||||
void acceptsCompleteIdentityClaims() {
|
||||
assertThat(validator.validate(jwt(true)).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingPermissionVersion() {
|
||||
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
private Jwt jwt(boolean includePermissionVersion) {
|
||||
Jwt.Builder builder = Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.subject("1001")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "2001")
|
||||
.claim("app_code", "PLATFORM");
|
||||
if (includePermissionVersion) {
|
||||
builder.claim("permission_version", 3L);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user