如何在 Spring Boot 啟用 mutual TLS(mTLS)

什麼是相互 TLS (mutual TLS)?

mTLS,全名相互驗證傳輸層安全性(mutual Transport Layer Security),是一種網路驗證方法。透過檢查網路連線雙方是否具有正確的私密金鑰,mTLS 確保了所有連線端點的身分合法性。

此外,TLS 憑證中的資訊也可提供額外的驗證。 在 Zero Trust 安全框架中,mTLS 常用於驗證組織內的使用者、裝置和伺服器,可有效防止 API 的安全風險。

mTLS 如何運作?

在 TLS 中,Server Side 具有 TLS 憑證和公、私密金鑰對,而用戶端則沒有。典型的 TLS 運作方式如下:

  1. 用戶端連接到伺服器
  2. 伺服器提供其 TLS 憑證
  3. 用戶端驗證伺服器的憑證
  4. 用戶端和伺服器透過加密的 TLS 連線交換資訊TLS 交握的基本步驟

不過,在 mTLS 中,用戶端和伺服器都有一個憑證,並且雙方都使用其公鑰/私鑰對進行驗證。與 TLS 相比, mTLS 中還有額外的步驟來驗證雙方 (增加的步驟以粗體標示):

  1. 用戶端連接到伺服器
  2. 伺服器提供其 TLS 憑證
  3. 用戶端驗證伺服器的憑證
  4. 用戶端提供其 TLS 憑證
  5. 伺服器驗證用戶端的憑證
  6. 伺服器授予存取權限
  7. 用戶端和伺服器透過加密的 TLS 連線交換資訊

相互 TLS (mTLS) 交握的基本步驟

mTLS 中的憑證授權單位

實作 mTLS 的組織會擔任自己的憑證授權單位,這和標準的 TLS 有很大的區別。標準的 TLS,憑證授權單位是一個外部的組織,他們負責確認憑證擁有者是否合法地擁有相關的網域。而使用 mTLS 時,必須有一個根憑證 (root CA),才能使組織成為自己的憑證授權單位。

為了使用這個方法,授權用戶端和伺服器使用的憑證必須與根憑證相對應。這個根憑證是由組織自己建立的,也就是自我簽署的。需要注意的是,這個方法不適用於單向 TLS,因為必須由外部憑證授權單位簽發這些憑證。

為什麼使用 mTLS?

mTLS 有助於保障用戶端和伺服器之間的流量在兩個方向上都是安全和可信的,提供了額外的安全層給登入組織網路或應用程式的使用者。此外,它還可以驗證不遵循登入過程的用戶端裝置,例如物聯網(IoT)裝置。mTLS 可以預防各種攻擊,包括中間人攻擊、詐騙攻擊、認證填充、暴力攻擊、網路釣魚攻擊和惡意 API 請求。使用 mTLS 可以可靠地預防這些攻擊,保護組織的網路和應用程式。

以下將以 Spring Boot 建立一支示範 API 並做 mTLS 設定示範。

產生根憑證 (root certificate)

首先我假設您已經安裝 openssl CLI,如果尚未安裝,請自行到官方網站下載。請按照以下步驟生成自簽根憑證。稍後我們會用它來簽署客戶端和服務器憑證。

$ openssl genrsa -des3 -out rootCA.key 2048

這個指令會產生根憑證的私鑰,並會要求輸入一個密碼作為該私鑰的密碼。這十分重要!請必須妥善保管,以防洩露後被他人拿來冒充簽署憑證。指令執行後會產生如下的輸出結果:

根憑證

使用前一步驟中生成的金鑰,向 openssl 請求建立證書。

$ openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 1825 -out rootCA.pem

days 參數代表的是,從現在開始指定證書的到期日期。而我們將證書命名為 rootCA.pem。在此,它會要求你提供一些識別訊息以放入證書中。

恭喜!你現在是一個憑證授權中心,可以為其他實體簽署憑證。申請憑證的實體需要向CA提供包含請求訊息的CSR。在公鑰為基礎的系統中,憑證簽署請求是由申請人發送給公鑰管理機構的一條訊息,以便申請數位身份證書。

簽署伺服器端憑證 (Server Certificate)

我們先建立私鑰,然後為伺服器證書建立 CSR。

