Spring Security Web 和 OAuth2

前言

Spring Security 是一個多模塊的項目,以前梳理了一下 Spring Security 認證流程,如今才發現,梳理的那部份內容更多的只是 Spring Security Core 這個核心模塊中的內容。html

平常使用時,還會更多的涉及 Spring Security Web 和 Spring Security OAuth2 中的東西,這篇博客的主要內容即是梳理一下這三者之間的關係,瞭解一下各自發揮的做用。java

Spring Security Core

Spring Security Core 在整個 Spring Security 框架中扮演着重要的角色,提供了有關於認證和權限控制相關的抽象。git

然而,在使用的過程當中,咱們接觸的更多的多是和認證相關的抽象,好比:github

  • 經過 AuthenticationManager 提供了進行用戶認證方法的抽象,容許經過 ProviderManagerAuthenticationProvider 來組裝和實現本身的認證方法
  • 經過 UserDetailsUserDetailsService 提供了用戶詳細信息和獲取用戶詳細信息方式的抽象
  • 經過 Authentication 提供了用戶認證信息和認證結果的抽象
  • 經過 SecurityContextSecurityContextHolder 提供了保存認證結果的方式
  • ……

這些東西其實就是將傳統的認證流程中的關鍵組成單獨抽象了出來,結合傳統的認證流程能夠很容易的理解這些組件之間的關係,也能夠看這張來自 Spring Security(一) —— Architecture Overview | 芋道源碼 —— 純源碼解析博客 的一張圖片:web

而權限控制部分的抽象,主要就是 AccessDecisionManagerAccessDecisionVoter 了,這兩個東西我目前尚未手動操做過,只能說,Spring Security Web 提供的服務太貼心, 權限控制部分的實現並不須要我操太多心。spring

關於 Spring Security Core 模塊更多的內容能夠參考:安全

Spring Security Web

若是說 Spring Security Core 只是提供了認證和權限控制相關的抽象的話,Spring Security Web 便爲咱們提供了這些抽象的具體實現與應用。服務器

Spring Security Web 經過 過濾器鏈 來實現了和 Web 安全相關的一系列功能,而用戶的認證和權限控制只是其中的一部分,在這部分的實現中,過濾器充當 Spring Security Core 調用者的身份,通常流程爲:session

  • 過濾器提取請求中的認證信息封裝爲 Authentication 傳遞給 AuthenticationManager 進行認證,而後將認證結果放到 SecurityContext 中供後續過濾器使用
  • 過濾器在請求進入端點前根據認證結果利用 AccessDecisionManager 判斷是否具有相應的權限

在這裏,Spring Security Core 只是 Spring Security Web 利用的一部分功能,更爲重要的是,整個過濾器鏈。架構

過濾器鏈的構建

以前原本只是想了解一下過濾器鏈的調用過程,可是看着看着,就跑到源碼去了。反應過來的時候才發現,已經搞了這麼多了停下來的話有點吃虧,就乾脆把過濾器鏈的構建邏輯理了一下。

在梳理完構建器鏈的構建和調用邏輯後感受,過濾器鏈的構建邏輯貌似沒有好多用,還不如直接看過濾器鏈的調用邏輯……

這部分邏輯的梳理過程有些複雜,反正我調試的時候斷點就在 build() 方法附近反覆橫跳,這裏爲了簡單,就直接放結果了1

時序圖畫的不是很標準,大體意思一下就能夠了哈( ̄▽ ̄),解析以下:

  1. Spring Security Web 中的過濾器鏈的構建主要是由 WebSecurityHttpSecurity 完成的
  2. WebSecurity 根據上下文中的 WebSecurityConfigurer 構建出 HttpSecurity 對象,而後經過 HttpSecurity 構建出 SecurityFilterChain 後,將 SecurityFilterChain 放到 FilterChainProxy 中。 其中,WebSecurityConfigurer 的經常使用實現爲 WebMvcConfigurerAdapter, 而 SecurityFilterChain 的經常使用實現爲 DefaultSecurityFilterChain
  3. HttpSecurity 根據直接添加的 Filter 和經過 AbstractHttpConfigurer 實現類構建的 Filter 生成過濾器鏈

這部分邏輯中,關鍵的對象分別是 WebSecurity 和它依賴的配置類 WebSecurityConfigurer, HttpSecurity 和它依賴的配置類 AbstractHttpConfigurer.

