feat(phase3): 收口父子单与全链路验收
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Server-side access session and its current authorization snapshot. */
|
||||
public record AccessTokenSession(int version, Long userId, Set<String> roles,
|
||||
Set<String> permissions) {
|
||||
public AccessTokenSession {
|
||||
if (version != 1 || userId == null || userId <= 0) {
|
||||
throw new IllegalArgumentException("Invalid access token session");
|
||||
}
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,5 +7,5 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AccessTokenSessionValidator {
|
||||
void validate(Jwt jwt);
|
||||
AccessTokenSession validate(Jwt jwt);
|
||||
}
|
||||
|
||||
+2
-1
@@ -48,9 +48,10 @@ public class BaiyueSecurityAutoConfiguration {
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint entryPoint,
|
||||
StringRedisTemplate redisTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint,
|
||||
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix));
|
||||
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix, objectMapper));
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+3
-3
@@ -27,7 +27,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint authenticationEntryPoint) {
|
||||
this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> { });
|
||||
this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null);
|
||||
}
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
@@ -55,8 +55,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String tokenValue = header.substring(BEARER.length()).trim();
|
||||
try {
|
||||
Jwt jwt = jwtDecoder.decode(tokenValue);
|
||||
sessionValidator.validate(jwt);
|
||||
Authentication authentication = claimsMapper.map(jwt);
|
||||
AccessTokenSession session = sessionValidator.validate(jwt);
|
||||
Authentication authentication = claimsMapper.map(jwt, session);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (JwtException | IllegalArgumentException exception) {
|
||||
|
||||
@@ -13,11 +13,15 @@ import java.util.Set;
|
||||
*/
|
||||
public class JwtClaimsMapper {
|
||||
public BaiyueJwtAuthenticationToken map(Jwt jwt) {
|
||||
return map(jwt, null);
|
||||
}
|
||||
|
||||
public BaiyueJwtAuthenticationToken map(Jwt jwt, AccessTokenSession session) {
|
||||
Set<String> permissions = new LinkedHashSet<>();
|
||||
addClaimValues(permissions, jwt.getClaims().get("authorities"));
|
||||
addClaimValues(permissions, jwt.getClaims().get("permissions"));
|
||||
addClaimValues(permissions, jwt.getClaims().get("scope"));
|
||||
addRoles(permissions, jwt.getClaims().get("roles"));
|
||||
if (session != null) {
|
||||
permissions.addAll(session.permissions());
|
||||
addRoles(permissions, session.roles());
|
||||
}
|
||||
|
||||
String appCode = jwt.getClaimAsString("app_code");
|
||||
if (appCode != null && !appCode.isBlank()) {
|
||||
|
||||
+28
-2
@@ -2,6 +2,7 @@ package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Redis 访问令牌会话校验器,根据 JWT ID 判断登录会话是否仍然有效。
|
||||
@@ -9,24 +10,49 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
||||
public final class RedisAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final String keyPrefix;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) {
|
||||
this(redisTemplate, keyPrefix, new ObjectMapper());
|
||||
}
|
||||
|
||||
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix,
|
||||
ObjectMapper objectMapper) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Jwt jwt) {
|
||||
public AccessTokenSession validate(Jwt jwt) {
|
||||
String jwtId = jwt.getId();
|
||||
if (jwtId == null || jwtId.isBlank()) {
|
||||
throw new IllegalArgumentException("Access token has no jti");
|
||||
}
|
||||
try {
|
||||
if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) {
|
||||
String value = redisTemplate.opsForValue().get(RedisTokenKeys.access(keyPrefix, jwtId));
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("Access token session is revoked or expired");
|
||||
}
|
||||
AccessTokenSession session = objectMapper.readValue(value, AccessTokenSession.class);
|
||||
Long userId = longClaim(jwt, "user_id");
|
||||
if (userId == null || !userId.equals(session.userId())) {
|
||||
throw new IllegalArgumentException("Access token session identity mismatch");
|
||||
}
|
||||
return session;
|
||||
} catch (org.springframework.dao.DataAccessException exception) {
|
||||
throw new IllegalArgumentException("Token session store is unavailable", exception);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalArgumentException("Invalid access token session", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Long longClaim(Jwt jwt, String name) {
|
||||
Object value = jwt.getClaims().get(name);
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
if (value instanceof String text) try { return Long.valueOf(text); } catch (NumberFormatException ignored) { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,31 @@ class JwtClaimsMapperTest {
|
||||
.claim("roles", List.of("ADMIN"))
|
||||
.build();
|
||||
|
||||
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt);
|
||||
AccessTokenSession session = new AccessTokenSession(1, 42L,
|
||||
java.util.Set.of("ADMIN"), java.util.Set.of("order:read", "order:create"));
|
||||
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt, session);
|
||||
assertThat(authentication.getPrincipal().userId()).isEqualTo(42L);
|
||||
assertThat(authentication.getPrincipal().tenantId()).isEqualTo(7L);
|
||||
assertThat(authentication.getAuthorities()).extracting("authority")
|
||||
.contains("order:read", "order:create", "ROLE_ADMIN", "APP_MERCHANT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresAuthorizationClaimsInJwt() {
|
||||
Jwt jwt = Jwt.withTokenValue("token").header("alg", "none").subject("42")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60))
|
||||
.claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM")
|
||||
.claim("permissions", List.of("platform:forged"))
|
||||
.claim("roles", List.of("FORGED")).build();
|
||||
|
||||
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt,
|
||||
new AccessTokenSession(1, 42L, java.util.Set.of(), java.util.Set.of("platform:real")));
|
||||
|
||||
assertThat(authentication.getAuthorities()).extracting("authority")
|
||||
.contains("platform:real", "APP_PLATFORM")
|
||||
.doesNotContain("platform:forged", "ROLE_FORGED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToNumericSubjectForUserId() {
|
||||
Jwt jwt = Jwt.withTokenValue("token")
|
||||
|
||||
+39
-3
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.RedisConnectionFailureException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -18,7 +19,10 @@ class RedisAccessTokenSessionValidatorTest {
|
||||
@Test
|
||||
void acceptsActiveSession() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(true);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1"))
|
||||
.thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}");
|
||||
|
||||
assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1"))).doesNotThrowAnyException();
|
||||
@@ -27,7 +31,9 @@ class RedisAccessTokenSessionValidatorTest {
|
||||
@Test
|
||||
void rejectsRevokedSession() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(false);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1")).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1")))
|
||||
@@ -38,7 +44,9 @@ class RedisAccessTokenSessionValidatorTest {
|
||||
@Test
|
||||
void failsClosedWhenRedisIsUnavailable() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1"))
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1"))
|
||||
.thenThrow(new RedisConnectionFailureException("offline"));
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
@@ -47,9 +55,37 @@ class RedisAccessTokenSessionValidatorTest {
|
||||
.hasMessageContaining("unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSessionForAnotherUser() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1"))
|
||||
.thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}");
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("identity mismatch");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedSessionJson() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json");
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Invalid access token session");
|
||||
}
|
||||
|
||||
private Jwt jwt(String jwtId) {
|
||||
return Jwt.withTokenValue("token").header("alg", "none")
|
||||
.subject("1").claim("jti", jwtId).issuedAt(Instant.now())
|
||||
.claim("user_id", 1L)
|
||||
.expiresAt(Instant.now().plusSeconds(60)).build();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user