$ openssl genrsa -des3 -out server.key 2048

建立私鑰後,建立 CSR。

$ openssl req -new -sha256 -key server.key -out server.csr

接下來會提示輸入與建立根憑證(root CA)所需相同的資訊。CN欄位必需是完全合格的主機名稱,也就是伺服器可被訪問的位置,在這個案例中我們使用的是本機(localhost)。

生成CSR

CSR 由申請憑證的實體提供,再傳遞給根憑證授權機構。在此案例中,由於我們也是根憑證授權機構,所以我們使用這個 CSR 簽署伺服器憑證。

$ openssl x509 -req -in server.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out server.pem -days 365 -sha256

這個過程中你需要輸入根憑證的密碼,以簽署新的憑證。接著會建立一個名為 server.pem 的伺服器憑證,該憑證將與 server.key 一起用在配置 Tomcat 以啟用 SSL。

簽署客戶端憑證

如前面所提,mTLS 是用在雙方彼此的驗證。如果只有單向傳輸層安全協定,我們就不需要客戶端憑證。在這個案例中,我們希望客戶端提供其憑證,並希望伺服器對其進行驗證。所以現在要創建客戶端憑證,以便我們可以使用它來存取API。

$ openssl genrsa -des3 -out client.key 2048

接著以相同方式為客戶建立CSR。

$ openssl req -new -sha256 -key client.key -out client.csr

然後,以同樣的方式簽署客戶證書。

$ openssl x509 -req -in client.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out client.pem -days 365 -sha256

現在我們擁有客戶端和伺服器端的證書。讓我們使用這些證書來保護我們的Spring Boot應用程式。

在 Spring Boot 範例程式啟動 SSL

我們使用 Gradle 建置 Spring Boot 專案

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.4'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.apache.httpcomponents.client5:httpclient5'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

這裡我以一個簡單的 Spring Boot API 作為例子。

