Spring-Security-OAuth2架構及原始碼淺析

Magic_Duck發表於2018-07-06

個人部落格地址:blog.sqdyy.cn

Spring Security OAuth2架構

Spring Security OAuth2是一個基於OAuth2封裝的一個類庫,它提供了構建Authorization ServerResource ServerClient三種Spring應用程式角色所需要的功能。Spring Security OAuth需要與Spring Framework(Spring MVC)Spring Security提供的功能協同工作,在使用Spring Security OAuth構建Authorization ServerResource ServerClient的情況下,Spring Security OAuth2的整體架構圖如下:

OAuth_OAuth2Architecture

  1. 資源擁有者通過UserAgent訪問client,在授權允許訪問授權端點的情況下,OAuth2RestTemplate會建立OAuth2認證的REST請求,指示UserAgent重定向到Authorization Server的授權端點AuthorizationEndpoint
  2. UserAgent訪問Authorization Server的授權端點的authorize方法,當未註冊授權時,授權端點將需要授權的介面/oauth/confirm_access顯示給資源擁有者,資源擁有者授權後會通過 AuthorizationServerTokenServices生成授權碼或訪問令牌,生成的令牌最終會通過userAgent重定向傳遞給客戶端。
  3. 客戶端的OAuth2RestTemplate拿到授權碼後建立請求訪問授權伺服器TokenEndpoint令牌端點,令牌端點通過呼叫AuthorizationServerTokenServices來驗證客戶端提供的授權碼進行授權,並頒發訪問令牌響應給客戶端。
  4. 客戶端的OAuth2RestTemplate在請求頭中加入從授權伺服器獲取的訪問令牌來訪問資源伺服器,資源伺服器通過OAuth2AuthenticationManager呼叫ResourceServerTokenServices驗證訪問令牌和與訪問令牌關聯的驗證資訊。訪問令牌驗證成功後,返回客戶端請求對應的資源。

上面大致講解了Spring Security OAuth2三種應用角色的執行流程,下面我們將逐個剖析這三種角色的架構和原始碼來加深理解。


Authorization Server(授權伺服器)架構

授權伺服器主要提供了資源擁有者的認證服務,客戶端通過授權伺服器向資源擁有者獲取授權,然後獲取授權伺服器頒發的令牌。在這個認證流程中,涉及到兩個重要端點,一個是授權端點AuthorizationEndpoint,另一個是令牌端點TokenEndpoint。下面將通過原始碼分析這兩個端點的內部執行流程。

AuthorizationEndpoint(授權端點)

首先讓我們來看下訪問授權端點AuthorizationEndpoint的執行流程:

OAuth_AutohrizationServerAuthArchitecture

  1. UserAgent會訪問授權伺服器的AuthorizationEndpoint(授權端點)的URI:/oauth/authorize,呼叫的是authorize方法,主要用於判斷使用者是否已經授權,如果授權頒發新的authorization_code,否則跳轉到使用者授權頁面。
  2. authorize它會先呼叫ClientDetailsService獲取客戶端詳情資訊,並驗證請求引數。
  3. 隨後authorize方法再將請求引數傳遞給UserApprovalHandler用來檢測客戶端是否已經註冊了scope授權。
  4. 當未註冊授權時,即approvedfalse,將會向資源擁有者顯示請求授權的介面/oauth/confirm_access
  5. 同4一致。
  6. 資源擁有者確認授權後會再次訪問授權伺服器的授權端點的URI:/oauth/authorize,此次請求引數會增加一個user_oauth_approval,因此會呼叫另一個對映方法approveOrDeny
  7. approveOrDeny會呼叫userApprovalHandler.updateAfterApproval根據使用者是否授權,來決定是否更新authorizationRequest物件中的approved屬性。
  8. userApprovalHandler的預設實現類是ApprovalStoreUserApprovalHandler,其內部是通過ApprovalStoreaddApprovals來註冊授權資訊的。

當沒有攜帶請求引數user_oauth_approval時,會訪問authorize方法,執行流程和上面1-5步對應,如果使用者已經授權則頒發新的authorization_code,否則跳轉到使用者授權頁面:

@RequestMapping(value = "/oauth/authorize")
public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
    SessionStatus sessionStatus, Principal principal) {
  // 根據請求引數封裝 認證請求物件 ----> AuthorizationRequest
  // Pull out the authorization request first, using the OAuth2RequestFactory. 
  // All further logic should query off of the authorization request instead of referring back to the parameters map. 
  // The contents of the parameters map will be stored without change in the AuthorizationRequest object once it is created.
  AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);
  // 獲取請求引數中的response_type型別,並進行條件檢驗:response_type只支援token和code,即令牌和授權碼
  Set<String> responseTypes = authorizationRequest.getResponseTypes();
  if (!responseTypes.contains("token") && !responseTypes.contains("code")) {
    throw new UnsupportedResponseTypeException("Unsupported response types: " + responseTypes);
  }
  // 請求引數必須攜帶客戶端ID
  if (authorizationRequest.getClientId() == null) {
    throw new InvalidClientException("A client id must be provided");
  }

  try {
    // 在使用Spring Security OAuth2授權完成之前,必須先完成Spring Security對使用者進行的身份驗證
    if (!(principal instanceof Authentication) || !((Authentication) principal).isAuthenticated()) {
      throw new InsufficientAuthenticationException(
          "User must be authenticated with Spring Security before authorization can be completed.");
    }
    // 獲取客戶端詳情
    ClientDetails client = getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId());
    // 獲得重定向URL,它可以來自請求引數,也可以來自客戶端詳情,總之你需要將它儲存在授權請求中
    // The resolved redirect URI is either the redirect_uri from the parameters or the one from clientDetails.
    // Either way we need to store it on the AuthorizationRequest.
    String redirectUriParameter = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI);
    String resolvedRedirect = redirectResolver.resolveRedirect(redirectUriParameter, client);
    if (!StringUtils.hasText(resolvedRedirect)) {
      throw new RedirectMismatchException(
          "A redirectUri must be either supplied or preconfigured in the ClientDetails");
    }
    authorizationRequest.setRedirectUri(resolvedRedirect);
    // 根據客戶端詳情來校驗請求引數中的scope
    // We intentionally only validate the parameters requested by the client (ignoring any data that may have been added to the request by the manager).
    oauth2RequestValidator.validateScope(authorizationRequest, client);

    // 此處檢測請求的使用者是否已經被授權,或者有配置預設授權的許可權;若已經有accessToke存在或者被配置預設授權的許可權則返回含有授權的物件
    // 用到userApprovalHandler ----> ApprovalStoreUserApprovalHandler
    // Some systems may allow for approval decisions to be remembered or approved by default. 
    // Check for such logic here, and set the approved flag on the authorization request accordingly.
    authorizationRequest = userApprovalHandler.checkForPreApproval(authorizationRequest,
        (Authentication) principal);
    // TODO: is this call necessary?
    // 如果authorizationRequest.approved為true,則將跳過Approval頁面。
    boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
    authorizationRequest.setApproved(approved);
    // 已授權 直接返回對應的檢視,返回的檢視中包含新生成的authorization_code(固定長度的隨機字串)值
    // Validation is all done, so we can check for auto approval...
    if (authorizationRequest.isApproved()) {
      if (responseTypes.contains("token")) {
        return getImplicitGrantResponse(authorizationRequest);
      }
      if (responseTypes.contains("code")) {
        return new ModelAndView(getAuthorizationCodeResponse(authorizationRequest,
            (Authentication) principal));
      }
    }

    // Place auth request into the model so that it is stored in the sessionfor approveOrDeny to use.
    // That way we make sure that auth request comes from the session,
    // so any auth request parameters passed to approveOrDeny will be ignored and retrieved from the session.
    model.put("authorizationRequest", authorizationRequest);
    // 未授權 跳轉到授權介面,讓使用者選擇是否授權
    return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal);

  }
  catch (RuntimeException e) {
    sessionStatus.setComplete();
    throw e;
  }
}
複製程式碼

使用者通過授權頁面確認是否授權,並攜帶請求引數user_oauth_approval訪問授權端點,會執行approveOrDeny方法,執行流程對應上面6-7步:

@RequestMapping(value = "/oauth/authorize", method = RequestMethod.POST, params = OAuth2Utils.USER_OAUTH_APPROVAL)
public View approveOrDeny(@RequestParam Map<String, String> approvalParameters, Map<String, ?> model,
    SessionStatus sessionStatus, Principal principal) {
  // 在使用Spring Security OAuth2授權完成之前,必須先完成Spring Security對使用者進行的身份驗證
  if (!(principal instanceof Authentication)) {
    sessionStatus.setComplete();
    throw new InsufficientAuthenticationException(
        "User must be authenticated with Spring Security before authorizing an access token.");
  }
  // 獲取請求引數
  AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
  if (authorizationRequest == null) {
    sessionStatus.setComplete();
    throw new InvalidRequestException("Cannot approve uninitialized authorization request.");
  }

  try {
    // 獲取請求引數中的response_type型別
    Set<String> responseTypes = authorizationRequest.getResponseTypes();
    // 設定Approval的引數
    authorizationRequest.setApprovalParameters(approvalParameters);
    // 根據使用者是否授權,來決定是否更新authorizationRequest物件中的approved屬性。
    authorizationRequest = userApprovalHandler.updateAfterApproval(authorizationRequest,
        (Authentication) principal);
    boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
    authorizationRequest.setApproved(approved);
    // 需要攜帶重定向URI
    if (authorizationRequest.getRedirectUri() == null) {
      sessionStatus.setComplete();
      throw new InvalidRequestException("Cannot approve request when no redirect URI is provided.");
    }
    // 使用者拒絕授權,響應錯誤資訊到客戶端的重定向URL上
    if (!authorizationRequest.isApproved()) {
      return new RedirectView(getUnsuccessfulRedirect(authorizationRequest,
          new UserDeniedAuthorizationException("User denied access"), responseTypes.contains("token")),
          false, true, false);
    }
    // 簡化模式,直接頒發訪問令牌
    if (responseTypes.contains("token")) {
      return getImplicitGrantResponse(authorizationRequest).getView();
    }
    // 授權碼模式,生成授權碼儲存並返回給客戶端
    return getAuthorizationCodeResponse(authorizationRequest, (Authentication) principal);
  }
  finally {
    sessionStatus.setComplete();
  }
}
複製程式碼

TokenEndpoint(令牌端點)

接下來我們看下令牌端點TokenEndpoint的執行流程:

OAuth_AutohrizationServerTokenArchitecture

  1. userAgent通過訪問授權伺服器令牌端點TokenEndpoint的URI:/oauth/token,呼叫的是postAccessToken方法,主要用於為客戶端生成Token
  2. postAccessToken首先會呼叫ClientDetailsService獲取客戶端詳情資訊並驗證請求引數。
  3. 呼叫對應的授權模式實現類生成Token
  4. 對應的授權模式都是實現了AbstractTokenGranter抽象類,它的成員AuthorizationServerTokenServices可以用來建立、重新整理、獲取token
  5. AuthorizationServerTokenServices預設實現類只有DefaultTokenServices,通過它的createAccessToken方法可以看到token是如何建立的。
  6. 真正操作token的類是TokenStore,程式根據TokenStore介面的不同實現來生產和儲存token

下面列出TokenEndpoint的URI:/oauth/token的原始碼分析:

@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
  // 在使用Spring Security OAuth2授權完成之前,必須先完成Spring Security對使用者進行的身份驗證
  if (!(principal instanceof Authentication)) {
    throw new InsufficientAuthenticationException(
        "There is no client authentication. Try adding an appropriate authentication filter.");
  }
  // 通過客戶端Id獲取客戶端詳情
  String clientId = getClientId(principal);
  ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
  // 根據請求引數封裝 認證請求物件 ----> AuthorizationRequest
  TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);

  if (clientId != null && !clientId.equals("")) {
    // Only validate the client details if a client authenticated during this
    // request.
    if (!clientId.equals(tokenRequest.getClientId())) {
      // double check to make sure that the client ID in the token request is the same as that in the
      // authenticated client
      throw new InvalidClientException("Given client ID does not match authenticated client");
    }
  }
  if (authenticatedClient != null) {
    // 根據客戶端詳情來校驗請求引數中的scope,防止客戶端越權獲取更多許可權
    oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
  }
  // 沒有指定授權模式
  if (!StringUtils.hasText(tokenRequest.getGrantType())) {
    throw new InvalidRequestException("Missing grant type");
  }
  // 訪問此端點不應該是簡化模式
  if (tokenRequest.getGrantType().equals("implicit")) {
    throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
  }
  // 如果grant_type=authoraztion_code,則清空scope
  if (isAuthCodeRequest(parameters)) {
    // The scope was requested or determined during the authorization step
    if (!tokenRequest.getScope().isEmpty()) {
      logger.debug("Clearing scope of incoming token request");
      tokenRequest.setScope(Collections.<String> emptySet());
    }
  }
  // 如果grant_type=refresh_token,設定重新整理令牌的scope
  if (isRefreshTokenRequest(parameters)) {
    // A refresh token has its own default scopes, so we should ignore any added by the factory here.
    tokenRequest.setScope(OAuth2Utils.parseParameterList(parameters.get(OAuth2Utils.SCOPE)));
  }
  // 為客戶端生成token
  OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
  if (token == null) {
    throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
  }
  return getResponse(token);
}
複製程式碼

