Spring Boot3.2+Security6中WebFlux Security无法从JWT提取角色
环境
Spring Boot 3.2、Spring Security 6
问题描述
我将Spring Cloud Gateway作为包含Actuator端点的OAuth2资源服务器,想要通过JWT中的角色限制Actuator端点的访问权限。该功能已在非响应式栈的微服务中实现,但Gateway不支持starter-web,找不到响应式栈对应的JWT Converter。
目前已通过EnableWebFluxSecurity配置ServerHttpSecurity,期望基于JWT角色控制Actuator访问,但调试时发现AbstractAuthenticationToken仅包含以“SCOPE_”开头的权限,未提取到JWT中的用户角色。
相关配置及代码
pom依赖
<!--#### SECURITY ###--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <!--#### REST ###--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>
配置属性
spring: main: allow-bean-definition-overriding: true web-application-type: reactive security: oauth2: resourceserver: jwt: issuer-uri: "http://localhost:${env.idp-port}/realms/osint-realm" jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs jwt: token: converter: resource-id: osint-keycloak-client principal-attribute: preferred_username
初始服务器过滤器代码
@Configuration @EnableWebFluxSecurity public class WebFluxSecurityConfig { @Value("${spring.application.name}") private String appName; private final CustomJwtTokenConverter customJwtTokenConverter; public WebFluxSecurityConfig(TokenConverterProperties tokenConverterProperties) { JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); this.customJwtTokenConverter = new CustomJwtTokenConverter(jwtGrantedAuthoritiesConverter, tokenConverterProperties); } @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity serverHttpSecurity) { serverHttpSecurity .csrf(ServerHttpSecurity.CsrfSpec::disable) .formLogin(ServerHttpSecurity.FormLoginSpec::disable) .authorizeExchange(exchange -> { exchange.pathMatchers("/eureka/**").permitAll(); exchange.pathMatchers("/" + appName + "/actuator/**").hasRole("actuator"); exchange.anyExchange().authenticated(); }) // 这里只获取了scope,没获取角色? .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults())); return serverHttpSecurity.build(); } }
我认为问题出在JWT的Customizer.withDefaults()上,它仅获取了SCOPE_profile、SCOPE_email这类权限(但我的JWT Token中并没有这些)。尝试参考Spring Security 6.1.4文档实现自定义JWT Converter,但文档示例不够清晰。
自定义JWT Converter实现
public class CustomJwtTokenConverter implements Converter<Jwt, AbstractAuthenticationToken> { private static final String RESOURCE_ACCESS = "resource_access"; private static final String ROLES = "roles"; protected static final String ROLE_PREFIX = "ROLE_"; private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter; private final TokenConverterProperties properties; public CustomJwtTokenConverter( JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter, TokenConverterProperties properties) { this.jwtGrantedAuthoritiesConverter = jwtGrantedAuthoritiesConverter; this.properties = properties; } @Override public AbstractAuthenticationToken convert(@NonNull Jwt jwt) { List<Collection<String>> roleResources = Optional.of(jwt) .map(token -> token.getClaimAsMap(RESOURCE_ACCESS)) .map(claimMap -> (Map<String, Object>) claimMap.get(properties.getResourceId())) .map(resourceData -> (Collection<String>) resourceData.get(ROLES)) .stream().collect(Collectors.toList()); List<SimpleGrantedAuthority> simpleGrantedAuthorities = new ArrayList<>(); roleResources.forEach(role -> role.forEach(roleValue -> simpleGrantedAuthorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + roleValue)))); Stream<SimpleGrantedAuthority> accesses = simpleGrantedAuthorities.stream().distinct(); Set<GrantedAuthority> authorities = Stream .concat(jwtGrantedAuthoritiesConverter.convert(jwt).stream(), accesses) .collect(Collectors.toSet()); String principalClaimName = properties.getPrincipalAttribute() .map(jwt::getClaimAsString) .orElse(jwt.getClaimAsString(JwtClaimNames.SUB)); return new JwtAuthenticationToken(jwt, authorities, principalClaimName); } }
修改后的过滤器代码
@Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity serverHttpSecurity) { serverHttpSecurity .csrf(ServerHttpSecurity.CsrfSpec::disable) .formLogin(ServerHttpSecurity.FormLoginSpec::disable) .authorizeExchange(exchange -> { exchange.pathMatchers("/eureka/**").permitAll(); exchange.pathMatchers("/" + appName + "/actuator/**").hasRole("actuator"); exchange.anyExchange().authenticated(); }) // 这里只获取了scope,没获取角色? .oauth2ResourceServer(oauth2 -> oauth2 // .jwt(Customizer.withDefaults())); .jwt(jwtSpec -> jwtSpec.jwtAuthenticationConverter(this.customJwtTokenConverter))); return serverHttpSecurity.build(); }
解决方案
你的自定义Converter逻辑方向正确,但有几个关键点需要调整,确保在响应式栈中正确生效:
1. 修复角色提取逻辑
当前roleResources的收集方式存在冗余,Optional为空时会得到空列表,存在时会把单个角色集合包装成List,导致后续遍历多一层。简化为直接获取角色集合:
Collection<String> roles = Optional.ofNullable(jwt.getClaimAsMap(RESOURCE_ACCESS)) .map(claimMap -> (Map<String, Object>) claimMap.get(properties.getResourceId())) .map(resourceData -> (Collection<String>) resourceData.get(ROLES)) .orElse(Collections.emptyList());
后续转换权限的代码也可以简化:
List<SimpleGrantedAuthority> roleAuthorities = roles.stream() .map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role)) .distinct() .collect(Collectors.toList());
2. 将Converter注册为Spring Bean
目前你在构造函数中实例化CustomJwtTokenConverter,建议将其声明为Bean由Spring管理,避免上下文问题:
@Bean public CustomJwtTokenConverter customJwtTokenConverter(TokenConverterProperties properties) { JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter(); // 若不需要默认的SCOPE权限,可关闭:defaultConverter.setEnableAuthorities(false); return new CustomJwtTokenConverter(defaultConverter, properties); }
然后在配置类中注入该Bean,而非在构造函数中创建。
3. 确认hasRole前缀匹配
Spring Security的hasRole("actuator")会自动添加ROLE_前缀,因此你的Converter中添加ROLE_actuator后,配置里的hasRole("actuator")是正确的,无需额外调整。
4. 调试验证JWT内容
在Converter中打印JWT的所有Claims,确认resource_access下的osint-keycloak-client和roles字段是否存在:
System.out.println("JWT Claims: " + jwt.getClaims());
确保Keycloak确实在JWT中返回了对应的角色信息。
5. 禁用默认Scope提取(可选)
如果你的JWT中没有Scope信息,或不需要这些权限,可以关闭默认转换器的Scope提取:
defaultConverter.setEnableAuthorities(false);
避免生成多余的SCOPE_权限。
调整后的完整代码
配置类
@Configuration @EnableWebFluxSecurity public class WebFluxSecurityConfig { @Value("${spring.application.name}") private String appName; private final CustomJwtTokenConverter customJwtTokenConverter; public WebFluxSecurityConfig(CustomJwtTokenConverter customJwtTokenConverter) { this.customJwtTokenConverter = customJwtTokenConverter; } @Bean public CustomJwtTokenConverter customJwtTokenConverter(TokenConverterProperties properties) { JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter(); // 禁用默认的Scope权限提取(可选) defaultConverter.setEnableAuthorities(false); return new CustomJwtTokenConverter(defaultConverter, properties); } @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity serverHttpSecurity) { serverHttpSecurity .csrf(ServerHttpSecurity.CsrfSpec::disable) .formLogin(ServerHttpSecurity.FormLoginSpec::disable) .authorizeExchange(exchange -> { exchange.pathMatchers("/eureka/**").permitAll(); exchange.pathMatchers("/" + appName + "/actuator/**").hasRole("actuator"); exchange.anyExchange().authenticated(); }) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwtSpec -> jwtSpec.jwtAuthenticationConverter(customJwtTokenConverter))); return serverHttpSecurity.build(); } }
自定义JWT Converter
public class CustomJwtTokenConverter implements Converter<Jwt, AbstractAuthenticationToken> { private static final String RESOURCE_ACCESS = "resource_access"; private static final String ROLES = "roles"; protected static final String ROLE_PREFIX = "ROLE_"; private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter; private final TokenConverterProperties properties; public CustomJwtTokenConverter( JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter, TokenConverterProperties properties) { this.jwtGrantedAuthoritiesConverter = jwtGrantedAuthoritiesConverter; this.properties = properties; } @Override public AbstractAuthenticationToken convert(@NonNull Jwt jwt) { // 提取resource_access中的角色 Collection<String> roles = Optional.ofNullable(jwt.getClaimAsMap(RESOURCE_ACCESS)) .map(claimMap -> (Map<String, Object>) claimMap.get(properties.getResourceId())) .map(resourceData -> (Collection<String>) resourceData.get(ROLES)) .orElse(Collections.emptyList()); // 转换为GrantedAuthority List<SimpleGrantedAuthority> roleAuthorities = roles.stream() .map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role)) .distinct() .collect(Collectors.toList()); // 合并默认转换器的权限(如果启用了的话) Set<GrantedAuthority> authorities = Stream .concat(jwtGrantedAuthoritiesConverter.convert(jwt).stream(), roleAuthorities.stream()) .collect(Collectors.toSet()); // 设置Principal String principalClaimName = properties.getPrincipalAttribute() .map(jwt::getClaimAsString) .orElse(jwt.getClaimAsString(JwtClaimNames.SUB)); return new JwtAuthenticationToken(jwt, authorities, principalClaimName); } }
调整后,即可正确从JWT的resource_access字段中提取角色,并添加到Authentication的权限列表中,让hasRole("actuator")规则生效,实现Actuator端点的权限控制。
内容的提问来源于stack exchange,提问作者B Randall

