优化
This commit is contained in:
+6
-9
@@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
||||
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
|
||||
*/
|
||||
@AutoConfiguration(
|
||||
after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class},
|
||||
after = JwksJwtDecoderAutoConfiguration.class,
|
||||
before = SecurityAutoConfiguration.class)
|
||||
@EnableMethodSecurity
|
||||
public class BaiyueSecurityAutoConfiguration {
|
||||
@@ -50,16 +50,13 @@ 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.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken,
|
||||
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
|
||||
AccessTokenSessionValidator validator;
|
||||
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
|
||||
else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken);
|
||||
else {
|
||||
StringRedisTemplate template=redis.getIfAvailable();
|
||||
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
|
||||
validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper);
|
||||
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);
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
|
||||
}
|
||||
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */
|
||||
public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||
@Override public AccessTokenSession validate(Jwt jwt) {
|
||||
Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id")));
|
||||
return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")),
|
||||
values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions"))));
|
||||
}
|
||||
private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} }
|
||||
private static Set<String> values(Object value){ Set<String> result=new LinkedHashSet<>(); if(value instanceof Iterable<?> it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; }
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */
|
||||
public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||
public static final String TOKEN_HEADER = "X-Internal-Service-Token";
|
||||
public static final String ROLES_HEADER = "X-Overseas-Roles";
|
||||
public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities";
|
||||
|
||||
private final byte[] expectedToken;
|
||||
|
||||
public GatewayAccessTokenSessionValidator(String expectedToken) {
|
||||
if (expectedToken == null || expectedToken.isBlank()) {
|
||||
throw new IllegalStateException("session-mode=gateway requires an internal service token");
|
||||
}
|
||||
this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccessTokenSession validate(Jwt jwt) {
|
||||
throw new IllegalArgumentException("Gateway authorization headers are required");
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
|
||||
String supplied = request.getHeader(TOKEN_HEADER);
|
||||
if (supplied == null || supplied.isBlank()) {
|
||||
throw new IllegalArgumentException("Missing overseas gateway authorization context");
|
||||
}
|
||||
byte[] suppliedToken = supplied.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(expectedToken, suppliedToken)) {
|
||||
throw new IllegalArgumentException("Invalid overseas gateway authorization context (expectedBytes="
|
||||
+ expectedToken.length + ", suppliedBytes=" + suppliedToken.length + ")");
|
||||
}
|
||||
Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id")));
|
||||
if (userId == null) {
|
||||
userId = number(jwt.getSubject());
|
||||
}
|
||||
return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)),
|
||||
values(request.getHeader(AUTHORITIES_HEADER)));
|
||||
}
|
||||
|
||||
private static Long number(Object value) {
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
try { return value == null ? null : Long.valueOf(value.toString()); }
|
||||
catch (NumberFormatException exception) { return null; }
|
||||
}
|
||||
|
||||
private static Set<String> values(String header) {
|
||||
if (header == null || header.isBlank()) return Set.of();
|
||||
LinkedHashSet<String> values = new LinkedHashSet<>();
|
||||
for (String value : header.split(",")) {
|
||||
String normalized = value.trim();
|
||||
if (!normalized.isEmpty()) values.add(normalized);
|
||||
}
|
||||
return Set.copyOf(values);
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。
|
||||
*/
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true)
|
||||
public class HmacJwtDecoderAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(JwtDecoder.class)
|
||||
JwtDecoder jwtDecoder(@Value("${baiyue.security.jwt-secret}") String jwtSecret,
|
||||
@Value("${baiyue.security.issuer:auth-service}") String issuer) {
|
||||
byte[] secret = jwtSecret.getBytes(StandardCharsets.UTF_8);
|
||||
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256"))
|
||||
.macAlgorithm(MacAlgorithm.HS256).build();
|
||||
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer));
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -3,7 +3,6 @@ package com.baiyue.common.security;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
@@ -14,12 +13,11 @@ import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */
|
||||
/** 所有业务服务通过可轮换 JWKS 验证 RS256,会话由共享 Redis 校验。 */
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks")
|
||||
public class JwksJwtDecoderAutoConfiguration {
|
||||
@Bean @ConditionalOnMissingBean(JwtDecoder.class)
|
||||
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri,
|
||||
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri:${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}}") String uri,
|
||||
@Value("${baiyue.security.issuer:auth-service}") String issuer,
|
||||
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
|
||||
|
||||
@@ -28,10 +28,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final AuthenticationEntryPoint authenticationEntryPoint;
|
||||
private final AccessTokenSessionValidator sessionValidator;
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint authenticationEntryPoint) {
|
||||
this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null);
|
||||
}
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint authenticationEntryPoint,
|
||||
|
||||
-1
@@ -1,3 +1,2 @@
|
||||
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class GatewayAccessTokenSessionValidatorTest {
|
||||
private final GatewayAccessTokenSessionValidator validator =
|
||||
new GatewayAccessTokenSessionValidator("gateway-secret");
|
||||
|
||||
@Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
|
||||
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
|
||||
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
|
||||
"platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view");
|
||||
|
||||
AccessTokenSession session = validator.validate(jwt(), request);
|
||||
|
||||
assertThat(session.userId()).isEqualTo(42L);
|
||||
assertThat(session.roles()).containsExactly("PLATFORM_ADMIN");
|
||||
assertThat(session.permissions()).containsExactlyInAnyOrder(
|
||||
"platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view");
|
||||
}
|
||||
|
||||
@Test void rejectsForgedOrMissingGatewayToken() {
|
||||
MockHttpServletRequest forged = new MockHttpServletRequest();
|
||||
forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged");
|
||||
forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
|
||||
"platform:overseas-warehouse:last-mile-order:list");
|
||||
assertThatThrownBy(() -> validator.validate(jwt(), forged))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid overseas gateway");
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(jwt(), new MockHttpServletRequest()))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Missing overseas gateway");
|
||||
}
|
||||
|
||||
private Jwt jwt() {
|
||||
return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", 0L).claim("app_code", "PLATFORM")
|
||||
.claim("permission_version", 7L).claim("token_version", 1).build();
|
||||
}
|
||||
}
|
||||
+15
-8
@@ -29,7 +29,8 @@ class JwtAuthenticationFilterTest {
|
||||
.claim("user_id", 1L).build();
|
||||
JwtDecoder decoder = token -> jwt;
|
||||
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(decoder, new JwtClaimsMapper(),
|
||||
new JsonAuthenticationEntryPoint(new ObjectMapper()));
|
||||
new JsonAuthenticationEntryPoint(new ObjectMapper()),
|
||||
token -> new AccessTokenSession(1, 1L, java.util.Set.of(), java.util.Set.of()));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer valid");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
@@ -43,20 +44,25 @@ class JwtAuthenticationFilterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatesMinimalOverseasTokenWithGatewaySnapshot() throws Exception {
|
||||
void authenticatesUnifiedTokenWithServerSessionIgnoringGatewayHeaders() throws Exception {
|
||||
Jwt jwt = Jwt.withTokenValue("overseas").header("alg", "RS256").subject("42")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60))
|
||||
.claim("tenant_id", 0L).claim("app_code", "PLATFORM")
|
||||
.claim("jti", "session-42").claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM")
|
||||
.claim("permission_version", 7L).claim("token_version", 1).build();
|
||||
var redis = org.mockito.Mockito.mock(org.springframework.data.redis.core.StringRedisTemplate.class);
|
||||
var values = org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class);
|
||||
org.mockito.Mockito.when(redis.opsForValue()).thenReturn(values);
|
||||
org.mockito.Mockito.when(values.get("baiyue:auth:access:session-42"))
|
||||
.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 GatewayAccessTokenSessionValidator("gateway-secret"));
|
||||
new RedisAccessTokenSessionValidator(redis, "baiyue:auth"));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRequestURI("/api/platform/v1/last-leg/orders");
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas");
|
||||
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
|
||||
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
|
||||
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, "platform:lastleg:view");
|
||||
request.addHeader("X-Internal-Service-Token", "gateway-secret");
|
||||
request.addHeader("X-Overseas-Roles", "FORGED_ADMIN");
|
||||
request.addHeader("X-Overseas-Authorities", "platform:wallet:write");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicBoolean invoked = new AtomicBoolean();
|
||||
|
||||
@@ -64,7 +70,8 @@ class JwtAuthenticationFilterTest {
|
||||
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
assertThat(authentication).isNotNull();
|
||||
assertThat(authentication.getAuthorities()).extracting("authority")
|
||||
.contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view");
|
||||
.contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view")
|
||||
.doesNotContain("ROLE_FORGED_ADMIN", "platform:wallet:write");
|
||||
invoked.set(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.support.StaticListableBeanFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class UnifiedSessionModeTest {
|
||||
@Test
|
||||
void rejectsLegacySessionModesAndMissingRedisInsteadOfTrustingClaimsOrHeaders() {
|
||||
var configuration = new BaiyueSecurityAutoConfiguration();
|
||||
var beans = new StaticListableBeanFactory();
|
||||
var redis = beans.getBeanProvider(StringRedisTemplate.class);
|
||||
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))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user