--- title: Spring BootでmTLS(Mutual TLS)の設定メモ tags: ["Java", "Spring Boot", "Spring Security", "mTLS", "TLS"] categories: ["Programming", "Java", "org", "springframework", "security", "web", "authentication", "preauth", "x509"] date: 2024-08-27T03:33:38Z updated: 2024-08-27T23:22:51Z --- Spring Bootで[mTLS(Mutual TLS)](https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/)の設定をします。Spring Boot 3.3.3で試しています。3.1で導入された[SSL Bundle](https://docs.spring.io/spring-boot/reference/features/ssl.html#features.ssl.bundles)を使用しているので、3.1より前のバージョンでは動作しません。 最終的なソースコードは https://github.com/making/demo-mtls です。 **目次** ### サンプルプロジェクトの作成 まずはSpring Initializrで雛形プロジェクトを作成します。 ```bash 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`以下の箇所をコメントアウトします。 ```xml ``` 雛形プロジェクトをビルドします。 ``` ./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証明書と、それを使ったサーバー証明書を発行します。次のコマンドで証明書を発行してください。 ```bash 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 < ${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 < ${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を定義します。 ```properties cat < 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](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.7-Release-Notes#web-server-ssl-configuration-using-pem-encoded-certificates)から証明書の設定はJKS(Keystore)以外にも、PEM形式がサポートされました。[cert-manager](https://cert-manager.io)との組み合わせが楽になりました。`server.ssl.bundle.*`形式(SSL Bundle)の設定は[Spring Boot 3.1](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.1-Release-Notes#ssl-configuration)でサポートされました。 > [!TIP] 3.2からはSSL Bundleの[Hot Reloading](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2-Release-Notes#ssl-bundle-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`に内容を記述します。 ```properties cat < 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`)は必要に応じて変更してください。 ```bash 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](https://docs.spring.io/spring-boot/reference/io/rest-client.html#io.rest-client.restclient.ssl)を使って簡単に設定できます。 次のようなテストコードを用意します。 ```java cat < 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 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](https://docs.spring.io/spring-security/reference/servlet/authentication/x509.html)認証を使うことで、 証明書の内容からユーザー情報を作成し、その情報を使って認可処理を行うことができます。 X.509認証は[Pre-Authentication](https://docs.spring.io/spring-security/reference/servlet/authentication/preauth.html)として実装されています。 Spring Securityに入る前に(ここではTomcatで)認証済みのリクエストを信用して認証済みユーザーを作成する手法です。 `pom.xml`でコメントアウトした箇所を戻します。 ```xml org.springframework.boot spring-boot-starter-security ``` Spring Securityの設定を行うため、次のファイルを作成します。 ```java cat < 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を作成します。 ```java cat < 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`には次のテストコードを追加してください。 ```java @Test void hello() { RestClient restClient = this.restClientBuilder .baseUrl("https://localhost:" + this.port) .apply(this.clientSsl.fromBundle("client")) .build(); ResponseEntity 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-auth`を`need`から`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証明書に関する改善が少しずつ行われているため、非常に簡単に設定できることがわかりました。