認證鑑權與API許可權控制在微服務架構中的設計與實現(二)

aoho發表於2017-10-22

引言: 本文系《認證鑑權與API許可權控制在微服務架構中的設計與實現》系列的第二篇,本文重點講解使用者身份的認證與token發放的具體實現。本文篇幅較長,對涉及到的大部分程式碼進行了分析,可收藏於閒暇時間閱讀,歡迎訂閱本系列文章。

1. 系統概覽

在上一篇 認證鑑權與API許可權控制在微服務架構中的設計與實現(一)介紹了該專案的背景以及技術調研與最後選型,並且對於最終實現的endpoint執行結果進行展示。對系統架構雖然有提到,但是並未列出詳細流程圖。在筆者的應用場景中,Auth系統與閘道器進行結合。在閘道器出配置相應的端點資訊,如登入系統申請token授權,校驗check_token等端點。

下圖為閘道器與Auth系統結合的流程圖,閘道器係統的具體實現細節在後面另寫文章介紹。(此處流程圖的繪製中,筆者使用極簡的語言描述,各位同學輕噴?!)

login
login

上圖展示了系統登入的簡單流程,其中的細節有省略,使用者資訊的合法性校驗實際是呼叫使用者系統。大體流程是這樣,客戶端請求到達閘道器之後,根據閘道器識別的請求登入端點,轉發到Auth系統,將使用者的資訊進行校驗。

另一方面是對於一般請求的校驗。一些不需要許可權的公開介面,在閘道器處配置好,請求到達閘道器後,匹配了路徑將會直接放行。如果需要對該請求進行校驗,會將該請求的相關驗證資訊擷取,以及API許可權校驗所需的上下文資訊(筆者專案對於一些操作進行許可權前置驗證,下一篇章會講到),呼叫Auth系統,校驗成功後進行路由轉發。

gw
gw

這篇文章就重點講解我們在第一篇文章中提到的使用者身份的認證與token發放。這個也主要包含兩個方面:

  • 使用者合法性的認證
  • 獲取到授權的token

2. 配置與類圖

2.1 AuthorizationServer主要配置

關於AuthorizationServerResourceServer的配置在上一篇文章已經列出。AuthorizationServer主要是繼承了AuthorizationServerConfigurerAdapter,覆寫了其實現介面的三個方法:

    //對應於配置AuthorizationServer安全認證的相關資訊,建立ClientCredentialsTokenEndpointFilter核心過濾器
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { 
    }

    //配置OAuth2的客戶端相關資訊
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    }

    //配置身份認證器,配置認證方式,TokenStore,TokenGranter,OAuth2RequestFactory
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    }複製程式碼

2.2 主要Authentication類的類圖

auth
auth

主要的驗證方法authenticate(Authentication authentication)在介面AuthenticationManager中,其實現類有ProviderManager,有上圖可以看出ProviderManager又依賴於AuthenticationProvider介面,其定義了一個List<AuthenticationProvider>全域性變數。筆者這邊實現了該介面的實現類CustomAuthenticationProvider。自定義一個provider,並在GlobalAuthenticationConfigurerAdapter中配置好改自定義的校驗provider,覆寫configure()方法。

@Configuration
public class AuthenticationManagerConfig extends GlobalAuthenticationConfigurerAdapter {

    @Autowired
    CustomAuthenticationProvider customAuthenticationProvider;

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(customAuthenticationProvider);//使用自定義的AuthenticationProvider
    }

}複製程式碼

AuthenticationManagerBuilder是用來建立AuthenticationManager,允許自定義提供多種方式的AuthenticationProvider,比如LDAP、基於JDBC等等。

3. 認證與授權token

下面講解認證與授權token主要的類與介面。

3.1 內建端點TokenEndpoint

Spring-Security-Oauth2的提供的jar包中內建了與token相關的基礎端點。本文認證與授權token與/oauth/token有關,其處理的介面類為TokenEndpoint。下面我們來看一下對於認證與授權token流程的具體處理過程。

@FrameworkEndpoint
public class TokenEndpoint extends AbstractEndpoint {
    ... 
    @RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
    public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
    Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
    //首先對client資訊進行校驗
        if (!(principal instanceof Authentication)) {
            throw new InsufficientAuthenticationException(
                    "There is no client authentication. Try adding an appropriate authentication filter.");
        }

