自定义从 JWT Claim 到 Spring Security Authority 的映射
1、简介
本文将带你了解如何自定义从 JWT(JSON Web Token)Claim 到 Spring Security 权限(Authority)的映射。
2、背景
基于 Spring Security 的应用接收到请求时,它会经过一系列步骤,本质上旨在实现两个目标。
- 认证请求,以便应用知道谁在访问它
- 决定通过身份认证的请求是否可以执行相关操作
对于使用 JWT 的应用,授权方面包括:
- 从 JWT payload(通常是
scope
或scp
Claim)中提取 Claim 值 - 将这些 Claim 声明映射到一组
GrantedAuthority
对象中
一旦 Security 引擎设置了这些权限,它就可以评估是否有任何访问限制适用于当前请求,并决定是否可以继续处理。
3、默认的映射
在开箱即用的情况下,Spring 使用一种直接的策略将 Claim 声明转换为 GrantedAuthority
实例。首先,它会提取 scope
或 scp
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
使用 JwtGrantedAuthoritiesConverter
将 GrantedAuthority
对象和其他属性填充到 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 映射到 Authentication
的 name
属性的标准 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
名称更友好。我们可以通过设置 JwtAuthenticationConverter
的 principalClaimName
属性,并将其设置为所需的 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
如果没有映射,会被保留。
最后,在 @Configuration
的 jwtGrantedAuthoritiesConverter()
方法中使用这个增强的 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