Spring Security OAuth2.0认证授权五:用户信息扩展到jwt

使用SpringBoot搭建Web项目

历史文章

Spring Security OAuth2.0认证授权一:框架搭建和认证测试
Spring Security OAuth2.0认证授权二:搭建资源服务
Spring Security OAuth2.0认证授权三:使用JWT令牌
Spring Security OAuth2.0认证授权四:分布式系统认证授权

上一篇文章讲解了如何在分布式系统环境下进行认证和鉴权,总体来说就是网关认证,目标服务鉴权,但是存在着一个问题:关于用户信息,目标服务只能获取到网关转发过来的username信息,为啥呢,因为认证服务颁发jwt令牌的时候就只存放了这么多信息,我们到jwt.io网站上贴出jwt令牌查看下payload中内容就就知道有什么内容了:

本篇文章的目的就是为了解决该问题,把用户信息(用户名、头像、手机号、邮箱等)放到jwt token中,经过网关解析之后携带用户信息访问目标服务,目标服务将用户信息保存到上下文并保证线程安全性的情况下封装成工具类提供给各种环境下使用。

注:本文章基于源代码https://gitee.com/kdyzm/spring-security-oauth-study/tree/v5.0.0 分析和改造。

一、实现UserDetailsService接口

1.问题分析和修改

jwt令牌中用户信息过于少的原因在于认证服务auth-server中com.kdyzm.spring.security.auth.center.service.MyUserDetailsServiceImpl#loadUserByUsername 方法中的这段代码

return User
                .withUsername(tUser.getUsername())
                .password(tUser.getPassword())
                .authorities(array).build();

这里User类实现了UserDetailsService接口,并使用建造者模式生成了需要的UserDetailsService对象,可以看到生成该对象仅仅传了三个参数,而用户信息仅仅有用户名和password两个参数———那么如何扩展用户信息就一目了然了,我们自己也实现UserDetailsService接口然后返回改值不就好了吗?不好!!实现UserDetailsService接口要实现它需要的好几个方法,不如直接继承User类,在改动最小的情况下保持原有的功能基本不变,这里定义UserDetailsExpand继承User

public class UserDetailsExpand extends User {
    public UserDetailsExpand(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
    }
    //userId
    private Integer id;
    //电子邮箱
    private String email;
    //手机号
    private String mobile;
    private String fullname;
    //Getter/Setter方法略
}

之后,修改com.kdyzm.spring.security.auth.center.service.MyUserDetailsServiceImpl#loadUserByUsername方法返回该类的对象即可

        UserDetailsExpand userDetailsExpand = new UserDetailsExpand(tUser.getUsername(), tUser.getPassword(), AuthorityUtils.createAuthorityList(array));
        userDetailsExpand.setId(tUser.getId());
        userDetailsExpand.setMobile(tUser.getMobile());
        userDetailsExpand.setFullname(tUser.getFullname());
        return userDetailsExpand;

2.测试修改和源码分析

修改了以上代码之后我们启动服务,获取jwt token之后查看其中的内容,会发现用户信息并没有填充进去,测试失败。。。。再分析下,为什么会没有填充进去?关键在于JwtAccessTokenConverter这个类,该类未发起作用的时候,返回请求放的token只是一个uuid类型(好像是uuid)的简单字符串,经过该类的转换之后就将一个简单的uuid转换成了jwt字符串,该类中的org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter#convertAccessToken方法在起作用,顺着该方法找下去:org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter#convertAccessToken,然后就发现了这行代码

response.putAll(token.getAdditionalInformation());

这个token就是OAuth2AccessToken对象,也就是真正返回给请求者的对象,查看该类中该字段的解释

/**
	 * The additionalInformation map is used by the token serializers to export any fields used by extensions of OAuth.
	 * @return a map from the field name in the serialized token to the value to be exported. The default serializers 
	 * make use of Jackson's automatic JSON mapping for Java objects (for the Token Endpoint flows) or implicitly call 
	 * .toString() on the "value" object (for the implicit flow) as part of the serialization process.
	 */
	Map<String, Object> getAdditionalInformation();

