2. 背景
当配置正确的Spring Security应用收到请求时,会经过一系列步骤,核心目标是:
- 认证请求,确定访问者身份
- 判断认证后的请求是否有权执行对应操作
对于使用JWT作为主要安全机制的应用,授权流程包含:
- 从JWT载荷中提取声明值(通常是
scope
或scp
声明) - 将这些声明映射为
GrantedAuthority
对象集合
一旦安全引擎设置好这些权限,就能评估当前请求的访问限制并决定是否放行
3. 默认映射机制
开箱即用时,Spring采用直接策略将声明转换为GrantedAuthority
实例:
- 提取
scope
或scp
声明并拆分为字符串列表 - 为每个字符串创建
SimpleGrantedAuthority
,使用SCOPE_
前缀+scope值
通过以下接口验证默认行为:
@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());
if ( principal instanceof AccountToken ) {
info.put( "account", ((AccountToken)principal).getAccount());
}
return info;
}
}
当传入包含以下载荷的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": "philippe.sevestre@techcorp.com",
"family_name": "Sevestre",
"given_name": "Philippe",
"name": "Philippe Sevestre",
"scp": "profile.read",
"sub": "eXWysuqIJmK1yDywH3gArS98PVO1SV67BLt-dvmQ-pM"
}
响应结果:
{
"tokenAttributes": { },
"name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"authorities": [
"SCOPE_profile",
"SCOPE_email",
"SCOPE_openid"
]
}
可通过SecurityFilterChain
限制访问:
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
// @formatter:off
return http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
.jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService)))).build();
// @formatter:on
}
注意:刻意避免使用
WebSecurityConfigureAdapter
,因其在Spring Security 5.7中已废弃
也可使用方法级注解:
@GetMapping("/authorities")
@PreAuthorize("hasAuthority('SCOPE_profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// 同上代码
}
4. 自定义SCOPE_
前缀
修改前缀需要配置两个核心类:
JwtAuthenticationConverter
:将原始JWT转换为AbstractAuthenticationToken
JwtGrantedAuthoritiesConverter
:从JWT提取GrantedAuthority
集合
最简单的方式是提供自定义JwtAuthenticationConverter
Bean:
@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;
}
}
当设置authorities-prefix=MY_SCOPE_
后,/user/authorities
返回:
{
"tokenAttributes": { },
"name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"authorities": [
"MY_SCOPE_profile",
"MY_SCOPE_email",
"MY_SCOPE_openid"
]
}
5. 在安全构造中使用自定义前缀
修改权限前缀会影响所有依赖名称的授权规则。例如:
@PreAuthorize
表达式HttpSecurity
的授权配置
解决方案:创建前缀Bean并在SpEL中引用:
@Bean
public String jwtGrantedAuthoritiesPrefix() {
return mappingProps.getAuthoritiesPrefix() != null ?
mappingProps.getAuthoritiesPrefix() :
"SCOPE_";
}
在注解中使用:
@GetMapping("/authorities")
@PreAuthorize("hasAuthority(@jwtGrantedAuthoritiesPrefix + 'profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// 省略实现
}
在SecurityFilterChain
中使用:
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> {
auth.requestMatchers("/user/**")
.hasAuthority(mappingProps.getAuthoritiesPrefix() + "profile");
})
.build();
}
6. 自定义Principal名称
当JWT的sub
声明值不友好时(如Keycloak生成的UUID),可指定其他声明作为principal名称:
{
"sub": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"scope": "openid profile email",
"preferred_username": "user1",
"name": "User Primo"
}
通过设置principalClaimName
属性修改:
@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());
if (StringUtils.hasText(mappingProps.getPrincipalClaimName())) {
converter.setPrincipalClaimName(mappingProps.getPrincipalClaimName());
}
return converter;
}
设置principal-claim-name=preferred_username
后,响应变为:
{
"tokenAttributes": { },
"name": "user1",
"authorities": [
"MY_SCOPE_profile",
"MY_SCOPE_email",
"MY_SCOPE_openid"
]
}
7. Scope名称映射
当需要将JWT中的scope名称映射为内部名称时(如适配不同认证服务器),需自定义转换器:
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_";
// 省略构造器和setter
@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) {
// 省略解析逻辑
}
}
在配置类中使用:
@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
生成过程(如添加数据库数据)时,可采用两种方案:
方案一:自定义Bean(需继承JwtAuthenticationConverter
)
方案二:通过DSL配置(推荐):
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
// @formatter:off
return http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
.jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService)))).build();
// @formatter:on
}
自定义转换器实现:
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);
}
}
修改接口使用增强的Authentication
:
@GetMapping("/authorities")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// 创建结果Map(省略)
if (principal instanceof AccountToken) {
info.put( "account", ((AccountToken)principal).getAccount());
}
return info;
}
优势:可在应用其他部分直接使用增强的认证对象,如SpEL表达式:
@GetMapping("/account/{accountNumber}")
@PreAuthorize("authentication.account.accountNumber == #accountNumber")
public Account getAccountById(@PathVariable("accountNumber") String accountNumber, AccountToken authentication) {
return authentication.getAccount();
}
9. 测试建议
测试时需注意:
- 身份提供者选择:推荐使用嵌入式Keycloak(配置参考指南)
- 客户端配置:
- 使用Postman测试授权码流程
- 关键点:正确配置
Valid Redirect URI
(Postman使用https://oauth.pstmn.io/v1/callback
) - 无网络环境可改用密码模式(安全性较低)
- 资源服务器配置:
- 设置
spring.security.oauth2.resourceserver.jwt.issuer-uri
- 例如Keycloak的URI:
http://localhost:8083/auth/realms/baeldung
- Spring会自动获取
.well-known/openid-configuration
配置
- 设置
10. 总结
本文展示了Spring Security中JWT声明到Authorities映射的多种定制方式:
- 修改默认前缀
- 自定义Principal名称
- 实现Scope名称映射
- 完全接管认证令牌生成过程
通过这些技术,可以灵活适配不同认证服务器和业务需求,避免踩坑的同时保持代码简洁。