2. 背景

当配置正确的Spring Security应用收到请求时,会经过一系列步骤,核心目标是:

  • 认证请求,确定访问者身份
  • 判断认证后的请求是否有权执行对应操作

对于使用JWT作为主要安全机制的应用,授权流程包含:

  • 从JWT载荷中提取声明值(通常是scopescp声明)
  • 将这些声明映射为GrantedAuthority对象集合

一旦安全引擎设置好这些权限,就能评估当前请求的访问限制并决定是否放行

3. 默认映射机制

开箱即用时,Spring采用直接策略将声明转换为GrantedAuthority实例:

  1. 提取scopescp声明并拆分为字符串列表
  2. 为每个字符串创建SimpleGrantedAuthority,使用SCOPE_前缀+scope值

通过以下接口验证默认行为:

@RestController
@RequestMapping("/user")
public class UserRestController {
    
    @GetMapping("/authorities")
    public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
        
        Collection<String> authorities = principal.getAuthorities()
          .stream()
          .map(GrantedAuthority::getAuthority)
          .collect(Collectors.toList());
        
        Map<String,Object> info = new HashMap<>();
        info.put("name", principal.getName());
        info.put("authorities", authorities);
        info.put("tokenAttributes", principal.getTokenAttributes());

        if ( principal instanceof AccountToken ) {
          info.put( "account", ((AccountToken)principal).getAccount());
        }

        return info;
    }
}

当传入包含以下载荷的JWT时:

{
  "aud": "api://f84f66ca-591f-4504-960a-3abc21006b45",
  "iss": "https://sts.windows.net/2e9fde3a-38ec-44f9-8bcd-c184dc1e8033/",
  "iat": 1648512013,
  "nbf": 1648512013,
  "exp": 1648516868,
  "email": "philippe.sevestre@techcorp.com",
  "family_name": "Sevestre",
  "given_name": "Philippe",
  "name": "Philippe Sevestre",
  "scp": "profile.read",
  "sub": "eXWysuqIJmK1yDywH3gArS98PVO1SV67BLt-dvmQ-pM"
}

响应结果:

{
  "tokenAttributes": { },
  "name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "authorities": [
    "SCOPE_profile",
    "SCOPE_email",
    "SCOPE_openid"
  ]
}

可通过SecurityFilterChain限制访问:

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    // @formatter:off
    return http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
                                .jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService)))).build();
    // @formatter:on
}

注意:刻意避免使用WebSecurityConfigureAdapter,因其在Spring Security 5.7中已废弃

也可使用方法级注解:

@GetMapping("/authorities")
@PreAuthorize("hasAuthority('SCOPE_profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
    // 同上代码
}

4. 自定义SCOPE_前缀

修改前缀需要配置两个核心类:

  • JwtAuthenticationConverter:将原始JWT转换为AbstractAuthenticationToken
  • JwtGrantedAuthoritiesConverter:从JWT提取GrantedAuthority集合

最简单的方式是提供自定义JwtAuthenticationConverter Bean

@Configuration
@EnableConfigurationProperties(JwtMappingProperties.class)
@EnableMethodSecurity
public class SecurityConfig {
    // 省略字段和构造器
    @Bean
    public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
        JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
        if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
            converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix().trim());
        }
        return converter;
    }
    
    @Bean
    public JwtAuthenticationConverter customJwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());
        return converter;
    }
}

当设置authorities-prefix=MY_SCOPE_后,/user/authorities返回:

{
  "tokenAttributes": { },
  "name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "authorities": [
    "MY_SCOPE_profile",
    "MY_SCOPE_email",
    "MY_SCOPE_openid"
  ]
}

5. 在安全构造中使用自定义前缀

修改权限前缀会影响所有依赖名称的授权规则。例如:

  • @PreAuthorize表达式
  • HttpSecurity的授权配置

解决方案:创建前缀Bean并在SpEL中引用:

@Bean
public String jwtGrantedAuthoritiesPrefix() {
  return mappingProps.getAuthoritiesPrefix() != null ?
    mappingProps.getAuthoritiesPrefix() : 
      "SCOPE_";
}

在注解中使用:

@GetMapping("/authorities")
@PreAuthorize("hasAuthority(@jwtGrantedAuthoritiesPrefix + 'profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
    // 省略实现
}

SecurityFilterChain中使用:

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    return http.authorizeHttpRequests(auth -> {
        auth.requestMatchers("/user/**")
          .hasAuthority(mappingProps.getAuthoritiesPrefix() + "profile");
      })
      .build();
}