可以看到,该字段是专门用来扩展OAuth字段的属性,万万没想到JWT同时用它扩展jwt串。。。接下来就该想想怎么给OAuth2AccessToken对象填充这个扩展字段了。

如果仔细看JwtAccessTokenConverter这个类的源码,可以看到有个方法org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter#enhance,该方法有个参数OAuth2AccessToken accessToken,同时它的返回值也是OAuth2AccessToken,也就是说这个方法,传入了OAuth2AccessToken对象,完事儿了之后还传出了OAuth2AccessToken对象,再根据enhance这个名字,可以推测出,它是一个增强方法,修改了或者代理了OAuth2AccessToken对象,查看父接口,是TokenEnhancer接口

public interface TokenEnhancer {
	/**
	 * Provides an opportunity for customization of an access token (e.g. through its additional information map) during
	 * the process of creating a new token for use by a client.
	 * 
	 * @param accessToken the current access token with its expiration and refresh token
	 * @param authentication the current authentication including client and user details
	 * @return a new token enhanced with additional information
	 */
	OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication);
}

根据该注释可以看出该方法用于定制access_token,那么通过这个方法填充access token的AdditionalInformation属性貌似正合适(别忘了目的是干啥的)。

看下JwtAccessTokenConverter是如何集成到认证服务的

    @Bean
    public AuthorizationServerTokenServices tokenServices(){
        DefaultTokenServices services = new DefaultTokenServices();
        services.setClientDetailsService(clientDetailsService);
        services.setSupportRefreshToken(true);
        services.setTokenStore(tokenStore);
        services.setAccessTokenValiditySeconds(7200);
        services.setRefreshTokenValiditySeconds(259200);

        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Collections.singletonList(jwtAccessTokenConverter));
        services.setTokenEnhancer(tokenEnhancerChain);
        return services;
    }

可以看到这里的tokenEnhancerChain可以传递一个列表,这里只传了一个jwtAccessTokenConverter对象,那么解决方案就有了,实现TokenEnhancer接口并将对象填到该列表中就可以了

3.实现TokenEnhancer接口

@Slf4j
@Component
public class CustomTokenEnhancer implements TokenEnhancer {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String,Object> additionalInfo = new HashMap<>();
        Object principal = authentication.getPrincipal();
        try {
            String s = objectMapper.writeValueAsString(principal);
            Map map = objectMapper.readValue(s, Map.class);
            map.remove("password");
            map.remove("authorities");
            map.remove("accountNonExpired");
            map.remove("accountNonLocked");
            map.remove("credentialsNonExpired");
            map.remove("enabled");
            additionalInfo.put("user_info",map);
            ((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(additionalInfo);
        } catch (IOException e) {
            log.error("",e);
        }
        return accessToken;
    }
}

以上代码干了以下几件事儿:

  • 从OAuth2Authentication对象取出principal对象
  • 转换principal对象为map并删除map对象中的若干个不想要的字段属性
  • 将map对象填充进入OAuth2AccessToken对象的additionalInfo属性

实现TokenEnhancer接口后将该对象加入到TokenEnhancerChain中

用户画像分析与场景应用

TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(customTokenEnhancer,jwtAccessTokenConverter));

4.接口测试