package com.example.mtlsdemo.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/mtls")
public class ServerController {
    @PostMapping("/connect")
    public ResponseEntity<Map<String, String>> connect(){
        try {
            Map<String, String> body = new HashMap<>();
            body.put("message", "Connect Succeed!");
            return new ResponseEntity<>(body, HttpStatus.OK);
        }catch (Exception e){
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
}

現在透過 Gradle 啟動應用程式

$ ./gradlew bootRun

如果啟動過程沒有任何例外,可以嘗試呼叫這個 API,確認尚未啟動 SSL 的 API 是否可以正常運行。

POST http://localhost:8080/api/v1/mtls/connect

Spring 提供一系列的配置,可以引入憑證。憑證及其相應的私鑰以 JKS 或 PKCS#12 格式綁定在一個密鑰庫中。儘管生成密鑰庫的工具 keytool 已從 JDK 9+ 開始將預設的存儲類型統一為 PKCS12,並將不再支援 JKS。我們將在PKCS12 密鑰庫中榜定我們的伺服器證書及其密鑰。

前往存放證書的目錄並執行以下命令,以伺服器證書和私人金鑰建立金鑰庫。

$ openssl pkcs12 -export -in server.pem -out keystore.p12 -name server -nodes -inkey server.key

這將匯出證書和私鑰到一個 PKCS 格式的密鑰庫,我們可以用它來配置 Spring 應用程式。預設情況下,私鑰被匯入到加密的密鑰庫中,這樣的話我們需要 Spring 使用密碼來進行解密。

CLI 也會要求輸入密鑰庫的密碼,我們需要將其記錄下來以供稍後給 Spring 使用。所以請務必記下這個密碼!

以伺服器證書和私人金鑰建立金鑰庫

我們將生成的 keystore.p12 文件放入應用程式的 src/main/resources 目錄中(雖然在實際的應用情境這可能透過某種外部配置提供)。

將以下程式碼片段放入 application.properties,然後重新啟動應用程式。

server.ssl.enabled=true

#key-store 是 PKCS12 檔案的路徑
server.ssl.key-store=classpath:keystore.p12

#key-store-password 則是創建密鑰庫時輸入的密碼。
server.ssl.key-store-password=password
server.ssl.key-store-type=PKCS12

重新啟動應用程式後,注意的第一件事是以下log:

Tomcat initialized with port 8080 (https)

這表示我們內嵌的 Tomcat 伺服器已啟用SSL。現在我們再次嘗試呼叫範例 API

POST http://localhost:8080/api/v1/mtls/connect

如果你使用了類似 Postman 這類的 API Client,你會得到以下的錯誤訊息。

Bad Request. This combination of host and port requires TLS.

400Error

這告訴我們應使用 https

POST https://localhost:8080/api/v1/mtls/connect

即便 API 回應正確,但 Postman 在交握期間無法驗證我們伺服器的憑證

這時你會發現,即便 API 回應正確,但 Postman 在交握期間無法驗證我們伺服器的憑證,這很正常!因為Postman 不知道我們是憑證授權機構。我們需要找到一種方法讓 Postman 信任我們就是 root CA。

前往 Postman Settings,然後點選 Certificates 標籤,在頂部有一個名為 CA certificates 的部分,它提供了手動信任CA的功能,加入證書到您的請求中。這是建立 TLS 的步驟之一,無論是單向還是雙向。

將開關切換至開啟狀態,然後選擇 rootCA.pem 檔案,以便 Postman 可以信任這個 CA 。再次執行請求,現在應該已經確認了連線。

新增憑證

已信任憑證

在 Spring Boot 範例程式啟用 mTLS

目前為止我們的Spring Boot 應用程式已配置了單向TLS,並完成信任 root CA 的配置,客戶端可以發送請求。但為實現客戶端和伺服器之間的互相驗證,我們尚需要微調配置,以便讓我們的伺服器也能要求客戶端憑證。請到application.properties 文件,加入以下屬性:

client-auth: need

這個 Key 讓我們可以設定是否需要客戶端驗證(也稱為雙向認證)。它可以是NEED、WANT和NONE三種選項。NEED表示伺服器必須驗證客戶端證書,而 WANT 也要求客戶端證書,但如果沒有提供驗證,它會保持連接。使用NONE 時,不會要求客戶端證書。重新啟動應用程式,再次嘗試呼叫 API。

無法取得回應

儘管錯誤訊息非常簡短且不是很有幫助,但如果打開紀錄觀察,會看到與伺服器的交握失敗了。在這種情況下,由於客戶端未提供任何類型的憑證,並且在握手期間連接已中斷,因此伺服器無法驗證客戶端。為解決此問題,我們需要在調用API時一起發送客戶端證書。

交握失敗

再次進入Postman偏好設定的憑證標籤,點擊新增憑證,然後添加以下項目:

  • host: localhost
  • port: 8080
  • CRT File: /path/to/client.pem
  • KEY File: /path/to/client.key
  • Password: 簽署客戶端憑證階段時設定的密碼

重新發送請求,但是 Postman 仍然出現相同的錯誤。雖然客戶端和伺服器彼此展示了它們的證書,但仍然無法建立連線。如果查看 server log,應該能夠追蹤到當客戶端呼叫 API 時引發了 SslHandShakeException 並有一條訊息"unknown certificate"。我們的伺服器無法驗證客戶端證書,它不信任客戶端證書的 root CA,所以會引發此異常。它知道這是一個有效的證書,但不知道它是在哪個地方簽署的,因此拒絕了請求。

讓我們建立一個信任庫,並將簽署用戶端證書的 root CA 放入其中。前往用戶端證書所在的位置,執行以下命令:

# 要使用 keytool,需要先安裝 Java
$ keytool -import -file rootCA.pem -alias rootCA -keystore truststore.p12

這個指令使用上述提到的 keytool CLI 建立一個 PKCS12 格式的信任存庫。它會提示輸入密碼,我們在配置應用程式時需要使用這個密碼。CLI 會顯示證書訊息,並詢問是否應該信任該證書。選擇“是”並創建信任庫。

建立TruthStore

現在,將 truststore.p12 文件放入 src/main/resources 文件夾中,並到 application.properties 文件,加入以下屬性:

server.ssl.trust-store=classpath:truststore.p12
server.ssl.trust-store-password=password
server.ssl.trust-store-type=PKCS12

重新啟動應用程式,再次嘗試呼叫 API:

完成

現在我們可以看到,伺服器也接受了用戶端憑證,因為它信任簽署它的 root CA。這就是我們在Spring中實現mTLS的方法!