You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot3.2+Security6中WebFlux Security无法从JWT提取角色

问题解决:Spring Cloud Gateway(响应式栈)中自定义JWT Converter提取角色控制Actuator访问

环境

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 17:09:50