Spring Security OAuth2.0認證授權五:用戶資訊擴展到jwt

歷史文章

Spring Security OAuth2.0認證授權一:框架搭建和認證測試
Spring Security OAuth2.0認證授權二:搭建資源服務
Spring Security OAuth2.0認證授權三:使用JWT令牌
Spring Security OAuth2.0認證授權四:分散式系統認證授權

上一篇文章講解了如何在分散式系統環境下進行認證和鑒權,總體來說就是網關認證,目標服務鑒權,但是存在著一個問題:關於用戶資訊,目標服務只能獲取到網關轉發過來的username資訊,為啥呢,因為認證服務頒發jwt令牌的時候就只存放了這麼多資訊,我們到jwt.io網站上貼出jwt令牌查看下payload中內容就就知道有什麼內容了:

jwt base64 decode結果

本篇文章的目的就是為了解決該問題,把用戶資訊(用戶名、頭像、手機號、郵箱等)放到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.介面測試

POST請求//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": "[email protected]",
        "mobile": "12345678912",
        "fullname": "張三"
    },
    "jti": "1d8f78af-857a-4e31-9861-6dabc6587726"
}

可以看到結果中多了user_info欄位,而且access_token長了很多,我們的目的是為了在jwt也就是access_token中放入用戶資訊,先不管為何user_info會以明文出現在這裡,我們先看下access_token中多了哪些內容

POST請求h//127.0.0.1:30000/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX2luZm8iOnsidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImlkIjoxLCJlbWFpbCI6IjEyMzQ1NkBmb3htYWlsLmNvbSIsIm1vYmlsZSI6IjEyMzQ1Njc4OTEyIiwiZnVsbG5hbWUiOiLlvKDkuIkifSwidXNlcl9uYW1lIjoiemhhbmdzYW4iLCJzY29wZSI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIiwiUk9MRV9BUEkiXSwiZXhwIjoxNjEwNjM4NjQzLCJhdXRob3JpdGllcyI6WyJwMSIsInAyIl0sImp0aSI6IjFkOGY3OGFmLTg1N2EtNGUzMS05ODYxLTZkYWJjNjU4NzcyNiIsImNsaWVudF9pZCI6ImMxIn0.Y9f5psNCgZi_I2KY3PLBLjuK5-U1VhXIB1vjKjMb9fc,得到相應結果

{
    "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