Spring Security集成Keycloak:JWT权限映射与aud校验问题
问题描述
我们的生产应用已上线,需要校验JWT的aud字段并支持自定义受众,但Keycloak自带的aud校验无法满足需求。参考Spring Security文档配置SecurityConfig后,已成功实现aud校验,但此时SecurityFilterChain不再使用org.keycloak.adapters.springsecurity.account.SimpleKeycloakAccount,转而使用org.springframework.security.oauth2.jwt.Jwt(因添加了http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);来定制aud校验器)。
当前系统报错,提示scope与SecurityFilterChain中的角色不匹配——我们的角色存储在JWT的独立声明realm_access.roles中,但尝试grantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access[roles]");等方式均无法成功从该声明中获取权限,请求解决权限映射问题。
JWT声明示例:
{sub=316232a4-2054-4b98-ab28-756cf767fa64, resource_access={"account":{"roles":["manage-account","manage-account-links","view-profile"]}}, clientHost=192.168.0.126, email_verified=false, clientId=test-client, iss=https://url.net/auth/realms/app, typ=Bearer, preferred_username=service-account-test-backoffice, clientAddress=192.168.0.126, aud=[kibana, account], acr=1, nbf=1970-01-01T00:00:00Z, realm_access={"roles":["backoffice","offline_access","uma_authorization"]}, azp=test-client, auth_time=0, scope=email profile, exp=2023-05-05T13:34:23Z, session_state=8b2877a3-9e04-44b2-8f52-43e079c446a9, iat=2023-05-05T13:14:23Z, jti=db589f9d-44d1-4bc0-813c-e34617b3ba64}
解决方案
Spring Security默认的JwtGrantedAuthoritiesConverter只会从scope或scp声明中提取权限,无法直接处理嵌套的realm_access.roles结构,需要自定义权限转换逻辑,以下是两种可行实现方式:
方式一:自定义JwtAuthenticationConverter
创建自定义转换器,同时支持提取realm_access.roles角色与默认scope权限:
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; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> { private final JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter(); @Override public AbstractAuthenticationToken convert(Jwt jwt) { // 获取默认的scope权限 Collection<GrantedAuthority> authorities = defaultConverter.convert(jwt); // 从realm_access.roles中提取角色并添加ROLE_前缀(适配Spring Security的hasRole规则) Map<String, Object> realmAccess = jwt.getClaim("realm_access"); if (realmAccess != null && realmAccess.containsKey("roles")) { List<String> roles = (List<String>) realmAccess.get("roles"); authorities.addAll(roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList())); } return new JwtAuthenticationToken(jwt, authorities); } }
在SecurityConfig中配置使用该转换器:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.OAuth2TokenValidator; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(new CustomJwtAuthenticationConverter()) // 配置你已实现的aud校验器 .audienceValidator(audienceValidator()) ) ) .authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("backoffice") .anyRequest().authenticated() ); return http.build(); } // 你的自定义aud校验器实现 private OAuth2TokenValidator<Jwt> audienceValidator() { return new AudienceValidator(); } }
方式二:扩展JwtGrantedAuthoritiesConverter
若不需要保留默认scope权限,可直接扩展默认转换器,仅提取realm_access.roles:
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.JwtGrantedAuthoritiesConverter; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class KeycloakRealmRolesConverter extends JwtGrantedAuthoritiesConverter { @Override public Collection<GrantedAuthority> convert(Jwt jwt) { Map<String, Object> realmAccess = jwt.getClaim("realm_access"); if (realmAccess == null || !realmAccess.containsKey("roles")) { return super.convert(jwt); } List<String> roles = (List<String>) realmAccess.get("roles"); return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); } }
在配置中替换默认转换器:
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { KeycloakRealmRolesConverter rolesConverter = new KeycloakRealmRolesConverter(); JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter(); jwtConverter.setJwtGrantedAuthoritiesConverter(rolesConverter); http .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(jwtConverter) .audienceValidator(audienceValidator()) ) ) .authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("backoffice") .anyRequest().authenticated() ); return http.build(); }
关键注意点
- 角色前缀:如果使用
hasRole("角色名"),Spring Security会自动匹配带ROLE_前缀的权限,因此转换器中需要添加该前缀;若使用hasAuthority("角色名"),则无需添加前缀,直接使用原始角色名即可。 - 权限合并:方式一保留了默认的scope权限提取,若不需要可删除相关代码,仅处理
realm_access.roles。
内容的提问来源于stack exchange,提问作者Stieg

