历史文章java
Spring Security OAuth2.0认证受权一:框架搭建和认证测试
Spring Security OAuth2.0认证受权二:搭建资源服务
Spring Security OAuth2.0认证受权三:使用JWT令牌
Spring Security OAuth2.0认证受权四:分布式系统认证受权git
上一篇文章讲解了如何在分布式系统环境下进行认证和鉴权,整体来讲就是网关认证,目标服务鉴权,可是存在着一个问题:关于用户信息,目标服务只能获取到网关转发过来的username信息,为啥呢,由于认证服务颁发jwt令牌的时候就只存放了这么多信息,咱们到jwt.io网站上贴出jwt令牌查看下payload中内容就就知道有什么内容了:spring
本篇文章的目的就是为了解决该问题,把用户信息(用户名、头像、手机号、邮箱等)放到jwt token中,通过网关解析以后携带用户信息访问目标服务,目标服务将用户信息保存到上下文并保证线程安全性的状况下封装成工具类提供给各类环境下使用。json
注:本文章基于源代码https://gitee.com/kdyzm/spring-security-oauth-study/tree/v5.0.0 分析和改造。缓存
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
类app
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;
修改了以上代码以后咱们启动服务,获取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接口并将对象填到该列表中就能够了
@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; } }
以上代码干了如下几件事儿:
实现TokenEnhancer接口后将该对象加入到TokenEnhancerChain中
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain(); tokenEnhancerChain.setTokenEnhancers(Arrays.asList(customTokenEnhancer,jwtAccessTokenConverter));
{ "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中多了哪些内容
{ "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();
上一篇文章中床架了该类并将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 父子线程数据传递问题
这一步是选作,可是仍是建议作,若是不考虑线程安全性问题,上一步就能够了。
首先新增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();
是否是简单了不少~
若是走到了上一步,则必定要使用阿里巴巴配套的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