自定义从 JWT Claim 到 Spring Security Authority 的映射

1、简介

本文将带你了解如何自定义从 JWT(JSON Web Token)Claim 到 Spring Security 权限(Authority)的映射。

2、背景

基于 Spring Security 的应用接收到请求时,它会经过一系列步骤,本质上旨在实现两个目标。

  • 认证请求,以便应用知道谁在访问它
  • 决定通过身份认证的请求是否可以执行相关操作

对于使用 JWT 的应用,授权方面包括:

  • 从 JWT payload(通常是 scopescp Claim)中提取 Claim 值
  • 将这些 Claim 声明映射到一组 GrantedAuthority 对象中

一旦 Security 引擎设置了这些权限,它就可以评估是否有任何访问限制适用于当前请求,并决定是否可以继续处理。

3、默认的映射

在开箱即用的情况下,Spring 使用一种直接的策略将 Claim 声明转换为 GrantedAuthority 实例。首先,它会提取 scopescp Claim,并将其拆分成一个字符串列表。接下来,它会为每个字符串创建一个新的 SimpleGrantedAuthority,使用前缀 SCOPE_,后跟 scope 值。

接下来,创建一个简单的端点来演示这个策略。看看 Authentication 实例有哪些关键属性。

@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());
        
        return info;
    }
}

如上,直接使用了 JwtAuthenticationToken 对象作为参数,这是 Spring Security 中基于 JWT 的 Authentication 实际实现。我们从其 name 属性、可用的 GrantedAuthority 实例和 JWT 的原始属性中提取信息,创建结果。

现在,假设我们调用了这个端点,并传递了如下 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": "psevestre@gmail.com",
  "family_name": "Sevestre",
  "given_name": "Philippe",
  "name": "Philippe Sevestre",
  "scp": "profile.read",
  "sub": "eXWysuqIJmK1yDywH3gArS98PVO1SV67BLt-dvmQ-pM",
  ... more claims omitted
}

响应应该是一个 JSON 对象,包含三个属性:

{
  "tokenAttributes": {
     // ... token claim 省略
  },
  "name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "authorities": [
    "SCOPE_profile",
    "SCOPE_email",
    "SCOPE_openid"
  ]
}

我们可以创建一个 SecurityFilterChain,通过使用这些 scope 来限制对应用的某些部分的访问。

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    return http.authorizeRequests(auth -> {
      auth.antMatchers("/user/**")
        .hasAuthority("SCOPE_profile");
    })
    .build();
}

注意,这里有意避免使用 WebSecurityConfigureAdapter。该类在 Spring Security 5.7 版本中被 弃用,因此最好尽快改用新方法。

或者,我们也可以使用方法级注解和 SpEL 表达式来达到同样的效果:

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

最后,对于更复杂的情况,还可以直接访问当前的 JwtAuthenticationToken,从中直接访问所有 GrantedAuthority

4、自定义 SCOPE_ 前缀

第一个示例,看看如何自定义 SCOPE_ 前缀。这个涉及两个类。

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

在内部,JwtAuthenticationConverter 使用 JwtGrantedAuthoritiesConverterGrantedAuthority 对象和其他属性填充到 JwtAuthenticationToken 中。

更改此前缀的最简单方法是提供自己的 JwtAuthenticationConverter Bean,并将其 JwtGrantedAuthoritiesConverter 配置为自定义的 Converter:

@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;
    }

这里的 JwtMappingProperties 是一个 @ConfigurationProperties 类,使用它来外部化映射属性。使用构造函数注入来初始化 mappingProps 字段,并使用从配置的 PropertySource 中填充的实例(在此片段中没有显示),从而提供足够的灵活性,在部署时更改其值。

这个 @Configuration 类有两个 @Bean 方法:jwtGrantedAuthoritiesConverter() 创建所需的 Converter,用于创建 GrantedAuthority 集合。在本例中,我们使用的是在配置属性中设置了前缀(prefix)的 JwtGrantedAuthoritiesConverter

接下来是 customJwtAuthenticationConverter(),在这里构建了为使用自定义 Converter 的 JwtAuthenticationConverter。Spring Security 会将其作为标准自动配置过程的一部分,并替换默认 Converter。

现在,只要将 baeldung.jwt.mapping.authorities-prefix 属性设置为某个值,例如 MY_SCOPE,然后调用 /user/authorities,就能看到自定义的 Authority:

{
  "tokenAttributes": {
    // ... token claim 省略
  },
  "name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "authorities": [
    "MY_SCOPE_profile",
    "MY_SCOPE_email",
    "MY_SCOPE_openid"
  ]
}

5、在 Security 架构中使用自定义前缀

注意,更改了权限(Authority)前缀,会影响任何依赖于权限名称的授权规则。

例如,前缀改为 MY_PREFIX_,那么任何假定默认前缀的 @PreAuthorize 表达式都将失效。同样的情况也适用于基于 HttpSecurity 的授权架构。

解决这个问题很简单。首先,在 @Configuration 类中添加一个 @Bean 方法,用于返回配置的前缀。该配置是可选的,在没有给定默认值的情况下返回默认值:

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

现在,可以 在 SpEL 表达式中使用 @<bean-name> 语法 来引用此 Bean。这是在 @PreAuthorize 中访问此 “前缀” Bean 的方式:

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

