Spring Security OAuth2.0認證授權五:用戶信息擴展到jwt
- 2021 年 1 月 14 日
- 筆記
- JAVA, JAVA EE 學習筆記, OAuth2.0, spring security
歷史文章
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中,經過網關解析之後攜帶用戶信息訪問目標服務,目標服務將用戶信息保存到上下文並保證線程安全性的情況下封裝成工具類提供給各種環境下使用。
註:本文章基於源代碼//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.接口測試
{
"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": "[email protected]",
"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": "[email protected]",
"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 父子線程數據傳遞問題
四、源代碼
源碼地址://gitee.com/kdyzm/spring-security-oauth-study/tree/v6.0.0
我的博客原文章地址://blog.kdyzm.cn/post/31