Spring Security OAuth2.0認證授權五:使用者資訊擴充套件到jwt

狂盜一枝梅發表於2021-01-14

歷史文章

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中,經過閘道器解析之後攜帶使用者資訊訪問目標服務,目標服務將使用者資訊儲存到上下文並保證執行緒安全性的情況下封裝成工具類提供給各種環境下使用。

注:本文章基於原始碼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

相關文章