Aug 27, 2024
Aug 27, 2024
N/A Views
MD

Spring BootでmTLS(Mutual TLS)の設定をします。Spring Boot 3.3.3で試しています。3.1で導入されたSSL Bundleを使用しているので、3.1より前のバージョンでは動作しません。

最終的なソースコードは https://github.com/making/demo-mtls です。

目次

サンプルプロジェクトの作成

まずはSpring Initializrで雛形プロジェクトを作成します。

curl https://start.spring.io/starter.tgz \
       -d artifactId=demo-mtls \
       -d baseDir=demo-mtls \
       -d packageName=com.example \
       -d dependencies=web,actuator,security \
       -d type=maven-project \
       -d name=demo-mtls \
       -d applicationName=DemoMtlsApplication | tar -xzvf -
cd demo-mtls

最初はSpring Securityを使用しないので、pom.xml以下の箇所をコメントアウトします。

                <!--
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-security</artifactId>
                </dependency>
                -->

雛形プロジェクトをビルドします。

./mvnw clean package -DskipTests

アプリケーションを起動します。

java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar

ヘルスチェックエンドポイントにcurlでアクセスします。

$ curl http://localhost:8080/actuator/health
{"status":"UP"}

ここまでは雛形通りです。

サーバー側でHTTPSを有効にする (One-way TLS)

双方向のmTLSを設定する前にまずはサーバー側のみの一応方のTLS設定を行います。普通のHTTPS設定です。

OpenSSLで自己署名のCA証明書と、それを使ったサーバー証明書を発行します。次のコマンドで証明書を発行してください。

DIR=$PWD/src/main/resources/self-signed
mkdir -p ${DIR}

# Create CA certificate
openssl req -new -nodes -out ${DIR}/ca.csr -keyout ${DIR}/ca.key -subj "/CN=@making/O=LOL.MAKI/C=JP"
chmod og-rwx ${DIR}/ca.key

cat <<EOF > ${DIR}/ext_ca.txt
basicConstraints=CA:TRUE
keyUsage=digitalSignature,keyCertSign
EOF

openssl x509 -req -in ${DIR}/ca.csr -days 3650 -signkey ${DIR}/ca.key -out ${DIR}/ca.crt -extfile ${DIR}/ext_ca.txt

cat <<EOF > ${DIR}/ext.txt
basicConstraints=CA:FALSE
keyUsage=digitalSignature,dataEncipherment,keyEncipherment,keyAgreement
extendedKeyUsage=serverAuth,clientAuth
EOF

# Create Server certificate signed by CA
openssl req -new -nodes -out ${DIR}/server.csr -keyout ${DIR}/server.key -subj "/CN=localhost"
chmod og-rwx ${DIR}/server.key
openssl x509 -req -in ${DIR}/server.csr -days 3650 -CA ${DIR}/ca.crt -CAkey ${DIR}/ca.key -CAcreateserial -out ${DIR}/server.crt -extfile ${DIR}/ext.txt

次のようなファイルが作成されるでしょう。

$ ls -l src/main/resources/self-signed
total 72
-rw-r--r--  1 tmaki  staff  1164  8 27 10:42 ca.crt
-rw-r--r--  1 tmaki  staff   932  8 27 10:42 ca.csr
-rw-------  1 tmaki  staff  1704  8 27 10:42 ca.key
-rw-r--r--  1 tmaki  staff    41  8 27 10:42 ca.srl
-rw-r--r--  1 tmaki  staff   137  8 27 10:42 ext.txt
-rw-r--r--  1 tmaki  staff    63  8 27 10:42 ext_ca.txt
-rw-r--r--  1 tmaki  staff  1204  8 27 10:42 server.crt
-rw-r--r--  1 tmaki  staff   891  8 27 10:42 server.csr
-rw-------  1 tmaki  staff  1704  8 27 10:42 server.key

このサーバー証明書を使用するようにapplication.propertiesを設定します。ここではtlsプロファイルでこの設定が有効になるようにapplication-tls.propertiesに内容を記述します。
self-signedという名前のSSL Bundleを定義します。

cat <<EOF > src/main/resources/application-tls.properties
server.port=8443
server.ssl.enabled=true
server.ssl.bundle=self-signed
spring.ssl.bundle.pem.self-signed.keystore.certificate=classpath:self-signed/server.crt
spring.ssl.bundle.pem.self-signed.keystore.private-key=classpath:self-signed/server.key
EOF

Note

Spring Boot 2.7から証明書の設定はJKS(Keystore)以外にも、PEM形式がサポートされました。cert-managerとの組み合わせが楽になりました。server.ssl.bundle.*形式(SSL Bundle)の設定はSpring Boot 3.1でサポートされました。

Tip

3.2からはSSL BundleのHot Reloadingもサポートされました。

ビルドします。

./mvnw clean package -DskipTests

tlsプロファイルを有効にしてアプリケーションを起動します。