        String clientId = getClientId(principal);
        //根據請求中的clientId,載入client的具體資訊
        ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);

        TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);

        ... 

        //驗證scope域範圍
        if (authenticatedClient != null) {
            oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
        }
        //授權方式不能為空
        if (!StringUtils.hasText(tokenRequest.getGrantType())) {
            throw new InvalidRequestException("Missing grant type");
        }
        //token endpoint不支援Implicit模式
        if (tokenRequest.getGrantType().equals("implicit")) {
            throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
        }
        ...

        //進入CompositeTokenGranter,匹配授權模式,然後進行password模式的身份驗證和token的發放
        OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
        if (token == null) {
            throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
        }

        return getResponse(token);

    }
    ...
}複製程式碼

client
client

上面給程式碼進行了註釋,讀者感興趣可以看看。介面處理的主要流程就是對authentication資訊進行檢查是否合法,不合法直接丟擲異常,然後對請求的GrantType進行處理,根據GrantType,進行password模式的身份驗證和token的發放。
這邊涉及到的getTokenGranter(),程式碼也列下:

public class CompositeTokenGranter implements TokenGranter {

    //GrantType的集合,有五種,之前有講
    private final List<TokenGranter> tokenGranters;

    public CompositeTokenGranter(List<TokenGranter> tokenGranters) {
        this.tokenGranters = new ArrayList<TokenGranter>(tokenGranters);
    }

    //遍歷list,匹配到相應的grantType就進行處理
    public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
        for (TokenGranter granter : tokenGranters) {
            OAuth2AccessToken grant = granter.grant(grantType, tokenRequest);
            if (grant!=null) {
                return grant;
            }
        }
        return null;
    }
    ...
}複製程式碼

本次請求是使用的password模式,隨後進入其GrantType具體的處理流程,下面是grant()方法。

    public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

        if (!this.grantType.equals(grantType)) {
            return null;
        }

        String clientId = tokenRequest.getClientId();
        //載入clientId對應的ClientDetails,為了下一步的驗證
        ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
        //再次驗證clientId是否擁有該grantType模式,安全
        validateGrantType(grantType, client);
        //獲取token
        return getAccessToken(client, tokenRequest);

    }

    protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
    //進入建立token之前,進行身份驗證
        return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
    }

    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
    //身份驗證
        OAuth2Request storedOAuth2Request = requestFactory.createOAuth2Request(client, tokenRequest);
        return new OAuth2Authentication(storedOAuth2Request, null);
    }複製程式碼

上面一段程式碼是grant()方法具體的實現細節。GrantType匹配到其對應的grant()後,先進行基本的驗證確保安全,然後進入主流程,就是下面小節要講的驗證身份和發放token。

3.2 自定義的驗證類CustomAuthenticationProvider

CustomAuthenticationProvider中定義了驗證方法的具體實現。其具體實現如下所示。

    //主要的自定義驗證方法
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();
        Map data = (Map) authentication.getDetails();
        String clientId = (String) data.get("client");
        Assert.hasText(clientId,"clientId must have value" );
        String type = (String) data.get("type");
        //通過呼叫user服務,校驗使用者資訊
        Map map = userClient.checkUsernameAndPassword(getUserServicePostObject(username, password, type));
            //校驗返回的資訊,不正確則丟擲異常,授權失敗
        String userId = (String) map.get("userId");
        if (StringUtils.isBlank(userId)) {
            String errorCode = (String) map.get("code");
            throw new BadCredentialsException(errorCode);
        }
        CustomUserDetails customUserDetails = buildCustomUserDetails(username, password, userId, clientId);
        return new CustomAuthenticationToken(customUserDetails);
    }
    //構造一個CustomUserDetails,簡單,略去
    private CustomUserDetails buildCustomUserDetails(String username, String password, String userId, String clientId) {
    }
    //構造一個請求userService的map,內容略
    private Map<String, String> getUserServicePostObject(String username, String password, String type) {
    }複製程式碼

authenticate()最後返回構造的自定義CustomAuthenticationToken,在CustomAuthenticationToken中,將boolean authenticated設為true,user資訊驗證成功。這邊傳入的引數CustomUserDetails與token生成有關,作為payload中的資訊,下面會講到。

//繼承抽象類AbstractAuthenticationToken
public class CustomAuthenticationToken extends AbstractAuthenticationToken {
    private CustomUserDetails userDetails;
    public CustomAuthenticationToken(CustomUserDetails userDetails) {
        super(null);
        this.userDetails = userDetails;
        super.setAuthenticated(true);
    }
    ...
}複製程式碼

AbstractAuthenticationToken實現了介面Authentication和CredentialsContainer,裡面的具體資訊讀者可以自己看下原始碼。

