1. Introduction
1.绪论
In this tutorial, we’ll show how to customize the mapping from JWT (JSON Web Token) claims into Spring Security’s Authorities.
在本教程中,我们将展示如何自定义从JWT(JSON Web Token)索赔到Spring Security的Authorities的映射。
2. Background
2. 背景
When a properly configured Spring Security-based application receives a request, it goes through a series of steps that, in essence, aims at two goals:
当一个正确配置的基于Spring Security的应用程序收到一个请求时,它要经过一系列的步骤,从本质上讲,其目的有两个。
- Authenticate the request, so the application can know who is accessing it
- Decide whether the authenticated request may perform the associated action
For an application using JWT as its main security mechanism, the authorization aspect consists of:
对于使用JWT作为其主要安全机制的应用程序,授权方面包括:。
- Extracting claim values from the JWT payload, usually the scope or scp claim
- Mapping those claims into a set of GrantedAuthority objects
Once the security engine has set up those authorities, it can then evaluate whether any access restrictions apply to the current request and decide whether it can proceed.
一旦安全引擎设置了这些权限,它就可以评估是否有任何访问限制适用于当前请求,并决定是否可以继续。
3. Default Mapping
3.默认映射
Out-of-the-box, Spring uses a straightforward strategy to convert claims into GrantedAuthority instances. Firstly, it extracts the scope or scp claim and splits it into a list of strings. Next, for each string, it creates a new SimpleGrantedAuthority using the prefix SCOPE_ followed by the scope value.
开箱即用,Spring使用直接的策略将索赔转换为GrantedAuthority实例。首先,它提取scope或scp索赔,并将其分割成一个字符串列表。接下来,对于每个字符串,它使用前缀SCOPE_和范围值创建一个新的SimpleGrantedAuthority。
To illustrate this strategy, let’s create a simple endpoint that allows us to inspect some key properties of the Authentication instance made available to the application:
为了说明这一策略,让我们创建一个简单的端点,允许我们检查向应用程序提供的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;
}
}
Here, we use a JwtAuthenticationToken argument because we know that, when using JWT-based authentication, this will be the actual Authentication implementation created by Spring Security. We create the result extracting from its name property, the available GrantedAuthority instances, and the JWT’s original attributes.
这里,我们使用JwtAuthenticationToken参数,因为我们知道,在使用基于JWT的认证时,这将是Spring Security创建的实际Authentication实现。我们从其name属性、可用的GrantedAuthority实例和JWT的原始属性中提取结果。
Now, let’s assume we invoke this endpoint passing and encoded-and-signed JWT containing this payload:
现在,让我们假设我们调用这个端点,传递包含这个有效载荷的经过编码和签名的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
}
The response should look be a JSON object with three properties:
响应看起来应该是一个有三个属性的JSON对象。
{
"tokenAttributes": {
// ... token claims omitted
},
"name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"authorities": [
"SCOPE_profile",
"SCOPE_email",
"SCOPE_openid"
]
}
We can use those scopes to restrict access to certain parts of our applications by creating a SecurityFilterChain:
我们可以通过创建SecurityFilterChain来使用这些作用域来限制对我们应用程序某些部分的访问。
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
return http.authorizeRequests(auth -> {
auth.antMatchers("/user/**")
.hasAuthority("SCOPE_profile");
})
.build();
}
Notice that we’ve intentionally avoided using WebSecurityConfigureAdapter. As described, this class will be deprecated in Spring Security version 5.7, so it’s better to start moving to the new approach as soon as possible.
请注意,我们有意避免使用WebSecurityConfigureAdapter。正如描述的那样,这个类将在 Spring Security 5.7 版本中被废弃,因此最好尽快开始转向新的方法。
Alternatively, we could use method-level annotations and an SpEL expression to achieve the same result:
另外,我们也可以使用方法级注释和SpEL表达式来实现同样的结果。
@GetMapping("/authorities")
@PreAuthorize("hasAuthority('SCOPE_profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// ... same code as before
}
Finally, for more complex scenarios, we can also resort to accessing directly the current JwtAuthenticationToken from which we have direct access to all GrantedAuthorities
最后,对于更复杂的情况,我们也可以直接访问当前的JwtAuthenticationToken,从那里我们可以直接访问所有GrantedAuthorities。
4. Customizing the SCOPE_ Prefix
4.定制SCOPE_前缀
As our first example of how to change Spring Security’s default claim mapping behavior, let’s see how to change the SCOPE_ prefix to something else. As described in the documentation, there are two classes involved in this task:
作为如何改变Spring Security默认的索赔映射行为的第一个例子,我们来看看如何将SCOPE_前缀改为其他内容。正如文档中所描述的,这项任务涉及两个类。
- JwtAuthenticationConverter: Converts a raw JWT into an AbstractAuthenticationToken
- JwtGrantedAuthoritiesConverter: Extracts a collection of GrantedAuthority instances from the raw JWT.
Internally, JwtAuthenticationConverter uses JwtGrantedAuthoritiesConverter to populate a JwtAuthenticationToken with GrantedAuthority objects along with other attributes.
在内部,JwtAuthenticationConverter使用JwtGrantedAuthoritiesConverter来将JwtAuthenticationToken与GrantedAuthority对象以及其他属性一起填充。
The simplest way to change this prefix is to provide our own JwtAuthenticationConverter bean, configured with JwtGrantedAuthoritiesConverter configured to one of our own choice:
改变这个前缀的最简单方法是提供我们自己的JwtAuthenticationConverter bean,将JwtGrantedAuthoritiesConverter配置为我们自己选择的一个。
@Configuration
@EnableConfigurationProperties(JwtMappingProperties.class)
@EnableMethodSecurity
public class SecurityConfig {
// ... fields and constructor omitted
@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;
}
Here, JwtMappingProperties is just a @ConfigurationProperties class that we’ll use to externalize mapping properties. Although not shown in this snippet, we’ll use constructor injection to initialize the mappingProps field with an instance populated from any configured PropertySource, thus giving us enough flexibility to change its values at deploy time.
在这里,JwtMappingProperties只是一个@ConfigurationProperties类,我们将用它来外部化映射属性。虽然在这个片段中没有显示,但我们将使用构造函数注入来初始化mappingProps字段,并从任何配置的PropertySource中填充一个实例,从而给我们足够的灵活性来在部署时改变其值。
This @Configuration class has two @Bean methods: jwtGrantedAuthoritiesConverter() creates the required Converter that creates the GrantedAuthority collection. In this case, we’re using the stock JwtGrantedAuthoritiesConverter configured with the prefix set in the configuration properties.
这个@Configuration/em>类有两个@Bean方法。jwtGrantedAuthoritiesConverter()创建所需的Converter,创建GrantedAuthority集合。在这种情况下,我们使用在配置属性中设置了前缀的库存JwtGrantedAuthoritiesConverter。
Next, we have customJwtAuthenticationConverter(), where we construct the JwtAuthenticationConverter configured to use our custom converter. From there, Spring Security will pick it up as part of its standard auto-configuration process and replace the default one.
接下来,我们有 customJwtAuthenticationConverter(),在这里我们构建了JwtAuthenticationConverter,配置为使用我们的自定义转换器。从那里,Spring Security将把它作为其标准自动配置过程的一部分,并取代默认的转换器。
Now, once we set the baeldung.jwt.mapping.authorities-prefix property to some value, MY_SCOPE, for instance, and invoke /user/authorities, we’ll see the customized authorities:
现在,一旦我们将baeldung.jwt.mapping.authorities-prefix属性设置为某个值,例如MY_SCOPE,并调用/user/authorities,我们将看到自定义的授权。
{
"tokenAttributes": {
// ... token claims omitted
},
"name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"authorities": [
"MY_SCOPE_profile",
"MY_SCOPE_email",
"MY_SCOPE_openid"
]
}
5. Using a Customized Prefix in Security Constructs
5.在安全结构中使用自定义的前缀
It is important to note that, by changing the authorities’ prefixes, we’ll impact any authorization rule that relies on their names. For instance, if we change the prefix to MY_PREFIX_, any @PreAuthorize expressions that assume the default prefix would no longer work. The same applies to HttpSecurity-based authorization constructs.
需要注意的是,通过改变授权机构的前缀,我们将影响任何依赖其名称的授权规则。例如,如果我们将前缀改为MY_PREFIX_,任何假设默认前缀的@PreAuthorize表达式将不再有效。这同样适用于基于HttpSecurity的授权构造。
Fixing this issue, however, is simple. First, let’s add to our @Configuration class a @Bean method that returns the configured prefix. Since this configuration is optional, we must ensure that we return the default value if no one was given it:
然而,解决这个问题很简单。首先,让我们在我们的@Configuration类中添加一个@Bean方法,返回配置的前缀。由于这个配置是可选的,我们必须确保在没有人给它的情况下返回默认值。
@Bean
public String jwtGrantedAuthoritiesPrefix() {
return mappingProps.getAuthoritiesPrefix() != null ?
mappingProps.getAuthoritiesPrefix() :
"SCOPE_";
}
Now, we can use reference this bean using the @<bean-name> syntax in SpEL expressions. This is how we’d use the prefix bean with @PreAuthorize:
现在,我们可以使用SpEL表达式中的@<bean-name>语法来引用这个bean。这就是我们如何在@PreAuthorize中使用前缀bean。
@GetMapping("/authorities")
@PreAuthorize("hasAuthority(@jwtGrantedAuthoritiesPrefix + 'profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// ... method implementation omitted
}
We can also use a similar approach when defining a SecurityFilterChain:
在定义SecurityFilterChain时,我们也可以使用类似的方法。
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
return http.authorizeRequests(auth -> {
auth.antMatchers("/user/**")
.hasAuthority(mappingProps.getAuthoritiesPrefix() + "profile");
})
// ... other customizations omitted
.build();
}
6. Customizing the Principal‘s Name
6.定制校长的名字
Sometimes, the standard sub claim that Spring maps to the Authentication’s name property comes with a value that is not very useful. Keycloak-generated JWTs are a good example:
有时,Spring映射到Authentication的name属性的标准sub主张会带来一个不太有用的值。Keycloak生成的JWTs就是一个很好的例子。
{
// ... other claims omitted
"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"
}
In this case, sub comes with an internal identifier, but we can see that the preferred_username claim has a more friendly value. We can easily modify JwtAuthenticationConverter’s behavior by setting its principalClaimName property with the desired claim name:
在这种情况下,sub带有一个内部标识符,但是我们可以看到,preferred_username索赔有一个更友好的值。我们可以通过将其JwtAuthenticationConverter的principalClaimName属性设置为所需的请求名称来轻松修改行为。
@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());
if (StringUtils.hasText(mappingProps.getPrincipalClaimName())) {
converter.setPrincipalClaimName(mappingProps.getPrincipalClaimName());
}
return converter;
}
Now, if we set the baeldung.jwt.mapping.authorities-prefix property to “preferred_username”, the /user/authorities result will change accordingly:
现在,如果我们将baeldung.jwt.mapping.authorities-prefix属性设置为 “preferred_username”,/user/authorities结果将相应改变。
{
"tokenAttributes": {
// ... token claims omitted
},
"name": "user1",
"authorities": [
"MY_SCOPE_profile",
"MY_SCOPE_email",
"MY_SCOPE_openid"
]
}
7. Scope Names Mapping
7.范围名称映射
Sometimes, we might need to map the scope names received in the JWT to an internal name. For example, this can be the case where the same application needs to work with tokens generated by different authorization servers, depending on the environment where it was deployed.
有时,我们可能需要将JWT中收到的范围名称映射到一个内部名称。例如,在这种情况下,同一个应用程序需要与不同的授权服务器生成的令牌一起工作,这取决于其部署的环境。
We might be tempted to extend JwtGrantedAuthoritiesConverter, but since this is a final class, we can’t use this approach. Instead, we must code our own Converter class and inject it into JwtAuthorizationConverter. This enhanced mapper, MappingJwtGrantedAuthoritiesConverter, implements Converter<Jwt, Collection<GrantedAuthority>> and looks much like the original one:
我们可能很想扩展JwtGrantedAuthoritiesConverter,但由于这是一个最终类,我们不能使用这种方法。相反,我们必须编码我们自己的转换器类,并将其注入JwtAuthorizationConverter。这个增强的映射器,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_";
// ... constructor and setters omitted
@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) {
// ... parse logic omitted
}
}
Here, the key aspect of this class is the mapping step, where we use the supplied scopes map to translate the original scopes into the mapped ones. Also, any incoming scope that has no mapping available will be preserved.
在这里,这个类的关键部分是映射步骤,我们使用提供的scopes映射来将原始作用域翻译成映射的作用域。另外,任何没有可用映射的传入作用域都将被保留下来。
Finally, we use this enhanced converter in our @Configuration in its jwtGrantedAuthoritiesConverter() method:
最后,我们在@Configuration的jwtGrantedAuthoritiesConverter()方法中使用这个增强的转换器。
@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. Using a Custom JwtAuthenticationConverter
8.使用一个自定义的JwtAuthenticationConverter
In this scenario, we’ll take full control of the JwtAuthenticationToken generation process. We can use this approach to return an extended version of this class with additional data recovered from a database.
在这种情况下,我们将完全控制JwtAuthenticationToken的生成过程。我们可以使用这种方法来返回这个类的扩展版本,并从数据库中恢复出额外的数据。
There are two possible approaches to replace the standard JwtAuthenticationConverter. The first, which we’ve used in the previous sections, is to create a @Bean method that returns our custom converter. This, however, implies that our customized version must extend Spring’s JwtAuthenticationConverter so the autoconfiguration process can pick it.
有两种可能的方法来取代标准的JwtAuthenticationConverter。第一种,也就是我们在前几节中使用的,是创建一个@Bean方法来返回我们的自定义转换器。然而,这意味着我们的自定义版本必须扩展Spring的JwtAuthenticationConverter,以便自动配置过程能够选中它。
The second option is to use the HttpSecurity-based DSL approach, where we can provide our custom converter. We’ll do this using the oauth2ResourceServer customizer, which allows us to plug any converter that implements a much more generic interface Converter<Jwt, AbstractAuthorizationToken>:
第二个选择是使用基于HttpSecurity的DSL方法,我们可以提供我们的自定义转换器。我们将使用oauth2ResourceServer自定义器来实现,它允许我们插入任何实现了更多通用接口的转换器Converter<Jwt, AbstractAuthorizationToken>。
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
return http.oauth2ResourceServer(oauth2 -> {
oauth2.jwt()
.jwtAuthenticationConverter(customJwtAuthenticationConverter());
})
.build();
}
Our CustomJwtAuthenticationConverter uses an AccountService (available online) to retrieve an Account object based on username claim value. It then uses it to create a CustomJwtAuthenticationToken with an extra accessor method for the account data:
我们的CustomJwtAuthenticationConverter使用AccountService(在线提供)来检索一个基于用户名声称值的Account对象。然后,它使用它来创建一个CustomJwtAuthenticationToken,并为账户数据提供一个额外的访问方法。
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
// ...private fields and construtor omitted
@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);
}
}
Now, let’s modify our /user/authorities handler to use our enhanced Authentication:
现在,让我们修改我们的/user/authorities处理程序,以使用我们增强的Authentication。
@GetMapping("/authorities")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// ... create result map as before (omitted)
if (principal instanceof AccountToken) {
info.put( "account", ((AccountToken)principal).getAccount());
}
return info;
}
One advantage of taking this approach is that we can now easily use our enhanced authentication object in other parts of the application. For instance, we can access the account info in SpEL expressions directly from the built-in variable authentication:
采取这种方法的一个好处是,我们现在可以在应用程序的其他部分轻松使用我们的增强型认证对象。例如,我们可以在SpEL表达式中直接从内置变量authentication访问帐户信息。
@GetMapping("/account/{accountNumber}")
@PreAuthorize("authentication.account.accountNumber == #accountNumber")
public Account getAccountById(@PathVariable("accountNumber") String accountNumber, AccountToken authentication) {
return authentication.getAccount();
}
Here, the @PreAuthorize expression enforces that the accountNumber passed in the path variable belongs to the user. This approach is particularly useful when used in conjunction with Spring Data JPA, as described in the official documentation.
在这里,@PreAuthorize表达式强制要求路径变量中传递的accountNumber属于该用户。如官方文档中所述,这种方法在与 Spring Data JPA 一起使用时特别有用。
9. Testing Tips
9.测试提示
The examples given so far assume we have a functioning identity provider (IdP) that issues JWT-based access tokens. A good option is to use the embedded Keycloak server that we’ve already covered here. Additional configuration instructions are also available in our Quick Guide to Using Keycloak.
到目前为止给出的例子假设我们有一个有效的身份提供者(IdP),它可以发行基于JWT的访问令牌。一个好的选择是使用我们已经在这里介绍过的嵌入式Keycloak服务器。在我们的使用Keycloak的快速指南中还提供了其他配置说明。
Please notice that those instructions cover how to register an OAuth client. For live tests, Postman is a good tool that supports the authorization code flow. The important detail here is how to properly configure the Valid Redirect URI parameter. Since Postman is a desktop application, it uses a helper site located at https://oauth.pstmn.io/v1/callback to capture the authorization code. Consequently, we must ensure we have internet connectivity during the tests. If this is not possible, we can use the less secure password grant flow instead.
请注意,这些说明涵盖了如何注册一个OAuth 客户端。对于实时测试,Postman是一个很好的工具,支持授权代码流。这里的重要细节是如何正确配置有效的重定向URI参数。由于Postman是一个桌面应用程序,它使用一个位于https://oauth.pstmn.io/v1/callback的辅助网站来捕获授权代码。因此,我们必须确保在测试期间有互联网连接。如果这是不可能的,我们可以使用不太安全的密码授予流程来代替。
Regardless of the selected IdP and client selection, we must configure our resource server so it can properly validate the received JWTs. For standard OIDC providers, this means providing a suitable value to the spring.security.oauth2.resourceserver.jwt.issuer-uri property. Spring will then fetch all configuration details using the .well-known/openid-configuration document available there.
无论选择何种IdP和客户端,我们都必须配置我们的资源服务器,使其能够正确验证收到的JWTs。对于标准的OIDC提供商来说,这意味着为spring.security.oauth2.resourceserver.jwt.issuer-uri属性提供一个合适的值。然后,Spring将使用那里提供的.known/openid-configuration文件来获取所有配置细节。
In our case, the issuer URI for our Keycloak realm is http://localhost:8083/auth/realms/baeldung. We can point our browser to retrieve the full document at http://localhost:8083/auth/realms/baeldung/.well-known/openid-configuration.
在我们的例子中,我们的Keycloak领域的发行者URI是http://localhost:8083/auth/realms/baeldung。我们可以将我们的浏览器指向http://localhost:8083/auth/realms/baeldung/.well-known/openid-configuration,以检索完整的文件。
10. Conclusion
10.结语
In this article, we’ve shown different ways to customize the way Spring Security map authorities from JWT claims. As usual, complete code is available over on GitHub.
在这篇文章中,我们展示了自定义Spring Security从JWT请求映射权限的不同方法。像往常一样,完整的代码可在GitHub上获得。