在實際的使用中,咱們一般會繼承 WebMvcConfigurerAdapter 這個 WebSecurityConfigurer 的實現類,而後在重寫它的 configure(HttpSecurity) 方法:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            .authorizeRequests()
              .antMatchers("/oauth/**")
              .authenticated()
              .and()
            .requestMatchers()
              .antMatchers("/oauth/**","/login/**","/logout/**")
              .and()
            .csrf()
              .disable()
            .formLogin()
              .permitAll();
        // @formatter:on
    }
}
複製代碼

在上面這個類中,咱們繼承了 WebSecurityConfigurerAdapter 這個類,當咱們將自定義的類放到 Spring 上下文中後,就能夠被 WebSecurity 拿到用於構建 HttpSecurity, 而重寫的 configure(HttpSecurity) 則會在 HttpSecurity 構建過濾器以前調用,完成過濾器鏈的配置。

其中,諸如 csrf() 之類的方法都會返回一個 AbstractHttpConfigurer 實現,容許咱們對特定的過濾器進行配置。

到了最後,HttpSecurity 就能夠根據相應的配置完成過濾器鏈的構建,而後再由 WebSecurity 將它們放到 FilterChainProxy 實例中返回。

過濾器鏈的調用

過濾器鏈的調用的話,主要涉及兩個對象:FilterChainProxy 和 DefaultSecurityFilterChain,關鍵其實仍是在 FilterChainProxy 上。

然而,這兩個對象的源碼都挺簡單的,這裏就不貼了,有興趣的能夠去看一下,這裏簡單說一下結果:

  • FilterChainProxy 會做爲 Servlet 容器過濾器鏈中的一個過濾器,當接收到請求後在持有的過濾器鏈中判斷是否存在匹配的過濾器鏈
  • 存在匹配的過濾器鏈時,會直接使用第一個匹配項對請求進行處理
  • 不存在匹配的過濾器鏈或者匹配的過濾器鏈走完後,就會回到 Servlet 容器過濾器鏈繼續執行

這裏的關鍵點其實就是,存在多條過濾器鏈,每條過濾器鏈匹配必定的請求。以前看文檔的時候不仔細,沒有意識到這一點,饒了很多彎路 QAQ

附圖:

過濾器鏈的使用

Spring Security Web 過濾器的使用主要就是自定義過濾器鏈,默認的過濾器鏈會添加一些 Spring Security Web 自帶的一些過濾器,使用時,須要考慮是否去掉默認的一些過濾器器(或者不使用默認配置), 並將自定義的過濾器添加到過濾器鏈中的一個合適的位置上。

這裏會簡要介紹部份內置過濾器的做用和過濾器的順序,首先是內置的幾個過濾器:

  • 過濾器 SecurityContextPersistenceFilter 能夠從 Session 中取出已認證用戶的信息
  • 過濾器 AnonymousAuthenticationFilter 在發現 SecurityContextHolder 中尚未認證信息時,會生成一個匿名認證信息放到 SecurityContextHolder
  • 過濾器 ExceptionTranslationFilter 能夠處理 FilterSecurityInterceptor 中拋出的異常,進行重定向、輸出錯誤信息等
  • 過濾器 FilterSecurityInterceptor 對認證信息的權限進行判斷,權限不足時拋出異常

在自定義過濾器時(一般是認證過濾器),咱們須要考慮自定義過濾器的位置,好比,咱們不該該把自定義的認證過濾器放在 AnonymousAuthenticationFilter 的後面,官方文檔對過濾器的順序給出瞭解釋: 在去除一些過濾器後,大體順序就爲:

其中,AuthenticationProcessingFilter 是指認證過濾器實現,好比經常使用的 UsernamePasswordAuthenticationFilter 這個過濾器。

完整的順序能夠參考:

在這個順序中,因爲 SecurityContextPersistenceFilter 可能從 Session 中取出已認證用戶的信息,所以,自定義過濾器時應該考慮 SecurityContextHolder 是否是已經存在用戶認證信息。 或者在登陸/註冊相關 URL 的過濾器鏈中設置認證用戶帳戶密碼的過濾器,在其餘過濾器鏈中設置認證 token 的過濾器。

Spring Security OAuth2

Spring Security OAuth2 創建在 Spring Security Core 和 Spring Security Web 的基礎上,提供了對 OAuth2 受權框架的支持。