POST请求http://127.0.0.1:30000/oauth/token?client_id=c1&client_secret=secret&grant_type=password&username=zhangsan&password=123得到结果

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX2luZm8iOnsidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImlkIjoxLCJlbWFpbCI6IjEyMzQ1NkBmb3htYWlsLmNvbSIsIm1vYmlsZSI6IjEyMzQ1Njc4OTEyIiwiZnVsbG5hbWUiOiLlvKDkuIkifSwidXNlcl9uYW1lIjoiemhhbmdzYW4iLCJzY29wZSI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIiwiUk9MRV9BUEkiXSwiZXhwIjoxNjEwNjM4NjQzLCJhdXRob3JpdGllcyI6WyJwMSIsInAyIl0sImp0aSI6IjFkOGY3OGFmLTg1N2EtNGUzMS05ODYxLTZkYWJjNjU4NzcyNiIsImNsaWVudF9pZCI6ImMxIn0.Y9f5psNCgZi_I2KY3PLBLjuK5-U1VhXIB1vjKjMb9fc",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX2luZm8iOnsidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImlkIjoxLCJlbWFpbCI6IjEyMzQ1NkBmb3htYWlsLmNvbSIsIm1vYmlsZSI6IjEyMzQ1Njc4OTEyIiwiZnVsbG5hbWUiOiLlvKDkuIkifSwidXNlcl9uYW1lIjoiemhhbmdzYW4iLCJzY29wZSI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIiwiUk9MRV9BUEkiXSwiYXRpIjoiMWQ4Zjc4YWYtODU3YS00ZTMxLTk4NjEtNmRhYmM2NTg3NzI2IiwiZXhwIjoxNjEwODkwNjQzLCJhdXRob3JpdGllcyI6WyJwMSIsInAyIl0sImp0aSI6IjM1OGFkMzA1LTU5NzUtNGM3MS05ODI4LWQ2N2ZjN2MwNDMyMCIsImNsaWVudF9pZCI6ImMxIn0._bhajMIdqnUL1zgc8d-5xlXSzhsCWbZ2jBWlNb8m_hw",
    "expires_in": 7199,
    "scope": "ROLE_ADMIN ROLE_USER ROLE_API",
    "user_info": {
        "username": "zhangsan",
        "id": 1,
        "email": "123456@foxmail.com",
        "mobile": "12345678912",
        "fullname": "张三"
    },
    "jti": "1d8f78af-857a-4e31-9861-6dabc6587726"
}

可以看到结果中多了user_info字段,而且access_token长了很多,我们的目的是为了在jwt也就是access_token中放入用户信息,先不管为何user_info会以明文出现在这里,我们先看下access_token中多了哪些内容

POST请求hhttp://127.0.0.1:30000/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX2luZm8iOnsidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImlkIjoxLCJlbWFpbCI6IjEyMzQ1NkBmb3htYWlsLmNvbSIsIm1vYmlsZSI6IjEyMzQ1Njc4OTEyIiwiZnVsbG5hbWUiOiLlvKDkuIkifSwidXNlcl9uYW1lIjoiemhhbmdzYW4iLCJzY29wZSI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIiwiUk9MRV9BUEkiXSwiZXhwIjoxNjEwNjM4NjQzLCJhdXRob3JpdGllcyI6WyJwMSIsInAyIl0sImp0aSI6IjFkOGY3OGFmLTg1N2EtNGUzMS05ODYxLTZkYWJjNjU4NzcyNiIsImNsaWVudF9pZCI6ImMxIn0.Y9f5psNCgZi_I2KY3PLBLjuK5-U1VhXIB1vjKjMb9fc,得到相应结果

{
    "aud": [
        "res1"
    ],
    "user_info": {
        "username": "zhangsan",
        "id": 1,
        "email": "123456@foxmail.com",
        "mobile": "12345678912",
        "fullname": "张三"
    },
    "user_name": "zhangsan",
    "scope": [
        "ROLE_ADMIN",
        "ROLE_USER",
        "ROLE_API"
    ],
    "exp": 1610638643,
    "authorities": [
        "p1",
        "p2"
    ],
    "jti": "1d8f78af-857a-4e31-9861-6dabc6587726",
    "client_id": "c1"
}

可以看到user_info也已经填充到了jwt串中,那么为什么这个串还会以明文的形式出现在相应结果的其它字段中呢?还记得本文章中说过的一句话"可以看到,该字段是专门用来扩展OAuth字段的属性,万万没想到JWT同时用它扩展jwt串",我们给OAuth2AccessToken对象填充了AdditionalInformation字段,而这本来是为了扩展OAuth用的,所以返回结果中自然会出现这个字段。

到此为止,接口测试已经成功了,接下来修改网关和目标服务(这里是资源服务),将用户信息提取出来并保存到上下文中

二、修改网关

网关其实不需要做啥大的修改,但是会出现中文乱码问题,这里使用Base64编码之后再将用户数据放到请求头带给目标服务。修改TokenFilter类

