Spring Security类型安全访问自定义OIDC声明的最佳方案
我们的Web应用基于OpenID Connect + Spring Security 5实现用户认证,借助框架开箱即用的OIDC能力,仅需通过oauth2Login配置即可快速完成OAuth2认证集成。
身份提供方(IdP)通过userInfo端点暴露了业务逻辑中高频使用的属性,其中包含不少富对象列表,使用通用Map<String,Object>结构访问存在诸多不便,我们需要以类型安全的方式访问这些属性。
默认实现中,OidcUserService会将userInfo端点返回的属性与ID令牌中的属性合并,存入DefaultOidcUser,作为OAuth2AuthenticationToken的组成部分,目前默认获取用户属性有三种途径:
- 通过
@AuthenticationPrincipal DefaultOidcUser defaultOidcUser注入,获取以Map结构存储全量属性的DefaultOidcUser实例 - 注入
java.security.Principal,获取OAuth2AuthenticationToken实例,其内部持有的principal即为上述DefaultOidcUser - 通过
SecurityContextHolder.getContext().getAuthentication()获取,返回值同样是OAuth2AuthenticationToken实例
现有方案的缺陷
目前已尝试的三种属性访问方案都存在明显不足:
对Principal或SecurityContextHolder获取的认证对象做类型强转
示例代码:public void doSomething(Principal principal) { OAuth2AuthenticationToken oAuth2AuthenticationToken = (OAuth2AuthenticationToken)principal; Map<String, Object> attributes = oAuth2AuthenticationToken.getPrincipal().getAttributes(); ... }缺点是需要手动做类型强转,且Map转POJO的逻辑散落在各业务代码中,无法做到一次转换全局复用。
通过
@AuthenticationPrincipal注解直接注入DefaultOidcUser访问属性
示例代码:public void doSomething(@AuthenticationPrincipal DefaultOidcUser defaultOidcUser) { Map<String, Object> attributes = defaultOidcUser.getAttributes(); ... }该方案省去了手动强转的步骤,但仍无法直接获取POJO类型的自定义属性。
自定义
OAuth2UserService钩子返回自定义OidcUser实现,暴露类型安全属性
示例代码:private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() { final OidcUserService delegate = new OidcUserService(); return (userRequest) -> { // 加载默认生成的用户对象 OidcUser oidcUser = delegate.loadUser(userRequest); // 解析自定义声明 CustomTypedAttributes customTypedAttributes = objectMapper.convertValue(oidcUser.getClaims(), CustomTypedAttributes.class); // 封装为自定义用户对象返回 return new CustomOidcUser(oidcUser.getAuthorities(), oidcUser.getIdToken(), oidcUser.getUserInfo(), customTypedAttributes); }; }该方案的优势是用户登录时仅需做一次属性转换,后续即可通过
customOidcUser.getCustomTypedAttributes()实现类型安全的属性访问,但仅为暴露类型安全属性就封装完整OidcUser实现的方式不够简洁优雅。
我们需要的替代方案需要满足三个核心要求:
- 支持便捷、类型安全地访问自定义OIDC属性
- 基于Spring Security标准原生组件实现
- 对测试友好,可直接适配Spring Security测试框架
直接利用Spring Security原生支持的AuthenticationPrincipal自定义解析能力实现,不需要封装自定义OidcUser,侵入性极低,完全符合要求。
实现步骤
定义自定义注入注解,通过SpEL声明属性解析逻辑
@Target({ElementType.PARAMETER, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @AuthenticationPrincipal(expression = "@oidcAttributeMapper.map(#this)") public @interface CurrentUser {}注解中SpEL表达式的
#this指代当前认证对象中的principal(即默认的DefaultOidcUser实例),表达式会直接调用Spring容器中名为oidcAttributeMapper的Bean的转换方法,返回最终的POJO对象。实现统一属性转换Mapper,注册为Spring Bean
@Component public class OidcAttributeMapper { private final ObjectMapper objectMapper; public OidcAttributeMapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } public CustomTypedAttributes map(OidcUser oidcUser) { // 可按需添加缓存,避免同一会话内重复转换 return objectMapper.convertValue(oidcUser.getClaims(), CustomTypedAttributes.class); } }如果业务场景中用户属性不会在会话周期内动态变更,也可以将转换结果按用户唯一标识(sub声明)做缓存,进一步降低开销,普通业务场景下直接转换的性能损耗完全可以忽略。
业务代码中直接注入使用即可
@RestController public class BusinessController { @GetMapping("/profile") public CustomTypedAttributes getProfile(@CurrentUser CustomTypedAttributes currentUser) { // 直接拿到类型安全的POJO,无强转、无零散Map解析逻辑 return currentUser; } }
方案优势
- 完全基于Spring Security原生的
AuthenticationPrincipalArgumentResolver实现,没有引入非标准自定义组件 - 不需要修改原有OidcUser、OidcUserService的核心逻辑,侵入性极低
- 测试适配成本极低:使用Spring Security Test框架时,既可以直接Mock转换后的POJO对象注入,也可以通过默认的
@WithMockUser、测试SecurityContext构造逻辑快速构造测试场景,不需要额外适配自定义OidcUser的构造逻辑 - 属性转换逻辑全局统一维护,后续调整映射规则时仅需修改Mapper代码,业务代码完全无感知
如果需要更细粒度的属性注入,比如仅需要注入单个声明字段而非完整POJO,甚至不需要编写Mapper,直接在注解中写SpEL读取对应claim即可,例如
@AuthenticationPrincipal(expression = "claims['phone_number']")可以直接注入用户手机号字符串。
内容的提问来源于stack exchange,提问作者ddewaele