其中,最爲複雜的部分是在 受權服務器 上,相對的,資源服務器基本上就是重用 Spring Security Web 提供的過濾器鏈,經過過濾器 OAuth2AuthenticationProcessingFilter 和請求攜帶的 Token 獲取認證信息, 所以,這裏的重心會放在受權服務器上。

受權服務器

對於傳統的認證方式來講,簡單認證用戶的信息基本上就足夠了,可是對於 OAuth2 來講是不夠的,對於 OAuth2 受權服務器來講,除了須要完成用戶的認證之外,還需完成客戶端的認證,還須要效驗客戶端請求的 Scope, 所以,單憑過濾器鏈是不足以完成二者的認證的,由於 SecurityContextHolder 只能持有一個認證結果。

因而,Spring Security OAuth2 採用的認證策略即是:在過濾器鏈中完成客戶端或用戶的認證,而後再在端點的內部邏輯中完成剩餘信息的效驗。而這個認證策略,在不一樣模式中也是不同的。

這裏主要會對 受權碼模式密碼模式 中的認證策略進行介紹,由於這兩個模式中使用到的端點 AuthorizationEndpointTokenEndpoint 已經涵蓋了兩條主要的過濾器鏈。

受權碼模式

首先是受權碼模式,對於受權碼模式來講,請求流程一般是先到 /oauth/authorize 獲取受權碼,而後再到 /oauth/token 獲取 Token,對於 /oauth/authorize 這個端點的過濾器鏈來講, 認證的是用戶的信息,認證經過後進入端點內部,會對客戶端請求 Scope 和用戶的 Approval 進行效驗,效驗經過會生成受權碼返回給客戶端。

其實這裏也就能夠明白爲何 /oauth/authorize 這個端點須要對用戶進行認證了,由於,這裏須要獲取的是 用戶 的受權。

而後客戶端拿着受權碼去 /oauth/token 這個端點獲取 Token 時,該端點的過濾器鏈會對客戶端進行認證,認證經過後進入端點內部,這時端點內部會對客戶端請求的 Scope 進行效驗, 效驗經過後就會經過 TokenGranter 生成 Token 返回給客戶端。

也就是說,對於受權碼模式來講:

  • 端點 /oauth/authorize 完成用戶的認證、客戶端請求的 Scope 的效驗、用戶的受權檢查
  • 端點 /oauth/token 完成客戶端的認證,客戶端請求的 Scope 的效驗、客戶端受權碼的檢查

這其實就能夠看作時對受權碼模式的代碼解釋,由於,在受權碼模式中,去獲取 Token 的每每不是用戶操做的客戶端,所以,須要認證客戶端是不是受信任的。

相關邏輯對應的源碼,去掉了一部分效驗代碼:

@RequestMapping(value = "/oauth/authorize")
public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters, SessionStatus sessionStatus, Principal principal) {
  AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);

  try {
    // 未經過認證的請求會拋異常
    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());

    // 效驗 Scope
    oauth2RequestValidator.validateScope(authorizationRequest, client);

    // 效驗用戶的受權
    authorizationRequest = userApprovalHandler.checkForPreApproval(authorizationRequest, (Authentication) principal);
    boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
    authorizationRequest.setApproved(approved);

    // 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));
      }
    }

    return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal);
  }
  catch (RuntimeException e) {
    sessionStatus.setComplete();
    throw e;
  }
}

@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {

  // 能夠看到,經過效驗的是客戶端
  String clientId = getClientId(principal);
  ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);

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

  // 效驗請求的 Scope
  if (authenticatedClient != null) {
    oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
  }

  if (isAuthCodeRequest(parameters)) {
    // The scope was requested or determined during the authorization step
    if (!tokenRequest.getScope().isEmpty()) {
      tokenRequest.setScope(Collections.<String> emptySet());
    }
  }

  // 調用 TokenGranter 進行受權
  OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
  if (token == null) {
    throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
  }
  return getResponse(token);
}
複製代碼

受權碼模式流程圖:

密碼模式

密碼模式,或者說簡化模式,只有一個端點即 /oauth/token 這個端點,也就是說,這個端點要同時完成用戶和客戶端的認證。

可是,這個端點不可能同時擁有兩個過濾器鏈,而爲了支持受權碼模式,這個端點的過濾器鏈的職責已經肯定了,就是完成客戶端的認證。所以,用戶的認證就只能在端點內部邏輯完成。