java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar --spring.profiles.active=tls

ヘルスチェックエンドポイントにcurlでアクセスします。-kオプションをつけて証明書のチェックを無視します。

$ curl -k https://localhost:8443/actuator/health
{"status":"UP"}

-kオプションを使う代わりに--cacertオプションでCA証明書のパスを指定しても良いです。

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443/actuator/health
{"status":"UP"}

-vオプションをつけてサーバー証明書の内容を確認できます。

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443/actuator/health -v
...
* Server certificate:
*  subject: CN=localhost
*  start date: Aug 27 01:42:54 2024 GMT
*  expire date: Aug 25 01:42:54 2034 GMT
*  common name: localhost (matched)
*  issuer: CN=@making; O=LOL.MAKI; C=JP
*  SSL certificate verify ok.
* using HTTP/1.x
...
{"status":"UP"}

クライアント証明も要求する (Mutual TLS)

次にクライアント認証も行うようにapplication.propertiesを設定します。ここではmtlsプロファイルでこの設定が有効になるようにapplication-mtls.propertiesに内容を記述します。

cat <<EOF > src/main/resources/application-mtls.properties
server.ssl.client-auth=need
spring.ssl.bundle.pem.self-signed.truststore.certificate=classpath:self-signed/ca.crt
EOF

ビルドします。

./mvnw clean package -DskipTests

tlsプロファイルとmtlsプロファイルを有効にしてアプリケーションを起動します。

java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar --spring.profiles.active=tls,mtls

先と同様にアプリケーションにアクセスします。すると今度はSSLV3_ALERT_BAD_CERTIFICATEというエラーが出ました。

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443/actuator/health   
curl: (56) BoringSSL SSL_read: BoringSSL: error:10000412:SSL routines:OPENSSL_internal:SSLV3_ALERT_BAD_CERTIFICATE, errno 0

クライアント認証はTomcatのレベルで行われ、このリクエストはサーブレットまでは到達していません。

spring.ssl.bundle.pem.self-signed.truststore.certificateに設定したCAを使って、クライアント証明書を発行します。
作り方はサーバー証明書の場合と同じです。Subjectの値(-subj)は必要に応じて変更してください。

DIR=$PWD/src/main/resources/self-signed
# Create Client certificate signed by CA
openssl req -new -nodes -out ${DIR}/client.csr -keyout ${DIR}/client.key -subj "/CN=toshiaki-maki"
chmod og-rwx ${DIR}/client.key
openssl x509 -req -in ${DIR}/client.csr -days 3650 -CA ${DIR}/ca.crt -CAkey ${DIR}/ca.key -CAcreateserial -out ${DIR}/client.crt -extfile ${DIR}/ext.txt

生成された証明書を確認します。IssuerとSubjectが想定通りか確認してください。

$ openssl x509 -noout -text -in src/main/resources/self-signed/client.crt 
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            72:52:05:2b:43:f7:d8:6a:23:95:50:65:19:d0:be:38:0e:e9:82:ed
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=@making, O=LOL.MAKI, C=JP
        Validity
            Not Before: Aug 27 01:56:49 2024 GMT
            Not After : Aug 25 01:56:49 2034 GMT
        Subject: CN=toshiaki-maki
...

このクライアント証明書を使ってアプリケーションにアクセスします。今度はOKが返ります。

$ curl --cacert src/main/resources/self-signed/ca.crt --cert src/main/resources/self-signed/client.crt --key src/main/resources/self-signed/client.key https://localhost:8443/actuator/health
{"status":"UP"}   

クライアントサイドのmTLS対応

次にクライアントサイドでどのようにmTLSを対応すれば良いか、すなわちcurl--cert--keyそして--cacertオプションに相当するものをどのように設定すれば良いかですが、
これもSSL Bundleを使って簡単に設定できます。

次のようなテストコードを用意します。

cat <<EOF > src/test/java/com/example/DemoMtlsApplicationTests.java
package com.example;

import javax.net.ssl.SSLHandshakeException;

import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.client.RestClientSsl;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
        "spring.profiles.active=tls,mtls",
        "spring.ssl.bundle.pem.client.keystore.certificate=classpath:self-signed/client.crt",
        "spring.ssl.bundle.pem.client.keystore.private-key=classpath:self-signed/client.key",
        "spring.ssl.bundle.pem.client.truststore.certificate=classpath:self-signed/ca.crt",
        "spring.ssl.bundle.pem.cacert.truststore.certificate=classpath:self-signed/ca.crt"
})
class DemoMtlsApplicationTests {
    @LocalServerPort int port;

    @Autowired 
    RestClient.Builder restClientBuilder;

    @Autowired
    RestClientSsl clientSsl;

