英文:
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
来实现。然而,由于SecurityConfig
的jwt().jwtAuthenticationConverter(...)
附录似乎现在已经过时了,我对如何实现此操作感到迷失。
编辑2:
- 基于Sebastiaans的答案和此样例转换器,我现在可以将claim转换为权限(authority),使用一个转换器:
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 Client’s `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<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);
}
};
}
- 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:
{
"sub":"AssortmentExtender",
"aud":"assortment-client",
"nbf":1689578763,
"auth":"ROLE_ADMIN",
"scope":[
"assortment.write"
],
"iss":"http://auth-server:9000",
"exp":1689579063,
"iat":1689578763
}
- Protected method, debug info:
@PreAuthorize("hasRole('ADMIN')")
@PutMapping("/bookstore/isbns/{isbn}")
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 = "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}
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('ADMIN')]
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, includingrole
,roles
, as discussed here)
EDIT:
- From what I've found so far, the problem is that
@PreAuthorize
does not acutally access the claims, but theauthorities
extracted from the JWT structure. By default it only extracts scopes, and prefixes each entry withSCOPE_
. - 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 thejwt().jwtAuthenticationConverter(...)
addendum for theSecurityConfig
appears to be deprecated now.
EDIT2
- Based on Sebastiaans answer, and this sample converter, I am now able to convert the claim to an authority, using a converter:
private Converter getJwtAuthenticationConverter() {
// create a custom JWT converter to map the "roles" from the token as granted authorities
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); // claim as JSON entry in JWT
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(
"ROLE_"); // 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 customroles
claim, but unfortunatley this throws acast
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("SCOPE_");
scope.setAuthoritiesClaimName("scope");
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthorityPrefix("ROLE_");
roles.setAuthoritiesClaimName("roles");
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 <Jwt, AbstractAuthToken>
, while the DelegatingJwtGrantedAuthoritiesConverter
is of type <Jwt, Collection<GrantedAuthority>>
.
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 -> {
resourceServer.jwt(jwt -> {
jwt.jwtAuthenticationConverter(converter -> {
// 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 -> {
resourceServer.jwt(jwt -> {
jwt.jwtAuthenticationConverter(converter -> {
// 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注释,以解释转换器代码中实际发生的事情。
实际上,只需要两件事:
- 在一个新类中创建您自己的转换器:
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);
}
}
- 在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:
- 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 "scope" and "role". All
* findings are prefixed with the respective "SCOPE_" and "ROLE_" 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<Jwt, AbstractAuthenticationToken> {
// 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 "role" claim. If found, all entries are
* prefixed with "ROLE_" and returned as list of authorities.
*
* @param jwt as the json web token to analyze for "role" claim entries.
* @return collection of granted authorities extracted from the jwt.
*/
private static Collection<? extends GrantedAuthority> extractResourceRoles(final Jwt jwt) {
ArrayList<String> resourceAccess = jwt.getClaim(
"role"); // <- specify here whatever additional jwt claim you wish to convert to authority
if (resourceAccess != null) {
// Convert every entry in value list of "role" claim to an Authority
return resourceAccess.stream().map(x -> new SimpleGrantedAuthority("ROLE_" + x))
.collect(Collectors.toSet());
}
// Fallback: return empty list in case the jwt has no "role" 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 "scp"/"scope" 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<GrantedAuthority> authorities =
Stream.concat(defaultGrantedAuthoritiesConverter.convert(source).stream(),
extractResourceRoles(source).stream()).collect(Collectors.toSet());
return new JwtAuthenticationToken(source, authorities);
}
}
- 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) -> authorize
// Whatever custom rules...
[...]
.oauth2ResourceServer(oauth2 -> {
oauth2.jwt(jwt -> {
jwt.jwtAuthenticationConverter(new FusedClaimConverter()); // <-- 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.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论