Spring BootでmTLS(Mutual TLS)の設定をします。Spring Boot 3.3.3で試しています。3.1で導入されたSSL Bundleを使用しているので、3.1より前のバージョンでは動作しません。
最終的なソースコードは https://github.com/making/demo-mtls です。
目次
- サンプルプロジェクトの作成
- サーバー側でHTTPSを有効にする (One-way TLS)
- クライアント証明も要求する (Mutual TLS)
- クライアントサイドのmTLS対応
- Spring SecurityによるTLS認証・認可
サンプルプロジェクトの作成
まずは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)... 正しい証明書を設定したclientSSL Bundleを使用する(2)... CA証明書だけ設定し、クライアント証明書を設定していないcacertSSL 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-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証明書に関する改善が少しずつ行われているため、非常に簡単に設定できることがわかりました。