fix: project overseas authorization outside jwt
This commit is contained in:
+1
-7
@@ -5,14 +5,13 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** 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", "token_version", "permission_version", "authorities");
|
||||
"sub", "tenant_id", "app_code", "token_version", "permission_version");
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
@@ -44,11 +43,6 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||
&& !("MERCHANT".equals(appCode) && tenantId > 0L)) {
|
||||
return failure("JWT app_code 与 tenant_id 访问域不一致");
|
||||
}
|
||||
Object authorities = jwt.getClaims().get("authorities");
|
||||
if (!(authorities instanceof Collection<?> values)
|
||||
|| values.stream().anyMatch(value -> value == null || value.toString().isBlank())) {
|
||||
return failure("JWT authorities 必须是权限列表");
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
|
||||
+14
-1
@@ -6,6 +6,8 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient {
|
||||
@@ -29,9 +31,20 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal
|
||||
return Mono.error(new IllegalStateException("海外安全投影响应无效"));
|
||||
}
|
||||
return Mono.just(new Result(data.path("valid").asBoolean(false),
|
||||
data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null)));
|
||||
data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null),
|
||||
strings(data.path("roles")), strings(data.path("authorities"))));
|
||||
});
|
||||
}
|
||||
|
||||
private static Set<String> strings(JsonNode values) {
|
||||
if (values == null || !values.isArray()) return Set.of();
|
||||
LinkedHashSet<String> result = new LinkedHashSet<>();
|
||||
values.forEach(value -> {
|
||||
String text = value.asText("");
|
||||
if (!text.isBlank()) result.add(text);
|
||||
});
|
||||
return Set.copyOf(result);
|
||||
}
|
||||
|
||||
private record Request(long userId, long tenantId, long permissionVersion) { }
|
||||
}
|
||||
|
||||
+19
-2
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.baiyue.gateway.config.GatewaySecurityConfig;
|
||||
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
@@ -11,15 +12,20 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.Collection;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
|
||||
private final OverseasSecurityValidationClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final OverseasSecurityProjectionProperties properties;
|
||||
|
||||
public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) {
|
||||
public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper,
|
||||
OverseasSecurityProjectionProperties properties) {
|
||||
this.client = client;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -45,7 +51,14 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
|
||||
return client.validate(userId, tenantId, permissionVersion)
|
||||
.switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用")))
|
||||
.flatMap(result -> {
|
||||
if (result.valid()) return chain.filter(exchange);
|
||||
if (result.valid()) {
|
||||
var request = exchange.getRequest().mutate()
|
||||
.header("X-Overseas-Roles", join(result.roles()))
|
||||
.header("X-Overseas-Authorities", join(result.authorities()))
|
||||
.header("X-Gateway-Internal-Token", properties.internalServiceToken())
|
||||
.build();
|
||||
return chain.filter(exchange.mutate().request(request).build());
|
||||
}
|
||||
if ("USER_DISABLED".equals(result.failureReason())) {
|
||||
return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用"));
|
||||
}
|
||||
@@ -64,6 +77,10 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
|
||||
catch (NumberFormatException exception) { return null; }
|
||||
}
|
||||
|
||||
private static String join(Collection<String> values) {
|
||||
return values.stream().sorted().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
@Override public int getOrder() { return -95; }
|
||||
|
||||
private static final class SecurityProjectionFailure extends RuntimeException {
|
||||
|
||||
+7
-1
@@ -1,8 +1,14 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.Set;
|
||||
|
||||
public interface OverseasSecurityValidationClient {
|
||||
Mono<Result> validate(long userId, long tenantId, long permissionVersion);
|
||||
record Result(boolean valid, String failureReason) { }
|
||||
record Result(boolean valid, String failureReason, Set<String> roles, Set<String> authorities) {
|
||||
public Result {
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -18,7 +18,8 @@ import java.util.List;
|
||||
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"
|
||||
"X-App-Code", "X-Client-Type", "X-Overseas-Roles", "X-Overseas-Authorities",
|
||||
"X-Gateway-Internal-Token"
|
||||
);
|
||||
|
||||
@Override
|
||||
@@ -70,4 +71,3 @@ public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
|
||||
@Override public String getName() { return "anonymous"; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -26,19 +26,19 @@ class RequiredClaimsJwtValidatorTest {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM")
|
||||
.claim("permission_version", 3L).claim("authorities", List.of())
|
||||
.claim("permission_version", 3L)
|
||||
.build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingAuthorities() {
|
||||
void acceptsTokenWithoutAuthoritiesBecauseProjectionOwnsAuthorization() {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1)
|
||||
.claim("permission_version", 3L)
|
||||
.build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
assertThat(validator.validate(value).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+21
-7
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
@@ -13,7 +14,9 @@ import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -23,11 +26,15 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class OverseasSecurityProjectionFilterTest {
|
||||
private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class);
|
||||
private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper());
|
||||
private final OverseasSecurityProjectionProperties properties =
|
||||
new OverseasSecurityProjectionProperties("http://lastleg",
|
||||
"gateway-internal-test-token-32-bytes", Duration.ofSeconds(2));
|
||||
private final OverseasSecurityProjectionFilter filter =
|
||||
new OverseasSecurityProjectionFilter(client, new ObjectMapper(), properties);
|
||||
|
||||
@Test void disabledUserFailsWithA0401() {
|
||||
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(false, "USER_DISABLED")));
|
||||
new OverseasSecurityValidationClient.Result(false, "USER_DISABLED", Set.of(), Set.of())));
|
||||
var exchange = exchange("/api/platform/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
@@ -37,7 +44,7 @@ class OverseasSecurityProjectionFilterTest {
|
||||
@Test void disabledTenantAndStalePermissionFailWithA0403() {
|
||||
for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) {
|
||||
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(false, reason)));
|
||||
new OverseasSecurityValidationClient.Result(false, reason, Set.of(), Set.of())));
|
||||
var exchange = exchange("/api/merchant/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
@@ -54,15 +61,22 @@ class OverseasSecurityProjectionFilterTest {
|
||||
|
||||
@Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() {
|
||||
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(true, null)));
|
||||
new OverseasSecurityValidationClient.Result(true, null, Set.of("PLATFORM_ADMIN"),
|
||||
Set.of("platform:overseas-warehouse:last-mile-order:list"))));
|
||||
AtomicBoolean routed = new AtomicBoolean();
|
||||
StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> {
|
||||
routed.set(true); return Mono.empty();
|
||||
var accepted = exchange("/api/platform/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(accepted, routedExchange -> {
|
||||
routed.set(true);
|
||||
assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Gateway-Internal-Token"))
|
||||
.isEqualTo("gateway-secret");
|
||||
assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Overseas-Authorities"))
|
||||
.isEqualTo("platform:overseas-warehouse:last-mile-order:list");
|
||||
return Mono.empty();
|
||||
})).verifyComplete();
|
||||
assertThat(routed).isTrue();
|
||||
|
||||
var isolatedClient = mock(OverseasSecurityValidationClient.class);
|
||||
var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper());
|
||||
var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper(), properties);
|
||||
StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete();
|
||||
verifyNoInteractions(isolatedClient);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user