Spring Security PreAuthorize ROLE无法获取JWT声明。

huangapple go评论119阅读模式
英文:

Spring Security PreAuthorize ROLE does not pick up JWT claim

问题

我已经实现了一个简单的OAuth2测试应用程序(授权服务器,资源服务器,客户端),基于Baeldung示例(示例代码在GitHub上)。

一切运行正常,但我想添加一个额外的安全约束

  • 到目前为止,资源服务器通过JWT检查来验证客户端的SCOPE
  • 我希望资源服务器也检查授权客户端的用户的**ROLE**。

我所做的:

  • 默认情况下,JWT中没有ROLE信息,因此我在授权服务器中添加了它。它现在添加了一个新的claim "auth"(不确定"auth"是否是正确的关键字):
  @Bean
  OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
    return context -> {
      if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN) {
        Authentication principal = context.getPrincipal();
        String enumeratedRoles = principal.getAuthorities().iterator().next().getAuthority();
        context.getClaims().claim("auth", enumeratedRoles);
      }
    };
  }
  • 我调试了资源服务器调用的受保护方法(由客户端调用)。JWT和authorized.principal都携带了额外的claim,到目前为止一切正常:

    • JWT载荷,反序列化:
{
   "sub":"AssortmentExtender",
   "aud":"assortment-client",
   "nbf":1689578763,
   "auth":"ROLE_ADMIN",
   "scope":[
      "assortment.write"
   ],
   "iss":"http://auth-server:9000",
   "exp":1689579063,
   "iat":1689578763
}
  • 受保护方法,调试信息:
  @PreAuthorize("hasRole('ADMIN')")
  @PutMapping("/bookstore/isbns/{isbn}")
  public void addBookToAssortment(@RequestBody BookDetailsImpl bookDetails, final @AuthenticationPrincipal
  Jwt jwt, Authentication authentication) {
// 保护逻辑,这里我调试过了...
}

调试器字段,对于方法的authentication参数正确显示了ROLE_ADMIN claim:

name = "AssortmentExtender"
...
principal = {Jwt@7362}
  headers = {Collections$UnmodifiableMap@7402\ size = 2
  claims = {Collections$UnmodifiableMap@7403} size = 8
    "sub" -> "AssortmentExtender"
    "aud" -> {ArrayList@7428} size = 1
    "nbf" -> Instant@74301 #2023-07-17T07:15:33Z
    "auth" -> "ROLE_ADMIN"
    "scope" -> {ArrayList@7434} size = 1
    "iss" -> "htto://auth-server:9000"
    "exp" -> {Instant@7406) "2023-07-17T07:20:33Z"
    "iat" -> (Instant@7405) "2023-07-17T07:15:33Z"
  tokenValue = "eyJrawQiOil3YTzNTNmOCOxYTAXLTQwNmQtYjczOCthN
  issuedAt = (Instant@7405) "2023-07-17T07:15:33Z"
  expiresAt = {Instant@74061 2023-07-17T07:20:33Z"
credentials = {Jwt@7362}

不起作用的部分:

  • 资源服务器的@PreAuthorize拒绝了客户端请求。
  • 日志显示:
Failed to authorize ReflectiveMethodInvocation: [...] with authorization manager
org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@4b97e0b0 and decision ExpressionAuthorizationDecision [granted=false,
expressionAttribute=hasRole('ADMIN')]

总结:

  • Claim是存在的,JWT和principal对象正确显示了ADMIN_ROLE claim。
  • @PreAuthorize仍然拒绝请求。

问题:

  • 为什么PreAuthorize没有获取到'auth' claim?
  • 我是否只是在使用错误的关键字?@PreAuthorize是否检查的是主体声明之外的其他内容?(我已经尝试了各种变体,包括这里讨论的

编辑:

  • 我目前找到的问题是@PreAuthorize实际上并没有访问声明,而是从JWT结构中提取的“权限(authorities)”。默认情况下,它只提取作用域,并在每个条目前加上“SCOPE_”前缀。
  • 我的猜测是我可能需要注册一个机制,也可以对我的JWT角色执行相同的操作,似乎可以通过自定义的JwtAuthenticationConverter来实现。然而,由于SecurityConfigjwt().jwtAuthenticationConverter(...)附录似乎现在已经过时了,我对如何实现此操作感到迷失。

编辑2:

private Converter getJwtAuthenticationConverter() {

  // 创建一个自定义的JWT转换器,将token中的“roles”映射为授予的权限
  JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
      new JwtGrantedAuthoritiesConverter();
  jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); // 在JWT中作为JSON条目的声明
  jwtGrantedAuthoritiesConverter.setAuthorityPrefix(
      "ROLE_"); // 在权限对象中要使用的前缀

  // 返回一个新的转换器对象,反映上述JWT声明到权限的规则。
  JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
  jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter

<details>
<summary>英文:</summary>

I have implemented a simple OAuth2 test app (Authorization Server, Resource Server, Client), based on [a Baeldung example](https://www.baeldung.com/spring-security-oauth-auth-server) (Sample code is on [GitHub](https://github.com/Baeldung/spring-security-oauth/tree/master/oauth-authorization-server))

Everything works fine, but **I want to add an *additional* security contraint**:

- So far the ResourceServer verifies the Clients `SCOPE` (by JWT inspection).
- I want the ResourceServer to also check the **`ROLE` of the user who authorized the client**.

**What I did:**

* By default the ROLE information is not as claim in the JWT, so I added it (in the Authorization Server). It now adds new claim auth (unsure if `auth` is the right keyword):

```java
  @Bean
  OAuth2TokenCustomizer&lt;JwtEncodingContext&gt; jwtCustomizer() {
    return context -&gt; {
      if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN) {
        Authentication principal = context.getPrincipal();
        String enumeratedRoles = principal.getAuthorities().iterator().next().getAuthority();
        context.getClaims().claim(&quot;auth&quot;, enumeratedRoles);
      }
    };
  }
  • I debugged the ResourceServer's portected method (the one invoked by client). Both, JWT and authorized.principal carry the additional claim, so far so good:
    • JWT bayload, deserialized:
{
&quot;sub&quot;:&quot;AssortmentExtender&quot;,
&quot;aud&quot;:&quot;assortment-client&quot;,
&quot;nbf&quot;:1689578763,
&quot;auth&quot;:&quot;ROLE_ADMIN&quot;,
&quot;scope&quot;:[
&quot;assortment.write&quot;
],
&quot;iss&quot;:&quot;http://auth-server:9000&quot;,
&quot;exp&quot;:1689579063,
&quot;iat&quot;:1689578763
}
  • Protected method, debug info:
  @PreAuthorize(&quot;hasRole(&#39;ADMIN&#39;)&quot;)
  @PutMapping(&quot;/bookstore/isbns/{isbn}&quot;)
  public void addBookToAssortment(@RequestBody BookDetailsImpl bookDetails, final @AuthenticationPrincipal
  Jwt jwt, Authentication authentication) {
// Whatever protected logic, here I debugged...
}

Debugger fields, for method's authentication parameter correctly shows the ROLE_ADMIN claim:

  name = &quot;AssortmentExtender&quot;
...
principal = {Jwt@7362}
headers = {Collections$UnmodifiableMap@7402\ size = 2
claims = {Collections$UnmodifiableMap@7403} size = 8
&quot;sub&quot; -&gt; &quot;AssortmentExtender&quot;
&quot;aud&quot; -&gt; {ArrayList@7428} size = 1
&quot;nbf&quot; -&gt; ‹Instant@74301 #2023-07-17T07:15:33Z&quot;
&quot;auth&quot; -&gt; &quot;ROLE_ADMIN&quot;
&quot;scope&quot; -&gt; {ArrayList@7434} size = 1
&quot;iss&quot; -&gt; &quot;htto://auth-server:9000&quot;
&quot;exp&quot; -&gt; {Instant@7406) &quot;2023-07-17T07:20:33Z&quot;
&quot;iat&quot; -&gt; (Instant@7405) &quot;2023-07-17T07:15:33Z&quot;
tokenValue = &quot;eyJrawQiOil3YTzNTNmOCOxYTAXLTQwNmQtYjczOCthN
issuedAt = (Instant@7405) &quot;2023-07-17T07:15:33Z&quot;
expiresAt = {Instant@74061 2023-07-17T07:20:33Z&quot;
credentials = {Jwt@7362}

What does not work:

  • The ResourceServer's @PreAuthorize rejects the Client request.
  • Logger says:
  Failed to authorize ReflectiveMethodInvocation: [...] with authorization manager
org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@4b97e0b0 and decision ExpressionAuthorizationDecision [granted=false,
expressionAttribute=hasRole(&#39;ADMIN&#39;)]

Summary:

  • The claim is there, JWT and principal object correctly show the ADMIN_ROLE claim.
  • @PreAuthorize rejects the request anyway.

Question:

  • Why does PreAuthorize not pick up the 'auth' claim?
  • Am I just using the wrong keyword? Is @PreAuthorize checking something else than the principal claims? 
(I've already tried various variants, including role, roles, as discussed here)

EDIT:

  • From what I've found so far, the problem is that @PreAuthorize does not acutally access the claims, but the authorities extracted from the JWT structure. By default it only extracts scopes, and prefixes each entry with SCOPE_.
  • My guess is that I somehow need to register a mechanism that does now the same for my jwt ROLES, and this seems to be possible with a custom JwtAuthenticationConverter. However, I am lost as of how to achieve this, notably since the jwt().jwtAuthenticationConverter(...) addendum for the SecurityConfig appears to be deprecated now.

EDIT2

  private Converter getJwtAuthenticationConverter() {

    // create a custom JWT converter to map the &quot;roles&quot; from the token as granted authorities
    JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
        new JwtGrantedAuthoritiesConverter();
    jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(&quot;roles&quot;); // claim as JSON entry in JWT
    jwtGrantedAuthoritiesConverter.setAuthorityPrefix(
        &quot;ROLE_&quot;); // prefix to be used in authority object

    // Return a new Converter object that reflects the above JWTclaim-To-Authorities rules.
    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
  }
  • However unfortunately this seems to overwrite all existing authorities from the scope claims.
  • Based on a github discussion, I tried to write a converter that combines the existing scope rules with the custom roles claim, but unfortunatley this throws a cast exception at runtime:
// Throws classcast exception at runtime: 
// java.util.LinkedHashSet cannot be cast to class org.springframework.security.authentication.AbstractAuthenticationToken
  private Converter getDualJwtAuthenticationConverter() {

    JwtGrantedAuthoritiesConverter scope = new JwtGrantedAuthoritiesConverter();
    scope.setAuthorityPrefix(&quot;SCOPE_&quot;);
    scope.setAuthoritiesClaimName(&quot;scope&quot;);
    JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
    roles.setAuthorityPrefix(&quot;ROLE_&quot;);
    roles.setAuthoritiesClaimName(&quot;roles&quot;);
    return new DelegatingJwtGrantedAuthoritiesConverter(scope, roles);
  }

As far as I can tell, the problem is that the Security configuration jwt.jwtAuthenticationConverter(whateverConverter()); wants a converter of type &lt;Jwt, AbstractAuthToken&gt;, while the DelegatingJwtGrantedAuthoritiesConverter is of type &lt;Jwt, Collection&lt;GrantedAuthority&gt;&gt;.

So ultimately the question is how to write a converter that combines multiple JWT claims into a fused list of authorities.

答案1

得分: 1

关于JWT内容的部分:

在JWT的声明部分,我们还会自己填充一个authorities字段。

配置身份验证转换器的部分:

这段代码并没有被废弃。API只有稍微变化了一点。

http.oauth2ResourceServer(resourceServer -&gt; {
     resourceServer.jwt(jwt -&gt; {
         jwt.jwtAuthenticationConverter(converter -&gt; {
            // TODO: implement me
            return null; 
         });
     });
});

所以你实际上可以以非废弃的方式实现它。

我不知道如何确切地实现这部分,因为我们不使用那些方法,而是自己完成。我们通过提供一个类型为AuthenticationProvider的bean来完成。如果你不需要什么特别的功能,我想你的方法也会起作用,甚至可能更受欢迎(我们的代码是在Spring中进行了现代重写之前的)。

英文:

Not entirely an answer but hopefully enough to get you on the way a bit.

On the topic of the JWT contents:

We also fill an authorities field ourselves in the claims section of the JWT.

On the topic of configuring the authentication converter:

This code isn't deprecated. The API only changed a little bit.

http.oauth2ResourceServer(resourceServer -&gt; {
resourceServer.jwt(jwt -&gt; {
jwt.jwtAuthenticationConverter(converter -&gt; {
// TODO: implement me
return null; 
});
});
});

So you can actually still implement that in a non-deprecated way.

I don't know how to exactly implement that part though, since we don't use those methods and just do that ourselves instead. We do it by providing a bean of the type AuthenticationProvider. If you don't need anything fancy, I imagine your way will also work and might even be preferred (our code is from before the modern rewrite of the oauth2 implementation in Spring)

答案2

得分: 0

我找到了如何将多个JWT声明融合成权限列表的方法。

  • 关键是确实要编写一个自定义转换器,正如@Sebastiaan所推荐的。
  • 解决方案与this proposal(相同目标,多个声明的融合)非常接近,尽管我更改了提案以匹配我的JWT结构。
  • 我还为实现添加了JavaDoc注释,以解释转换器代码中实际发生的事情。

实际上,只需要两件事:

  1. 在一个新类中创建您自己的转换器:
import java.util.ArrayList;
import java.util.Collection;
import java.util.stream.Collectors;
import java.util.Collections;
import java.util.stream.Stream;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

/**
 * 这是一个自定义转换器,用于提取两个Jwt声明“scope”和“role”的所有条目。所有发现的条目都以相应的“SCOPE_”和“ROLE_”前缀开头,然后包装成权限列表,可以由Spring的@PreAuthorize注解处理。
 * 此实现基于:https://stackoverflow.com/a/58234971/13805480
 */
public class FusedClaimConverter implements Converter<Jwt, AbstractAuthenticationToken> {

  // 融合转换器在内部融合了两个转换器的输出。
  // 其中一个组件是默认转换器(提取scp/scope声明信息)。
  // 因此,我们创建一个此即用即走的转换器实例以供以后使用。
  private final JwtGrantedAuthoritiesConverter defaultGrantedAuthoritiesConverter =
      new JwtGrantedAuthoritiesConverter();

  /**
   * 该方法提供了我们自定义转换器的第二个组件。这是一个手动实现,它搜索jwt以查找自定义“role”声明。如果找到,所有条目都将以“ROLE_”前缀返回为权限列表。
   *
   * @param jwt 用于分析“role”声明条目的JSON Web Token。
   * @return 从jwt中提取的授予的权限集合。
   */
  private static Collection<? extends GrantedAuthority> extractResourceRoles(final Jwt jwt) {
    ArrayList<String> resourceAccess = jwt.getClaim(
        "role"); // <- 在此处指定要转换为权限的任何其他jwt声明
    if (resourceAccess != null) {
      // 将“role”声明值列表中的每个条目转换为权限
      return resourceAccess.stream().map(x -> new SimpleGrantedAuthority("ROLE_" + x))
          .collect(Collectors.toSet());
    }
    // 回退:如果jwt没有“role”声明,则返回空列表。
    return Collections.emptySet();
  }

  /**
   * 这是要覆盖的主要转换器方法。在这里,我们提供了一个自定义实现,该实现连接了从两个不同转换器生成的权限列表。
   * 其中一个是默认的转换器,该转换器操作“scp”/“scope”声明。另一个是我们自定义声明的转换器。
   *
   * @param source 用于检查声明的JSON Web Token。
   * @return 从令牌中提取的权限列表,包装在AbstractAuthenticationToken对象中。
   */
  @Override
  public AbstractAuthenticationToken convert(final Jwt source) {
    Collection<GrantedAuthority> authorities =
        Stream.concat(defaultGrantedAuthoritiesConverter.convert(source).stream(),
            extractResourceRoles(source).stream()).collect(Collectors.toSet());
    return new JwtAuthenticationToken(source, authorities);
  }
}
  1. 在ResourceServer的SecurityFilterChain中注册您的自定义转换器:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
        .authorizeHttpRequests((authorize) -> authorize
            // 任何自定义规则...
            [...]
        .oauth2ResourceServer(oauth2 -> {
          oauth2.jwt(jwt -> {
            jwt.jwtAuthenticationConverter(new FusedClaimConverter()); // <-- 在这里注册自定义转换器。
          });
        });

    return http.build();
  }

编辑:
为了完整起见,我已经在GitHub上上传了一个带有此配置的详细记录的可运行应用程序。

英文:

I figured out how to fuse multiple JWT claims to a list of authorities.

  • The trick was indeed to write a custom converter, as recommended by @Sebastiaan.
  • The solution is very close to this proposal (same goal, fusing of multiple claims), although I changed the proposal to match my JWT structure.
  • I also added JavaDoc comments for the implementation, to explain what actually happens in the converter code.

In essence, only two things were needed:

  1. Create you own converter in a new class:
import java.util.ArrayList;
import java.util.Collection;
import java.util.stream.Collectors;
import java.util.Collections;
import java.util.stream.Stream;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

/**
 * This is a custom converter to extract all entries of two Jwt claims &quot;scope&quot; and &quot;role&quot;. All
 * findings are prefixed with the respective &quot;SCOPE_&quot; and &quot;ROLE_&quot; prefixes, and then wrapped up as a
 * list of authorities, which can be processed by Springs SPeL in @PreAuthorize annotations. This
 * implementation is based on: https://stackoverflow.com/a/58234971/13805480
 */
public class FusedClaimConverter implements Converter&lt;Jwt, AbstractAuthenticationToken&gt; {

  // The fused converter internally fuses the outputs of two converters.
  // One component is the default converter (extracting scp/scope claim information).
  // So we create one instance of this off-the-shelf convert for later use.
  private final JwtGrantedAuthoritiesConverter defaultGrantedAuthoritiesConverter =
      new JwtGrantedAuthoritiesConverter();

  /**
   * This method provides the second component of our custom converter. It is a manual
   * implementation that searches the jwt for a custom &quot;role&quot; claim. If found, all entries are
   * prefixed with &quot;ROLE_&quot; and returned as list of authorities.
   *
   * @param jwt as the json web token to analyze for &quot;role&quot; claim entries.
   * @return collection of granted authorities extracted from the jwt.
   */
  private static Collection&lt;? extends GrantedAuthority&gt; extractResourceRoles(final Jwt jwt) {
    ArrayList&lt;String&gt; resourceAccess = jwt.getClaim(
        &quot;role&quot;); // &lt;- specify here whatever additional jwt claim you wish to convert to authority
    if (resourceAccess != null) {
      // Convert every entry in value list of &quot;role&quot; claim to an Authority
      return resourceAccess.stream().map(x -&gt; new SimpleGrantedAuthority(&quot;ROLE_&quot; + x))
          .collect(Collectors.toSet());
    }
    // Fallback: return empty list in case the jwt has no &quot;role&quot; claim.
    return Collections.emptySet();
  }

  /**
   * This is the main converter method to override. In essence here we provide a custom
   * implementation that concatenates the authority lists generated from two respective conterters.
   * One is the off-the-shelf default converter that operates on the &quot;scp&quot;/&quot;scope&quot; claim. The other
   * is the converter for our custom claim.
   *
   * @param source as the json web token to inspect for claims
   * @return list of authorities extracted from token, wrapped up in AbstractAuthenticationToken
   * object.
   */
  @Override
  public AbstractAuthenticationToken convert(final Jwt source) {
    Collection&lt;GrantedAuthority&gt; authorities =
        Stream.concat(defaultGrantedAuthoritiesConverter.convert(source).stream(),
            extractResourceRoles(source).stream()).collect(Collectors.toSet());
    return new JwtAuthenticationToken(source, authorities);
  }
}
  1. Register your custom converter in the ResourceServer's SecurityFilterChain:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
        .authorizeHttpRequests((authorize) -&gt; authorize
            // Whatever custom rules...
            [...]
        .oauth2ResourceServer(oauth2 -&gt; {
          oauth2.jwt(jwt -&gt; {
            jwt.jwtAuthenticationConverter(new FusedClaimConverter()); // &lt;-- Here the custom converter is registered.
          });
        });

    return http.build();
  }

EDIT:
For completeness, I've uploaded a well documented runnable application with this configuration on GitHub.

huangapple
  • 本文由 发表于 2023年7月17日 16:30:53
  • 转载请务必保留本文链接:https://go.coder-hub.com/76702695.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定