3.3 關於JWT

使用者資訊校驗完成之後,下一步則是要對該使用者進行授權。在講具體的授權之前,先補充下關於JWT Token的相關知識點。

Json web token (JWT), 是為了在網路應用環境間傳遞宣告而執行的一種基於JSON的開放標準(RFC 7519)。該token被設計為緊湊且安全的,特別適用於分散式站點的單點登入(SSO)場景。JWT的宣告一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便於從資源伺服器獲取資源,也可以增加一些額外的其它業務邏輯所必須的宣告資訊,該token也可直接被用於認證,也可被加密。

從上面的描述可知JWT的定義,這邊讀者可以對比下token的認證和傳統的session認證的區別。推薦一篇文章什麼是 JWT -- JSON WEB TOKEN,筆者這邊就不詳細擴充套件講了,只是簡單介紹下其構成。

JWT包含三部分:header頭部、payload資訊、signature簽名。下面以上一篇生成好的access_token為例介紹。

  • header
    jwt的頭部承載兩部分資訊,一是宣告型別,這裡是jwt;二是宣告加密的演算法 通常直接使用 HMAC SHA256。第一部分一般固定為:
    eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9複製程式碼
  • playload
    存放的有效資訊,這些有效資訊包含三個部分、標準中註冊的宣告、公共的宣告、私有的宣告。這邊筆者額外新增的資訊為X-KEETS-UserIdX-KEETS-ClientId。讀者可根據實際專案需要進行定製。最後playload經過base64編碼後的結果為:

      eyJYLUtFRVRTLVVzZXJJZCI6ImQ2NDQ4YzI0LTNjNGMtNGI4MC04MzcyLWMyZDYxODY4ZjhjNiIsImV4cCI6MTUwODQ0Nzc1NiwidXNlcl9uYW1lIjoia2VldHMiLCJqdGkiOiJiYWQ3MmIxOS1kOWYzLTQ5MDItYWZmYS0wNDMwZTdkYjc5ZWQiLCJjbGllbnRfaWQiOiJmcm9udGVuZCIsInNjb3BlIjpbImFsbCJdfQ複製程式碼
  • signature
    jwt的第三部分是一個簽證資訊,這個簽證資訊由三部分組成:header (base64後的)、payload (base64後的)、secret。
    關於secret,細心的讀者可能會發現之前的配置裡面有具體設定。前兩部分連線組成的字串,通過header中宣告的加密方式進行加鹽secret組合加密,然後就構成了jwt的第三部分。第三部分結果為:

    5ZNVN8TLavgpWy8KZQKArcbj7ItJLLaY1zBRaAgMjdo複製程式碼

至於具體應用方法,可以參見第一篇文章中構建的/logout端點。

3.3 自定義的AuthorizationTokenServices

現在到了為使用者建立token,這邊主要與自定義的介面AuthorizationServerTokenServices有關。AuthorizationServerTokenServices主要有如下三個方法:

    //建立token
    OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;
    //重新整理token
    OAuth2AccessToken refreshAccessToken(String refreshToken, TokenRequest tokenRequest)
            throws AuthenticationException;
    //獲取token
    OAuth2AccessToken getAccessToken(OAuth2Authentication authentication);複製程式碼

由於篇幅限制,筆者這邊僅對createAccessToken()的實現方法進行分析,其他的方法實現,讀者可以下關注筆者的GitHub專案。

public class CustomAuthorizationTokenServices implements AuthorizationServerTokenServices, ConsumerTokenServices {
    ...

    public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
        //通過TokenStore,獲取現存的AccessToken
        OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
        OAuth2RefreshToken refreshToken;
        //移除已有的AccessToken和refreshToken
        if (existingAccessToken != null) {
            if (existingAccessToken.getRefreshToken() != null) {
                refreshToken = existingAccessToken.getRefreshToken();
                // The token store could remove the refresh token when the
                    // access token is removed, but we want to be sure
                tokenStore.removeRefreshToken(refreshToken);
            }
            tokenStore.removeAccessToken(existingAccessToken);
        }
        //recreate a refreshToken
        refreshToken = createRefreshToken(authentication);

        OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
        if (accessToken != null) {
            tokenStore.storeAccessToken(accessToken, authentication);
        }
        refreshToken = accessToken.getRefreshToken();
        if (refreshToken != null) {
            tokenStore.storeRefreshToken(refreshToken, authentication);
        }
        return accessToken;
    }
    ...
}複製程式碼