令牌端點最關鍵的就是如何生產token,不同的授權模式都會基於AbstractTokenGranter介面做不同實現,AbstractTokenGranter會委託AuthorizationServerTokenServices來建立、重新整理、獲取tokenAuthorizationServerTokenServices的預設實現只有DefaultTokenServices,簡單抽取它的createAccessToken方法原始碼即可看到:

// 生成accessToken和RefreshToken
@Transactional
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
  // 首先嚐試獲取當前存在的Token
  OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
  OAuth2RefreshToken refreshToken = null;
  // 如果現有的訪問令牌accessToken不為空且沒有失效,則儲存現有訪問令牌, 如果失效則重新儲存新的訪問令牌
  if (existingAccessToken != null) {
    if (existingAccessToken.isExpired()) {
      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);
    }
    else {
      // Re-store the access token in case the authentication has changed
      tokenStore.storeAccessToken(existingAccessToken, authentication);
      return existingAccessToken;
    }
  }
  // 如果沒有重新整理令牌則建立重新整理令牌,如果重新整理令牌過期,重新建立重新整理令牌。
  // Only create a new refresh token if there wasn't an existing one associated with an expired access token.
  // Clients might be holding existing refresh tokens, so we re-use it in the case that the old access token expired.
  if (refreshToken == null) {
    refreshToken = createRefreshToken(authentication);
  }
  // But the refresh token itself might need to be re-issued if it has expired.
  else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
    ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
    if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
      refreshToken = createRefreshToken(authentication);
    }
  }
  // 生成新的訪問令牌並儲存,儲存重新整理令牌
  OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
  tokenStore.storeAccessToken(accessToken, authentication);
  // In case it was modified
  refreshToken = accessToken.getRefreshToken();
  if (refreshToken != null) {
    tokenStore.storeRefreshToken(refreshToken, authentication);
  }
  return accessToken;
}
複製程式碼

Resource Server(資源伺服器)架構

資源伺服器主要用於處理客戶端對受保護資源的訪問請求並返回相應。資源伺服器會驗證客戶端的訪問令牌是否有效,並獲取與訪問令牌關聯的認證資訊。獲取認證資訊後,驗證訪問令牌是否在允許的scope內,驗證完成後的處理行為可以類似於普通應用程式來實現。下面是資源伺服器的執行流程:

OAuth_ResourceServerArchitecture

  • (1) 客戶端開始訪問資源伺服器時,會先經過OAuth2AuthenticationProcessingFilter,這個攔截器的作用是從請求中提取訪問令牌,然後從令牌中提取認證資訊Authentication並將其存放到上下文中。
  • (2) OAuth2AuthenticationProcessingFilter攔截器中會呼叫AuthenticationManager的authenticate方法提取認證資訊。
  • (2·) OAuth2AuthenticationProcessingFilter攔截器如果發生認證錯誤時,將委託AuthenticationEntryPoint做出錯誤響應,預設實現類是OAuth2AuthenticationEntryPoint
  • (3)OAuth2AuthenticationProcessingFilter執行完成後進入下一個安全過濾器ExceptionTranslationFilter
  • (3·) ExceptionTranslationFilter過濾器用來處理在系統認證授權過程中丟擲的異常,攔截器如果發生異常,將委託AccessDeniedHandler做出錯誤響應,預設實現類是OAuth2AccessDeniedHandler
  • (4) 當請求的認證/授權驗證成功後,返回客戶得請求對應的資源。

