Tomcat 容器的安全認證和鑑權

wskwbog發表於2019-05-20

大量的 Web 應用都有安全相關的需求,正因如此,Servlet 規範建議容器要有滿足這些需求的機制和基礎設施,所以容器要對以下安全特性予以支援:

  • 身份驗證:驗證授權使用者的使用者名稱和密碼
  • 資源訪問控制:限制某些資源只允許部分使用者訪問
  • 資料完整性:能夠證明資料在傳輸過程中未被第三方修改
  • 機密性或資料隱私:傳輸加密(SSL),確保資訊只能被信任使用者訪問

本文就以上問題,對 Tomcat 容器提供的認證和鑑權的設計與實現,以及內部單點登入的原理進行分析。首發於微信公眾號頓悟原始碼.

1. 授權

容器和 Web 應用採用的是基於角色的許可權訪問控制方式,其中容器需要實現認證和鑑權的功能,而 Web 應用則要實現授權的功能。

在 Servlet 規範中描述了兩種授權方式:宣告式安全和程式設計式安全。宣告式安全就是在部署描述符中宣告角色、資源訪問許可權和認證方式。以下程式碼片段摘自 Tomcat 自帶的 Manager 應用的 web.xml:

<security-constraint> <!-- 安全約束 -->
  <web-resource-collection> <!-- 限制訪問的資源集合 -->
    <web-resource-name>HTML Manager commands</web-resource-name>
    <url-pattern>/html/*</url-pattern>
  </web-resource-collection>
  <auth-constraint><!-- 授權可訪問此資源集合的角色 -->
     <role-name>manager-gui</role-name>
  </auth-constraint>
</security-constraint>

<login-config><!-- 配置驗證方法 -->
  <auth-method>BASIC</auth-method>
  <realm-name>Tomcat Manager Application</realm-name>
</login-config>

<security-role><!-- 定義一個安全形色 -->
  <description>
    The role that is required to access the HTML Manager pages
  </description>
  <role-name>manager-gui</role-name>
</security-role>

這些安全相關的配置,都會在應用部署時,初始化和設定到 StandardContext 物件中。更多詳細的內容可檢視規範對部署描述檔案的解釋,接下來看 Tomcat 怎麼設計和實現認證及鑑權。

2. 認證和鑑權的設計

Servlet 規範雖然描述了 Web 應用宣告安全約束的機制,但沒有定義容器與關聯使用者和角色資訊之間的介面。因此,Tomcat 定義了一個 Realm 介面,用於適配身份驗證的各種資訊源。整體設計的類圖如下:

Tomcat 認證和鑑權類圖

上圖中,包含了各個類的核心方法,關鍵類或介面的作用如下:

  • Realm - 譯為,域有泛指某種範圍的意思,在這個範圍記憶體儲著使用者名稱、密碼、角色和許可權,並且提供身份和許可權驗證的功能,典型的這個範圍可以是某個配置檔案或資料庫
  • CombinedRealm - 內部包含一個或多個 Realm,按配置順序執行身份驗證,任一 Realm 驗證成功,則表示成功驗證
  • LockOutRealm - 提供使用者鎖定機制,防止在一定時間段有過多身份驗證失敗的嘗試
  • Authenticator - 不同身份驗證方法的介面,主要有 BASIC、DIGEST、FORM、SSL 這幾種標準實現
  • Principal - 對認證主體的抽象,它包含使用者身份和許可權資訊
  • SingleSignOn - 用於支援容器內多應用的單點登入功能

2.1 初始化

Realm 是容器的一個可巢狀元件,可以巢狀在 Engine、Host 和 Context 中,並且子容器可以覆蓋父容器配置的 Realm。預設的 server.xml 在 Engine 中配置了一個 LockOutRealm 組合域,內部包含一個 UserDatabaseRealm,它從配置的全域性資源 conf/tomcat-users.xml 中提取使用者資訊。

web.xml 中宣告的安全約束會初始化成對應的 SecurityConstraint、SecurityCollection 和 LoginConfig 物件,並關聯到一個 StandardContext 物件。

在上圖可以看到,AuthenticatorBase 還實現了 Valve 介面,StandardContext 物件在配置的過程中,如果發現宣告瞭標準的驗證方法,那麼就會把它加入到自己的 Pipeline 中。

3. 一次請求認證和鑑權過程

Context 在 Tomcat 內部就代表著一個 Web 應用,假設配置使用 BASIC 驗證方法,那麼 Context 內部的 Pipeline 就有 BasicAuthenticator 和 StandardContextValve 兩個閥門,當請求進入 Context 管道時,就首先進行認證和鑑權,方法呼叫如下:

認證和鑑權序列圖

整個過程的核心程式碼就在 AuthenticatorBase 的 invoke 方法中:

public void invoke(Request request, Response response) throws IOException, ServletException {
  LoginConfig config = this.context.getLoginConfig();
  // 0. Session 物件中是否快取著一個已經進行身份驗證的 Principal
  if (cache) {
    Principal principal = request.getUserPrincipal();
    if (principal == null) {
      Session session = request.getSessionInternal(false);
      if (session != null) {
        principal = session.getPrincipal();
        if (principal != null) {
          request.setAuthType(session.getAuthType());
          request.setUserPrincipal(principal);
        }
      }
    }
  }
  // 對於基於表單登入,可能位於安全域之外的特殊情況進行處理
  String contextPath = this.context.getPath();
  String requestURI = request.getDecodedRequestURI();
  if (requestURI.startsWith(contextPath) && requestURI.endsWith(Constants.FORM_ACTION)) {
          return;
      }
  }
  // 獲取安全域物件,預設配置是 LockOutRealm
  Realm realm = this.context.getRealm();
  // 根據請求 URI 嘗試獲取配置的安全約束
  SecurityConstraint [] constraints = realm.findSecurityConstraints(request, this.context);
 
  if ((constraints == null) /* && (!Constants.FORM_METHOD.equals(config.getAuthMethod())) */ ) {
    // 為 null 表示訪問的資源沒有安全約束,直接訪問下一個閥門
    getNext().invoke(request, response);
    return;
  }
  // 確保受約束的資源不會被 Web 代理或瀏覽器快取,因為快取可能會造成安全漏洞
  if (disableProxyCaching && 
      !"POST".equalsIgnoreCase(request.getMethod())) {
      if (securePagesWithPragma) {
          response.setHeader("Pragma", "No-cache");
          response.setHeader("Cache-Control", "no-cache");
      } else {
          response.setHeader("Cache-Control", "private");
      }
      response.setHeader("Expires", DATE_ONE);
  }
  int i;
  // 1. 檢查使用者資料的傳輸安全約束
  if (!realm.hasUserDataPermission(request, response, constraints)) {
    // 驗證失敗
    // Authenticator已經設定了適當的HTTP狀態程式碼,因此我們不必做任何特殊的事情
    return;
  }
  // 2. 檢查是否包含授權約束,也就是角色驗證
  boolean authRequired = true;
  for(i=0; i < constraints.length && authRequired; i++) {
    if(!constraints[i].getAuthConstraint()) {
      authRequired = false;
    } else if(!constraints[i].getAllRoles()) {
      String [] roles = constraints[i].findAuthRoles();
      if(roles == null || roles.length == 0) {
        authRequired = false;
      }
    }
  }
  // 3. 驗證使用者名稱和密碼
  if(authRequired) {
    // authenticate 是一個抽象方法,由不同的驗證方法實現
    if (!authenticate(request, response, config)) {
      return;
    } 
  }
  // 4. 驗證使用者是否包含授權的角色
  if (!realm.hasResourcePermission(request, response,constraints,this.context)) {
    return;
  }
  // 5. 已滿足任何和所有指定的約束
  getNext().invoke(request, response);
}