這邊具體的實現在上面有註釋,基本沒有改寫多少,讀者此處可以參閱原始碼。createAccessToken()還呼叫了兩個私有方法,分別建立accessToken和refreshToken。建立accessToken,需要基於refreshToken。
此處可以自定義設定token的時效長度,accessToken建立實現如下:

     private int refreshTokenValiditySeconds = 60 * 60 * 24 * 30; // default 30 days.

    private int accessTokenValiditySeconds = 60 * 60 * 12; // default 12 hours.

    private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
    //對應tokenId,儲存的標識
        DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
        int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
        if (validitySeconds > 0) {
            token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
        }
        token.setRefreshToken(refreshToken);
        //scope對應作用範圍
        token.setScope(authentication.getOAuth2Request().getScope());
        //上一節介紹的自定義TokenEnhancer,這邊使用
        return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
    }複製程式碼

既然提到TokenEnhancer,這邊簡單貼一下程式碼。

public class CustomTokenEnhancer extends JwtAccessTokenConverter {

    private static final String TOKEN_SEG_USER_ID = "X-KEETS-UserId";
    private static final String TOKEN_SEG_CLIENT = "X-KEETS-ClientId";

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken,
                                     OAuth2Authentication authentication) {
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();

        Map<String, Object> info = new HashMap<>();
        //從自定義的userDetails中取出UserId
        info.put(TOKEN_SEG_USER_ID, userDetails.getUserId());

        DefaultOAuth2AccessToken customAccessToken = new DefaultOAuth2AccessToken(accessToken);
        customAccessToken.setAdditionalInformation(info);

        OAuth2AccessToken enhancedToken = super.enhance(customAccessToken, authentication);
        //設定ClientId
        enhancedToken.getAdditionalInformation().put(TOKEN_SEG_CLIENT, userDetails.getClientId());

        return enhancedToken;
    }

}複製程式碼

自此,使用者身份校驗與發放授權token結束。最終成功返回的結果為:

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJYLUtFRVRTLVVzZXJJZCI6ImQ2NDQ4YzI0LTNjNGMtNGI4MC04MzcyLWMyZDYxODY4ZjhjNiIsImV4cCI6MTUwODQ0Nzc1NiwidXNlcl9uYW1lIjoia2VldHMiLCJqdGkiOiJiYWQ3MmIxOS1kOWYzLTQ5MDItYWZmYS0wNDMwZTdkYjc5ZWQiLCJjbGllbnRfaWQiOiJmcm9udGVuZCIsInNjb3BlIjpbImFsbCJdfQ.5ZNVN8TLavgpWy8KZQKArcbj7ItJLLaY1zBRaAgMjdo",   
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJYLUtFRVRTLVVzZXJJZCI6ImQ2NDQ4YzI0LTNjNGMtNGI4MC04MzcyLWMyZDYxODY4ZjhjNiIsInVzZXJfbmFtZSI6ImtlZXRzIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImJhZDcyYjE5LWQ5ZjMtNDkwMi1hZmZhLTA0MzBlN2RiNzllZCIsImV4cCI6MTUxMDk5NjU1NiwianRpIjoiYWE0MWY1MjctODE3YS00N2UyLWFhOTgtZjNlMDZmNmY0NTZlIiwiY2xpZW50X2lkIjoiZnJvbnRlbmQifQ.mICT1-lxOAqOU9M-Ud7wZBb4tTux6OQWouQJ2nn1DeE",
    "expires_in": 43195,
    "scope": "all",
    "X-KEETS-UserId": "d6448c24-3c4c-4b80-8372-c2d61868f8c6",
    "jti": "bad72b19-d9f3-4902-affa-0430e7db79ed",
    "X-KEETS-ClientId": "frontend"
}複製程式碼

4. 總結

本文開頭給出了Auth系統概述,畫出了簡要的登入和校驗的流程圖,方便讀者能對系統的實現有個大概的瞭解。然後主要講解了使用者身份的認證與token發放的具體實現。對於其中主要的類和介面進行了分析與講解。下一篇文章主要講解token的鑑定和API級別的上下文許可權校驗。

本文的原始碼地址:
GitHub:github.com/keets2012/A…
碼雲: gitee.com/keets/Auth-…

訂閱最新文章,歡迎關注我的公眾號

微信公眾號
微信公眾號


參考

  1. 什麼是 JWT -- JSON WEB TOKEN
  2. Re:從零開始的Spring Security OAuth2(二)

相關閱讀

認證鑑權與API許可權控制在微服務架構中的設計與實現(一)

相關文章