TokenEndpoint 發現受權模式爲 密碼模式 時,會將 ResourceOwnerPasswordTokenGranter 放入 TokenGranter, 而 ResourceOwnerPasswordTokenGranter 進行受權時會調用 AuthenticationManager 來完成對用戶的認證,認證成功纔會經過 TokenService 生成 Token 返回。

// AuthorizationServerEndpointsConfigurer.getDefaultTokenGranters
private List<TokenGranter> getDefaultTokenGranters() {
  List<TokenGranter> tokenGranters = new ArrayList<TokenGranter>();
  tokenGranters.add(new AuthorizationCodeTokenGranter(tokenServices, authorizationCodeServices, clientDetails, requestFactory));
  tokenGranters.add(new RefreshTokenGranter(tokenServices, clientDetails, requestFactory));
  tokenGranters.add(new ImplicitTokenGranter(tokenServices, clientDetails, requestFactory));
  tokenGranters.add(new ClientCredentialsTokenGranter(tokenServices, clientDetails, requestFactory));
  if (authenticationManager != null) {
    tokenGranters.add(new ResourceOwnerPasswordTokenGranter(authenticationManager, tokenServices, clientDetails, requestFactory));
  }
  return tokenGranters;
}
複製代碼

密碼模式流程圖:

客戶端認證

經過對 受權碼模式密碼模式 的瞭解咱們知道了客戶端的認證是在過濾器鏈中完成的,這個認證能夠經過 BasicAuthenticationFilter 完成,但更通用的大概是 ClientCredentialsTokenEndpointFilter 這個過濾器。

其內部的認證流程實際上是很簡單的,最爲重要的一點是,它用的仍是 Spring Security Core 那一套!

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {

  String clientId = request.getParameter("client_id");
  String clientSecret = request.getParameter("client_secret");

  // If the request is already authenticated we can assume that this filter is not needed
  Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
  if (authentication != null && authentication.isAuthenticated()) {
    return authentication;
  }

  UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(clientId, clientSecret);

  // 經過 AuthenticationManager 完成認證
  return this.getAuthenticationManager().authenticate(authRequest);
}
複製代碼

咱們知道,Spring Security OAuth2 提供了 ClientDetails 和 ClientDetailsService 這兩種抽象,它們和 UserDetails 和 UserDetailsService 是不兼容的,這時,能夠選擇本身實現一個 AuthenticationProvider 使用 ClientDetails 和 ClientDetailsService, 但也能夠將 ClientDetails 和 ClientDetailsService 轉換爲 UserDetails 和 UserDetailsService,Spring Security OAuth2 經過 ClientDetailsUserDetailsService 來完成這一轉換:

public class ClientDetailsUserDetailsService implements UserDetailsService {
  private final ClientDetailsService clientDetailsService;

  public ClientDetailsUserDetailsService(ClientDetailsService clientDetailsService) {
    this.clientDetailsService = clientDetailsService;
  }

  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    ClientDetails clientDetails;
    try {
      clientDetails = clientDetailsService.loadClientByClientId(username);
    } catch (NoSuchClientException e) {
      throw new UsernameNotFoundException(e.getMessage(), e);
    }
    String clientSecret = clientDetails.getClientSecret();
    if (clientSecret== null || clientSecret.trim().length()==0) {
      clientSecret = emptyPassword;
    }
    return new User(username, clientSecret, clientDetails.getAuthorities());
  }
}
複製代碼

TokenGranter

Spring Security OAuth2 中受權碼的生成時經過 TokenGranter 來完成的,進行受權碼的生成時,會遍歷擁有的各個 TokenGranter 實現,直到成功生成 Token 或者全部 TokenGranter 實現都不能生成 Token。

生成 Token 也是一個能夠抽象出來的環節,所以,Spring Security OAuth2 經過 TokenService 和 TokenStore 來生成、獲取和保存 Token。

public abstract class AbstractTokenGranter implements TokenGranter {
  private final AuthorizationServerTokenServices tokenServices;

  private final ClientDetailsService clientDetailsService;

  private final OAuth2RequestFactory requestFactory;

  private final String grantType;

  protected AbstractTokenGranter(AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, String grantType) {
    this.clientDetailsService = clientDetailsService;
    this.grantType = grantType;
    this.tokenServices = tokenServices;
    this.requestFactory = requestFactory;
  }

  public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
    // 每一個 TokenGranter 對應一種受權類型
    if (!this.grantType.equals(grantType)) {
      return null;
    }