//builder.header("token-info", payLoad).build();
builder.header("token-info", Base64.encode(payLoad.getBytes(StandardCharsets.UTF_8))).build();

三、修改资源服务

1.修改AuthFilterCustom

上一篇文章中床架了该类并将userName填充到了UsernamePasswordAuthenticationToken对象的Principal,这里我们需要将扩展的UserInfo整个填充到Principal,完整代码如下

public class AuthFilterCustom extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        String base64Token = request.getHeader("token-info");
        if(StringUtils.isEmpty(base64Token)){
            log.info("未找到token信息");
            filterChain.doFilter(request,response);
            return;
        }
        byte[] decode = Base64.decode(base64Token);
        String tokenInfo = new String(decode, StandardCharsets.UTF_8);
        JwtTokenInfo jwtTokenInfo = objectMapper.readValue(tokenInfo, JwtTokenInfo.class);
        List<String> authorities1 = jwtTokenInfo.getAuthorities();
        String[] authorities=new String[authorities1.size()];
        authorities1.toArray(authorities);
        //将用户信息和权限填充 到用户身份token对象中
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                        jwtTokenInfo.getUser_info(),
                null,
                AuthorityUtils.createAuthorityList(authorities)
        );
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        //将authenticationToken填充到安全上下文
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request,response);
    }
}

这里JwtTokenInfo新增了user_info字段,而其类型正是前面说的UserDetailsExpand类型。

通过上述修改,我们可以在Controller中使用如下代码获取到上下文中的信息

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetailsExpand principal = (UserDetailsExpand)authentication.getPrincipal();

经过测试,结果良好,但是还存在问题,那就是在异步情况下,比如使用线程池或者新开线程的情况下,极有可能出现线程池内缓存或者取不到数据的情况(未测试,瞎猜的),具体可以参考我以前的文章使用 transmittable-thread-local 组件解决 ThreadLocal 父子线程数据传递问题

2.解决线程安全性问题

这一步是选做,但是还是建议做,如果不考虑线程安全性问题,上一步就可以了。

首先新增AuthContextHolder类维护我们需要的ThreadLocal,这里一定要使用TransmittableThreadLocal。

public class AuthContextHolder {
    private TransmittableThreadLocal threadLocal = new TransmittableThreadLocal();
    private static final AuthContextHolder instance = new AuthContextHolder();

    private AuthContextHolder() {
    }

    public static AuthContextHolder getInstance() {
        return instance;
    }

    public void setContext(UserDetailsExpand t) {
        this.threadLocal.set(t);
    }

    public UserDetailsExpand getContext() {
        return (UserDetailsExpand)this.threadLocal.get();
    }

    public void clear() {
        this.threadLocal.remove();
    }
}

然后新建拦截器AuthContextIntercepter

@Component
public class AuthContextIntercepter implements HandlerInterceptor {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if(Objects.isNull(authentication) || Objects.isNull(authentication.getPrincipal())){
            //无上下文信息,直接放行
            return true;
        }
        UserDetailsExpand principal = (UserDetailsExpand) authentication.getPrincipal();
        AuthContextHolder.getInstance().setContext(principal);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        AuthContextHolder.getInstance().clear();
    }
}

该拦截器在AuthFilter之后执行的,所以一定能获取到SecurityContextHolder中的内容,之后,我们就可以在Controller中使用如下代码获取用户信息了

UserDetailsExpand context = AuthContextHolder.getInstance().getContext();

是不是简单了很多~

3.其他问题

如果走到了上一步,则一定要使用阿里巴巴配套的TransmittableThreadLocal解决方案,否则TransmittableThreadLocal和普通的ThreadLocal没什么区别。具体参考使用 transmittable-thread-local 组件解决 ThreadLocal 父子线程数据传递问题

四、源代码

源码地址:https://gitee.com/kdyzm/spring-security-oauth-study/tree/v6.0.0

我的博客原文章地址:https://blog.kdyzm.cn/post/31

.NET 云原生架构师训练营(模块二 基础巩固 RabbitMQ Masstransit 异常处理)--学习笔记

相关推荐

发表评论

路人甲

网友评论(0)