6. 自定义Principal名称

当JWT的sub声明值不友好时(如Keycloak生成的UUID),可指定其他声明作为principal名称:

{
  "sub": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "scope": "openid profile email",
  "preferred_username": "user1",
  "name": "User Primo"
}

通过设置principalClaimName属性修改

@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());

    if (StringUtils.hasText(mappingProps.getPrincipalClaimName())) {
        converter.setPrincipalClaimName(mappingProps.getPrincipalClaimName());
    }
    return converter;
}

设置principal-claim-name=preferred_username后,响应变为:

{
  "tokenAttributes": { },
  "name": "user1",
  "authorities": [
    "MY_SCOPE_profile",
    "MY_SCOPE_email",
    "MY_SCOPE_openid"
  ]
}

7. Scope名称映射

当需要将JWT中的scope名称映射为内部名称时(如适配不同认证服务器),需自定义转换器:

public class MappingJwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
    private static Collection<String> WELL_KNOWN_AUTHORITIES_CLAIM_NAMES = Arrays.asList("scope", "scp");
    private Map<String,String> scopes;
    private String authoritiesClaimName = null;
    private String authorityPrefix = "SCOPE_";
     
    // 省略构造器和setter

    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        Collection<String> tokenScopes = parseScopesClaim(jwt);
        if (tokenScopes.isEmpty()) {
            return Collections.emptyList();
        }
        
        return tokenScopes.stream()
          .map(s -> scopes.getOrDefault(s, s))  // 关键映射步骤
          .map(s -> this.authorityPrefix + s)
          .map(SimpleGrantedAuthority::new)
          .collect(Collectors.toCollection(HashSet::new));
    }
    
    protected Collection<String> parseScopesClaim(Jwt jwt) {
       // 省略解析逻辑
    }
}

在配置类中使用:

@Bean
public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
    MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(mappingProps.getScopes());

    if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
        converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix());
    }
    if (StringUtils.hasText(mappingProps.getAuthoritiesClaimName())) {
        converter.setAuthoritiesClaimName(mappingProps.getAuthoritiesClaimName());
    }
    return converter;
}

8. 使用自定义JwtAuthenticationConverter

当需要完全控制JwtAuthenticationToken生成过程(如添加数据库数据)时,可采用两种方案:

方案一:自定义Bean(需继承JwtAuthenticationConverter 方案二:通过DSL配置(推荐)

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    // @formatter:off
    return http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
                                .jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService)))).build();
    // @formatter:on
}

自定义转换器实现:

public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
    // 省略私有字段和构造器
    @Override
    public AbstractAuthenticationToken convert(Jwt source) {
        Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(source);
        String principalClaimValue = source.getClaimAsString(this.principalClaimName);
        Account acc = accountService.findAccountByPrincipal(principalClaimValue);
        return new AccountToken(source, authorities, principalClaimValue, acc);
    }
}

修改接口使用增强的Authentication

@GetMapping("/authorities")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
    // 创建结果Map(省略)
    if (principal instanceof AccountToken) {
        info.put( "account", ((AccountToken)principal).getAccount());
    }
    return info;
}

优势:可在应用其他部分直接使用增强的认证对象,如SpEL表达式:

@GetMapping("/account/{accountNumber}")
@PreAuthorize("authentication.account.accountNumber == #accountNumber")
public Account getAccountById(@PathVariable("accountNumber") String accountNumber, AccountToken authentication) {
    return authentication.getAccount();
}

9. 测试建议

测试时需注意:

  1. 身份提供者选择:推荐使用嵌入式Keycloak(配置参考指南
  2. 客户端配置
    • 使用Postman测试授权码流程
    • 关键点:正确配置Valid Redirect URI(Postman使用https://oauth.pstmn.io/v1/callback
    • 无网络环境可改用密码模式(安全性较低)
  3. 资源服务器配置
    • 设置spring.security.oauth2.resourceserver.jwt.issuer-uri
    • 例如Keycloak的URI:http://localhost:8083/auth/realms/baeldung
    • Spring会自动获取.well-known/openid-configuration配置

10. 总结

本文展示了Spring Security中JWT声明到Authorities映射的多种定制方式:

  • 修改默认前缀
  • 自定义Principal名称
  • 实现Scope名称映射
  • 完全接管认证令牌生成过程

通过这些技术,可以灵活适配不同认证服务器和业务需求,避免踩坑的同时保持代码简洁。


原始标题:Spring Security – Map Authorities from JWT | Baeldung