另外,AuthenticatorBase 還有一個比較重要的 register() 方法,它會把認證後生成的 Principal 物件設定到當前 Session 中,如果配置了SingleSignOn 單點登入的閥門,同時把使用者身份、許可權資訊關聯到 SSO 中。

4. 單點登入

Tomcat 支援通過一次驗證就能訪問部署在同一個虛擬主機上的所有 Web 應用,可通過以下配置實現:

<Host name="localhost" ...>
  ...
  <Valve className="org.apache.catalina.authenticator.SingleSignOn"/>
  ...
</Host>

Tomcat 的單點登入是利用 Cookie 實現的:

  • 當任一 Web 應用身份驗證成功後,都會把使用者身份資訊快取到 SSO 中,並生成一個名為 JSESSIONIDSSO 的 Cookie
  • 當使用者再次訪問這個主機時,會通過 Cookie 拿出儲存的使用者 token,獲取使用者 Principal 並關聯到 Request 物件中

在單機環境下,沒有問題,在叢集環境下,Tomcat 支援 Session 的複製,那單點登入相關的資訊也會同步複製嗎?後續會繼續分析 Tomcat 叢集的原理和實現。

5. 小結

本文介紹的是 Tomcat 內部實現的登入認證和許可權,而應用程式通常都是通過 Filter 或者自定義的攔截器(如 Spring 的 Interceptor)實現登入,或者使用第三方安全框架,比如 Shiro,但是原理都差不多。

至此,除了叢集的實現,Tomcat 的核心原理已經分析完畢,接下來將會模擬實現一個簡單的 Tomcat,歡迎關注。

相關文章