歷史文章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