feat(phase3): 收口父子单与全链路验收

This commit is contained in:
2026-08-10 12:23:43 +08:00
parent b294695169
commit 75d5fd4e26
55 changed files with 1110 additions and 115 deletions
@@ -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);
}
}
@@ -7,5 +7,5 @@ import org.springframework.security.oauth2.jwt.Jwt;
*/
@FunctionalInterface
public interface AccessTokenSessionValidator {
void validate(Jwt jwt);
AccessTokenSession validate(Jwt jwt);
}
@@ -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
@@ -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()) {
@@ -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;
}
}