資源伺服器我們要關心的是它如何驗證客戶端的訪問令牌是否有效,所以我們從一開始的OAuth2AuthenticationProcessingFilter原始碼入手,這個攔截器的作用是從請求中提取訪問令牌,然後從令牌中提取認證資訊Authentication並將其存放到上下文中:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    boolean debug = logger.isDebugEnabled();
    HttpServletRequest request = (HttpServletRequest)req;
    HttpServletResponse response = (HttpServletResponse)res;

    try {
        // 從請求中提取token,然後再提取token中的認證資訊Authorization
        Authentication authentication = this.tokenExtractor.extract(request);
        if (authentication == null) {
            if (this.stateless && this.isAuthenticated()) {
                if (debug) {
                    logger.debug("Clearing security context.");
                }

                SecurityContextHolder.clearContext();
            }

            if (debug) {
                logger.debug("No token in request, will continue chain.");
            }
        } else {
            request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal());
            if (authentication instanceof AbstractAuthenticationToken) {
                AbstractAuthenticationToken needsDetails = (AbstractAuthenticationToken)authentication;
                needsDetails.setDetails(this.authenticationDetailsSource.buildDetails(request));
            }
            //獲取token攜帶的認證資訊Authentication並進行驗證,然後存到spring security的上下文,以供後續使用 
            Authentication authResult = this.authenticationManager.authenticate(authentication);
            if (debug) {
                logger.debug("Authentication success: " + authResult);
            }
            this.eventPublisher.publishAuthenticationSuccess(authResult);
            SecurityContextHolder.getContext().setAuthentication(authResult);
        }
    } catch (OAuth2Exception var9) {
        SecurityContextHolder.clearContext();
        if (debug) {
            logger.debug("Authentication request failed: " + var9);
        }
        this.eventPublisher.publishAuthenticationFailure(new BadCredentialsException(var9.getMessage(), var9), new PreAuthenticatedAuthenticationToken("access-token", "N/A"));
        this.authenticationEntryPoint.commence(request, response, new InsufficientAuthenticationException(var9.getMessage(), var9));
        return;
    }

    chain.doFilter(request, response);
}
複製程式碼

上面程式碼提到Oauth2AuthenticationManager會獲取token攜帶的認證資訊進行認證,通過原始碼可以瞭解到它主要做了3步工作:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {

  if (authentication == null) {
    throw new InvalidTokenException("Invalid token (token not found)");
  }
  String token = (String) authentication.getPrincipal();
  // 1.通過token獲取OAuuth2Authentication物件
  OAuth2Authentication auth = tokenServices.loadAuthentication(token);
  if (auth == null) {
    throw new InvalidTokenException("Invalid token: " + token);
  }
  // 2.驗證資源服務的ID是否正確
  Collection<String> resourceIds = auth.getOAuth2Request().getResourceIds();
  if (resourceId != null && resourceIds != null && !resourceIds.isEmpty() && !resourceIds.contains(resourceId)) {
    throw new OAuth2AccessDeniedException("Invalid token does not contain resource id (" + resourceId + ")");
  }
  // 3.驗證客戶端的訪問範圍(scope)
  checkClientDetails(auth);

  if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
    OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
    // Guard against a cached copy of the same details
    if (!details.equals(auth.getDetails())) {
      // Preserve the authentication details from the one loaded by token services
      details.setDecodedDetails(auth.getDetails());
    }
  }
  auth.setDetails(authentication.getDetails());
  auth.setAuthenticated(true);
  return auth;

}
複製程式碼

驗證通過後,經過ExceptionTranslationFilter過濾器,即可訪問資源。


Client(客戶端)架構

Spring security OAuth2客戶端控制著OAuth 2.0保護的其它伺服器的資源的訪問許可權。配置包括建立相關受保護資源與有許可權訪問資源的使用者之間的連線。客戶端也需要實現儲存使用者的授權程式碼和訪問令牌的功能。

OAuth_ClientArchitecture.png

客戶端程式碼結構不是特別複雜,這裡接觸架構圖的描述,有興趣可以自己按著下面介紹的流程研究原始碼:

  1. 首先UserAgent呼叫客戶端的Controller,在這之前會經過OAuth2ClientContextFilter過濾器,它主要用來捕獲第5步可能發生的UserRedirectRequiredException,以便重定向到授權伺服器重新授權。
  2. 客戶端service層相關程式碼需要注入RestOperations->OAuth2RestOperations介面的實現類OAuth2RestTemplate。它主要提供訪問授權伺服器或資源伺服器的RestAPI
  3. OAuth2RestTemplate的成員OAuth2ClientContext介面實現類為DefaultOAuth2ClientContext。它會校驗訪問令牌是否有效,有效則執行第6步訪問資源伺服器。
  4. 如果訪問令牌不存在或者超過了有效期,則呼叫AccessTokenProvider來獲取訪問令牌。
  5. AccessTokenProvider根據定義的資源詳情資訊和授權型別獲取訪問令牌,如果獲取不到,丟擲UserRedirectRequiredException
  6. 指定3或5中獲取的訪問令牌來訪問資源伺服器。如果在訪問過程中發生令牌過期異常,則初始化所儲存的訪問令牌,然後走第4步。

文中架構圖和部分內容參考自TERASOLUNA伺服器框架(5.x)開發指南,轉載請註明來源。

相關文章