    String clientId = tokenRequest.getClientId();
    ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
    validateGrantType(grantType, client);

    // 獲取受權碼
    return getAccessToken(client, tokenRequest);
  }

  protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
    return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
  }
}

// 默認的 TokenServices 的部分代碼
public class DefaultTokenServices {
  @Transactional
  public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
    // 首先從 TokenStore 中獲取 Token
    OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
    OAuth2RefreshToken refreshToken = null;
    if (existingAccessToken != null) {
      if (existingAccessToken.isExpired()) {
        if (existingAccessToken.getRefreshToken() != null) {
          refreshToken = existingAccessToken.getRefreshToken();
          tokenStore.removeRefreshToken(refreshToken);
        }
        tokenStore.removeAccessToken(existingAccessToken);
      }
      else {
        // Re-store the access token in case the authentication has changed
        tokenStore.storeAccessToken(existingAccessToken, authentication);
        return existingAccessToken;
      }
    }

    if (refreshToken == null) {
      refreshToken = createRefreshToken(authentication);
    }

    OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
    // 保存 accessToken
    tokenStore.storeAccessToken(accessToken, authentication);
    refreshToken = accessToken.getRefreshToken();
    if (refreshToken != null) {
      tokenStore.storeRefreshToken(refreshToken, authentication);
    }
    return accessToken;
  }

  // 從 TokenStore 中獲取 Token
  public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) {
    return tokenStore.getAccessToken(authentication);
  }
}
複製代碼

簡單來講就是:

  1. 在過濾器鏈和端點內部邏輯中完成客戶端和用戶的認證與 Scope 的效驗
  2. 經過 TokenGranter 生成 Token,而 TokenGranter 經過 TokenService 建立 Token,TokenStore 能夠保存 Token

資源服務器

資源服務器相較於受權服務器來講就要簡單多了,和傳統的流程差很少,經過過濾器 OAuth2AuthenticationProcessingFilterOAuth2AuthenticationManager 驗證 Token 並獲取認證信息:

public class OAuth2AuthenticationProcessingFilter implements Filter, InitializingBean {
  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    final HttpServletRequest request = (HttpServletRequest) req;
    final HttpServletResponse response = (HttpServletResponse) res;

    // 從請求頭中提取 Token
    Authentication authentication = tokenExtractor.extract(request);

    Authentication authResult = authenticationManager.authenticate(authentication);

    SecurityContextHolder.getContext().setAuthentication(authResult);

    chain.doFilter(request, response);
  }
}

public class OAuth2AuthenticationManager implements AuthenticationManager, InitializingBean {
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    String token = (String) authentication.getPrincipal();

    // 經過 TokenService 獲取認證信息
    OAuth2Authentication auth = tokenServices.loadAuthentication(token);

    if (auth == null) {
      throw new InvalidTokenException("Invalid token: " + token);
    }

    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;
  }
}
複製代碼

Spring Security JWT

不少地方均可以看到 JWT 在 OAuth2 中的使用,Spring Security JWT 在 Spring Security OAuth2 中便扮演了 TokenService 和 TokenStore 的角色,用於生成和效驗 Token。

可是,我仍是很想吐槽一下 JWT 這個東西。當初剛看到的時候感受頗有趣,使用 JWT 能夠直接在 Token 中攜帶一些信息,同時服務端還不用存儲 Token 的信息。

然而,在實際的一些使用中,可能會碰見須要做廢還有效的 JWT Token 的需求,這對於 JWT 來講是沒法實現的。爲了實現這一需求,就只能在服務端存儲一些信息。

可是,既然都要在服務端存儲信息了,那幹嗎還用 JWT 呢?只要須要在服務端存儲信息,那麼,用不用 JWT 都沒多大區別了啊……

結語

Spring Security 真的是一個很複雜的框架,目前設計的還只是在 Servlet 程序中的應用,然鵝我目前忽然對 Spring WebFlux 產生了一點興趣, 不知道 Spring Security 在 Spring WebFlux 中是啥樣的……

另外,我想說的是,Spring Security 的官方教程真的很棒,將大致的架構都解釋清楚了,惋惜吃了英語的虧 TT

參考連接

Spring Security 總體相關的資料:

Spring Security Web 相關的資料:

Spring Security OAuth2 相關的資料:

Footnotes

1 對詳細過程有興趣的,能夠看個人筆記 Spring Security Web 過濾器鏈的構建

相關文章
相關標籤/搜索