在定义 SecurityFilterChain 时,也可以使用类似的方法:

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    return http.authorizeRequests(auth -> {
        auth.antMatchers("/user/**")
          .hasAuthority(mappingProps.getAuthoritiesPrefix() + "profile");
      })
      // ... 其他自定义代码省略
      .build();
}

6、自定义 Principal 名称

有时候,Spring 映射到 Authenticationname 属性的标准 sub Claim 的值并不是很有用。Keycloak 生成的 JWT 是一个很好的例子。

{
  // ... 其他 Claim 省略
  "sub": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
  "scope": "openid profile email",
  "email_verified": true,
  "name": "User Primo",
  "preferred_username": "user1",
  "given_name": "User",
  "family_name": "Primo"
}

在本例中,sub 附带了一个内部 id,但你可以看到 preferred_username 名称更友好。我们可以通过设置 JwtAuthenticationConverterprincipalClaimName 属性,并将其设置为所需的 Claim 名称,从而轻松修改 JwtAuthenticationConverter 的行为。

@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());

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

现在,如果将 baeldung.jwt.mapping.authorities-prefix 属性设置为 preferred_username/user/authorities 的结果就会相应改变:

{
  "tokenAttributes": {
    // ... token claim 省略
  },
  "name": "user1",
  "authorities": [
    "MY_SCOPE_profile",
    "MY_SCOPE_email",
    "MY_SCOPE_openid"
  ]
}

7、Scope 名称映射

有时,可能需要将 JWT 中接收到的 scope 名称映射为内部名称。例如,同一个应用需要使用不同授权服务器生成的 Token,部署的环境不同,授权服务器也可能会不同。

你可能想要继承 JwtGrantedAuthoritiesConverter,但它是一个 final 类,无法继承。我们可以编写自己的 Converter 类,并将其注入到 JwtAuthorizationConverter 中。

这个增强的 Converter MappingJwtGrantedAuthoritiesConverter 实现了 Converter<Jwt, Collection<GrantedAuthority>>,并且与原始的类很相似。

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_";
     
    // ... 构造函数个 get/set 方法省略

    @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) {
       // ... 解析逻辑省略
    }
}

如上,该类的关键是映射步骤,使用提供的 scopes map 将原始 scope 转换为映射后的 scope。而且,传入的 scope 如果没有映射,会被保留。

最后,在 @ConfigurationjwtGrantedAuthoritiesConverter() 方法中使用这个增强的 Converter:

@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 生成过程。可以使用这种方法返回一个它的子类,其中包含从数据库中检索的额外数据。

有两种可能的方法来替换标准的 JwtAuthenticationConverter。第一种方法是我们在前面的部分中使用的方法,即创建一个返回自定义 Converter 的 @Bean 方法。然而,这意味着我们的自定义版本必须继承 Spring 的 JwtAuthenticationConverter,以便自动配置过程可以选择它。

第二种方法是使用基于 HttpSecurity 的 DSL 方法来提供自定义 Converter。使用 oauth2ResourceServer 方法来实现,它允许插入任何实现了 Converter<Jwt, AbstractAuthorizationToken> 接口的 Converter:

@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
    return http.oauth2ResourceServer(oauth2 -> {
        oauth2.jwt()
          .jwtAuthenticationConverter(customJwtAuthenticationConverter());
      })
      .build();
}

其中 CustomJwtAuthenticationConverter 使用 AccountService 根据 username Claim 值检索 Account 对象。然后,它使用该对象创建一个 CustomJwtAuthenticationToken,并为账户数据提供一个额外的访问方法:

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);
    }
}

现在,修改 /user/authorities Handler,以使用增强型 Authentication

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

采用这种方法的一个好处是,现在可以在应用的其他部分轻松使用增强的 Authentication 对象。例如,可以直接从内置变量 authentication 访问 SpEL 表达式中的账户信息:

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

这里的 @PreAuthorize 表达式强制要求 URI 路径变量中传递的 accountNumber 和当前用户一致。与 Spring Data JPA 结合使用时,这种方法尤其有用,详情可参阅 官方文档

9、测试技巧

到目前为止所举的例子都假定我们有一个正常运行的身份提供商(IdP),它可以签发基于 JWT 的 Access Token。一个不错的选择是使用嵌入式 Keycloak 服务器。

对于实时测试,Postman 是支持 “授权码模式” 的好工具。这里的重要细节是如何正确配置有效重定向 URI 参数。由于 Postman 是一个桌面应用,它使用 https://oauth.pstmn.io/v1/callback 的辅助网站来获取授权码。因此,必须确保在测试期间能够连接互联网。如果不行,可以使用安全性较低的 “密码授权模式” 来代替。

无论选择哪种 IdP 和客户端,我们都必须配置资源服务器,使其能够正确验证接收到的 JWT。对于标准 OIDC 提供商,需要为 spring.security.oauth2.resourceserver.jwt.issuer-uri 属性提供一个合适的值。然后,Spring 使用其 .well-known/openid-configuration 文档获取所有配置细节。

在本例中,假设 Keycloak Realm 的 Issuer URI 是 http://localhost:8083/auth/realms/baeldung。那么,可以用浏览器访问 http://localhost:8083/auth/realms/baeldung/.well-known/openid-configuration 以获取完整文档。


参考:https://www.baeldung.com/spring-security-map-authorities-jwt