    @Test
    void healthCheckWithValidCertificate() {
        RestClient restClient = this.restClientBuilder
                .baseUrl("https://localhost:" + this.port)
                .apply(this.clientSsl.fromBundle("client")) // (1)
                .build();
        ResponseEntity<String> response = restClient.get()
                .uri("/actuator/health")
                .retrieve()
                .toEntity(String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo("{\"status\":\"UP\"}");
    }

    @Test
    void healthCheckWithoutCertificate() {
        RestClient restClient = this.restClientBuilder
                .baseUrl("https://localhost:" + this.port)
                .apply(this.clientSsl.fromBundle("cacert")) // (2)
                .build();
        try {
            restClient.get()
                    .uri("/actuator/health")
                    .retrieve()
                    .toEntity(String.class);
            fail("Should have thrown an exception");
        }
        catch (ResourceAccessException e) {
            assertThat(e.getCause()).isInstanceOf(SSLHandshakeException.class);
            assertThat(e.getCause().getMessage()).isEqualTo("Received fatal alert: bad_certificate");
        }
    }

}
EOF
  • (1) ... 正しい証明書を設定したclient SSL Bundleを使用する
  • (2) ... CA証明書だけ設定し、クライアント証明書を設定していないcacert SSL Bundleを使用する

テストを実行してください。

./mvnw clean test

Spring SecurityによるTLS認証・認可

ここまでのmTLS処理はTomcatレイヤーで行われます。クライアント証明書が正しいかどうかのチェックしか行われません。
Spring SecurityのX.509認証を使うことで、
証明書の内容からユーザー情報を作成し、その情報を使って認可処理を行うことができます。

X.509認証はPre-Authenticationとして実装されています。
Spring Securityに入る前に(ここではTomcatで)認証済みのリクエストを信用して認証済みユーザーを作成する手法です。

pom.xmlでコメントアウトした箇所を戻します。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

Spring Securityの設定を行うため、次のファイルを作成します。

cat <<EOF > src/main/java/com/example/SecurityConfig.java
package com.example;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers("/").hasRole("MTLS") // (3)
                        .anyRequest().permitAll())
                .x509(s -> s.subjectPrincipalRegex("CN=([\\w\\-]+)")) // (1)
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> User.withUsername(username).password("{noop}dummy" /* (2) */).roles("MTLS").build();
    }
}
EOF
  • (1) ... 証明書からCN属性を抽出し、ユーザー名として使用する
  • (2) ... Pre-Authenticationではパスワードは不要なので、dummy値を設定する(ビルダーの必須項目であるため)
  • (3) ... /に対するアクセスはMTLSロールが必要とする

Tip

java.security.cert.X509Certificateから柔軟にユーザー情報を抽出したい場合は、org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractorにロジックを実装して、.x509(s -> s.x509PrincipalExtractor(new MyExtractor()))で設定できます。

/に対するリクエストを処理するControllerを作成します。

cat <<EOF > src/main/java/com/example/HelloController.java
package com.example;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    @GetMapping(path = "/")
    public String sayHello(@AuthenticationPrincipal UserDetails user) {
        return "Hello " + user.getUsername() + "!";
    }
}
EOF

DemoMtlsApplicationTestsには次のテストコードを追加してください。

    @Test
    void hello() {
        RestClient restClient = this.restClientBuilder
                .baseUrl("https://localhost:" + this.port)
                .apply(this.clientSsl.fromBundle("client"))
                .build();
        ResponseEntity<String> response = restClient.get()
                .uri("/")
                .retrieve()
                .toEntity(String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo("Hello toshiaki-maki!");
    }

アプリケーションをビルドして、実行します。

./mvnw clean package
java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar --spring.profiles.active=tls,mtls

正しいクライアント証明書を渡した場合は、CN属性に設定したユーザー名が返ります。

$ curl --cacert src/main/resources/self-signed/ca.crt --cert src/main/resources/self-signed/client.crt --key src/main/resources/self-signed/client.key https://localhost:8443 
Hello toshiaki-maki!

クライアント証明書を渡さない場合は、証明書エラーになります。

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443  
curl: (56) BoringSSL SSL_read: BoringSSL: error:10000412:SSL routines:OPENSSL_internal:SSLV3_ALERT_BAD_CERTIFICATE, errno 0

クライアント証明書を渡さない場合に証明書エラーではなく、Spring Securityによる403エラーを返したい場合は、server.ssl.client-authneedからwantに変えてください。
この場合は、Tomcatレイヤーで証明書検証に失敗してもサーブレットレイヤーにリクエストが送られます。

java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar --spring.profiles.active=tls,mtls --server.ssl.client-auth=want

この状態でクライアント証明書を渡さずにリクエストを送ると403エラーが返ります。

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443 
{"timestamp":"2024-08-27T03:28:12.310+00:00","status":403,"error":"Forbidden","path":"/"}

Spring BootでmTLSの設定を行いました。Spring Boot 2.7以降、TLS証明書に関する改善が少しずつ行われているため、非常に簡単に設定できることがわかりました。

Found a mistake? Update the entry.
Share this article: