This commit is contained in:
2026-09-29 18:47:37 +08:00
parent 2c60a29e0f
commit c2927ab9a6
291 changed files with 4008 additions and 6298 deletions
+3 -2
View File
@@ -13,7 +13,7 @@
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) |
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` |
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java` |
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java`;`HttpSessionStateVerifier.java` 每请求调用 Auth 权威状态校验,超时/错误失败关闭 |
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
| 安全回归测试 | `src/test/java/com/baiyue/common/security/` |
@@ -24,5 +24,6 @@
- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。
- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或会话已撤销时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
- Access 先校验 Redis 会话身份,再通过 `baiyue.security.session-validation-uri` 在线核对 Auth/IAM 持久化状态;禁止正结果缓存、失败降级或请求头信任。内部调用只带服务令牌和 jwtId/userId,不透传 Bearer;4 秒总超时、1 秒连接超时、不跟随重定向。
- 不降低服务端客户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。
@@ -50,13 +50,16 @@ public class BaiyueSecurityAutoConfiguration {
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
ObjectMapper objectMapper,
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
@Value("${baiyue.security.session-mode:redis}") String sessionMode,
@Value("${baiyue.security.session-validation-uri:http://auth:9100/internal/auth/accounts/sessions/validate}") String validationUri,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
if (!"redis".equalsIgnoreCase(sessionMode)) {
throw new IllegalStateException("Only shared Redis access sessions are supported");
}
StringRedisTemplate template = redis.getIfAvailable();
if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate");
AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper);
AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper,
new HttpSessionStateVerifier(validationUri, internalToken, objectMapper));
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
}
@@ -0,0 +1,44 @@
package com.baiyue.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/** Bounded, non-redirecting internal call. Never forwards an end-user Authorization header. */
public final class HttpSessionStateVerifier implements SessionStateVerifier {
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1))
.followRedirects(HttpClient.Redirect.NEVER).build();
private final URI uri;
private final String token;
private final ObjectMapper mapper;
public HttpSessionStateVerifier(String uri, String token, ObjectMapper mapper) {
this.uri = URI.create(uri);
this.token = token;
this.mapper = mapper;
}
@Override public void verify(String jwtId, Long userId) {
try {
var request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(4))
.header("Content-Type", "application/json").header("X-Internal-Service-Token", token)
.POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(
Map.of("jwtId", jwtId, "userId", userId)))).build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var body = mapper.readTree(response.body());
if (response.statusCode() != 200 || body == null || !body.path("code").isTextual() || !"200".equals(body.path("code").asText())
|| !body.path("data").isBoolean() || !body.path("data").booleanValue()) {
throw new IllegalArgumentException("Authorization state is revoked or unavailable");
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalArgumentException("Authorization state validation interrupted", failure);
} catch (Exception failure) {
throw new IllegalArgumentException("Authorization state is revoked or unavailable", failure);
}
}
}
@@ -12,15 +12,14 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio
private final String keyPrefix;
private final ObjectMapper objectMapper;
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) {
this(redisTemplate, keyPrefix, new ObjectMapper());
}
private final SessionStateVerifier stateVerifier;
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix,
ObjectMapper objectMapper) {
ObjectMapper objectMapper, SessionStateVerifier stateVerifier) {
this.redisTemplate = redisTemplate;
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
this.objectMapper = objectMapper;
this.stateVerifier = java.util.Objects.requireNonNull(stateVerifier);
}
@Override
@@ -39,6 +38,7 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio
if (userId == null || !userId.equals(session.userId())) {
throw new IllegalArgumentException("Access token session identity mismatch");
}
stateVerifier.verify(jwtId, userId);
return session;
} catch (org.springframework.dao.DataAccessException exception) {
throw new IllegalArgumentException("Token session store is unavailable", exception);
@@ -0,0 +1,7 @@
package com.baiyue.common.security;
/** Verifies persisted authorization state, without positive caching. */
@FunctionalInterface
public interface SessionStateVerifier {
void verify(String jwtId, Long userId);
}
@@ -0,0 +1,45 @@
package com.baiyue.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.*;
class HttpSessionStateVerifierTest {
@Test void acceptsOnlyExplicitSuccessAndNeverForwardsBearer() throws Exception {
var body = new AtomicReference<>("{\"code\":\"200\",\"data\":true}");
var auth = new AtomicReference<String>();
var internal = new AtomicReference<String>();
var input = new AtomicReference<String>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/validate", exchange -> {
auth.set(exchange.getRequestHeaders().getFirst("Authorization"));
internal.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
input.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
try {
var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + server.getAddress().getPort() + "/validate", "internal-only", new ObjectMapper());
verifier.verify("session", 42L);
assertThat(auth.get()).isNull(); assertThat(internal.get()).isEqualTo("internal-only");
assertThat(new ObjectMapper().readTree(input.get()).path("userId").longValue()).isEqualTo(42);
for (String rejected : new String[]{"{}", "{\"code\":200,\"data\":true}", "{\"code\":\"200\",\"data\":false}",
"{\"code\":\"A0403\",\"data\":true}", "{\"code\":\"200\",\"data\":\"true\"}", "not-json"}) {
body.set(rejected);
assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class);
}
} finally { server.stop(0); }
}
@Test void unavailableAuthorityFailsClosed() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
int port = server.getAddress().getPort(); server.start(); server.stop(0);
var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + port + "/validate", "internal", new ObjectMapper());
assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class);
}
}
@@ -56,7 +56,7 @@ class JwtAuthenticationFilterTest {
.thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}");
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(),
new JsonAuthenticationEntryPoint(new ObjectMapper()),
new RedisAccessTokenSessionValidator(redis, "baiyue:auth"));
new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), org.mockito.Mockito.mock(SessionStateVerifier.class)));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/platform/v1/last-leg/orders");
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas");
@@ -24,7 +24,7 @@ class RedisAccessTokenSessionValidatorTest {
when(values.get("baiyue:auth:access:jti-1"))
.thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}");
assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1"))).doesNotThrowAnyException();
}
@@ -35,7 +35,7 @@ class RedisAccessTokenSessionValidatorTest {
when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1")).thenReturn(null);
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("revoked or expired");
@@ -49,7 +49,7 @@ class RedisAccessTokenSessionValidatorTest {
when(values.get("baiyue:auth:access:jti-1"))
.thenThrow(new RedisConnectionFailureException("offline"));
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("unavailable");
@@ -63,7 +63,7 @@ class RedisAccessTokenSessionValidatorTest {
when(values.get("baiyue:auth:access:jti-1"))
.thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}");
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("identity mismatch");
@@ -76,7 +76,7 @@ class RedisAccessTokenSessionValidatorTest {
when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json");
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid access token session");
@@ -15,7 +15,7 @@ class UnifiedSessionModeTest {
for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) {
assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null,
new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()),
redis, new ObjectMapper(), "baiyue:auth", mode))
redis, new ObjectMapper(), "baiyue:auth", mode, "http://auth:9100/internal/auth/accounts/sessions/validate", "test-token"))
.isInstanceOf(IllegalStateException.class);
}
}