Reputation: 19
I have a spring boot application where I implemented mTLS authentication by enabling the following properties:
server:
port: 8080
ssl:
enabled: true
client-auth: need
key-store: 'classpath:keystore.p12'
key-store-password: serverkeystore
key-store-type: PKCS12
trust-store: 'classpath:truststore.p12'
trust-store-type: PKCS12
trust-store-password: servertruststore
subject-validation:
enabled: true
allowed-list: test1.com,test.test1.com
cn-pattern: "CN=([^,]+)"
Also I have added the following implementation for certificate CN and SAN validation:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
@ConditionalOnProperty(
name = "server.ssl.subject-validation.enabled",
havingValue = "true"
)
@RequiredArgsConstructor
public class ClientCertificateValidationFilter implements Filter {
@Value("#{'${server.ssl.subject-validation.allowed-list:[]}'.split(',')}")
private final List<String> clientCnOrSanAllowedList;
@Value("${server.ssl.subject-validation.cn-pattern:CN=([^,]+)}")
private String cnValidationPattern;
private Pattern cnPattern;
@SneakyThrows
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain) throws PatternSyntaxException {
compileValidationPattern(this.cnValidationPattern);
X509Certificate[] x509Certificates = extractCertificateFromRequest(servletRequest);
boolean isClientSanOrCnMatched = verifyAllowedClients(x509Certificates);
if (isClientSanOrCnMatched) {
filterChain.doFilter(servletRequest, servletResponse);
} else {
HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "Client Request: Not Allowed");
}
}
private void compileValidationPattern(String cnValidationPattern) {
try {
log.debug("[compileValidationPatterns()] Compiling validation pattern: \"{}\"", cnValidationPattern);
cnPattern = Pattern.compile(cnValidationPattern);
} catch (PatternSyntaxException exception) {
throw new PatternSyntaxException(exception.getDescription(), exception.getPattern(), exception.getIndex());
}
}
private X509Certificate[] extractCertificateFromRequest(ServletRequest servletRequest) {
log.debug("[extractCertificateFromRequest()] Extracting certificate from request");
HttpServletRequest httpServletRequest = ((HttpServletRequest) servletRequest);
return (X509Certificate[]) httpServletRequest.getAttribute("jakarta.servlet.request.X509Certificate");
}
private boolean verifyAllowedClients(X509Certificate[] x509Certificates) throws CertificateParsingException {
boolean isClientSanOrCnMatched = false;
if (Objects.nonNull(x509Certificates) && x509Certificates.length > 0) {
X509Certificate x509Certificate = x509Certificates[0];
isClientSanOrCnMatched = validateClientCn(x509Certificate);
if (!isClientSanOrCnMatched) {
isClientSanOrCnMatched = validateClientSan(x509Certificate);
}
}
return isClientSanOrCnMatched;
}
private boolean validateClientCn(X509Certificate x509Certificate) {
String subjectDN = x509Certificate.getSubjectX500Principal().getName();
log.debug("[validateClientCn()] Client Subject: {}", subjectDN);
Matcher commonNameMatcher = cnPattern.matcher(subjectDN);
if(commonNameMatcher.find()){
String clientCommonName = commonNameMatcher.group(1);
log.debug("[validateClientCn()] Client CN: {}", clientCommonName);
return clientCnOrSanAllowedList.stream().anyMatch(clientCommonName::equals);
}
return false;
}
private boolean validateClientSan(X509Certificate x509Certificate) throws CertificateParsingException {
if(Objects.nonNull(x509Certificate.getSubjectAlternativeNames())) {
log.debug("[validateClientSan()] Client SANs: {}", x509Certificate.getSubjectAlternativeNames());
return x509Certificate.getSubjectAlternativeNames().stream()
.anyMatch(sanList -> sanList.stream()
.anyMatch(clientCnOrSanAllowedList::equals));
}
return false;
}
}
I'm trying to create integration tests, either by MockMvc
or TestRestTemplate
but unfortunately I didn't find any example on the internet that simulates such a case. I have been stuck with this for a while and reached nothing.
Here is my trial using TestRestTemplate
:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ClientCertificateValidationFilterTests {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@BeforeEach
public void setup() throws Exception {
String TRUSTSTORE_PATH = "truststore.p12";
String KEYSTORE_PATH = "keystore.p12";
String KEYSTORE_PASSWORD = "clientkeystore";
String TRUSTSTORE_PASSWORD = "servertruststore";
SSLContext sslContext = createSSLContext(KEYSTORE_PATH, KEYSTORE_PASSWORD, TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD);
SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(sslContext);
HttpClientConnectionManager connectionManager =
PoolingHttpClientConnectionManagerBuilder.create()
.setSSLSocketFactory(socketFactory).build();
CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(connectionManager).build();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient);
RestTemplateBuilder restTemplateBuilder = new RestTemplateBuilder()
.requestFactory(() -> factory)
.rootUri("https://localhost:" + port);
this.restTemplate = new TestRestTemplate(restTemplateBuilder,
null,
null,
TestRestTemplate.HttpClientOption.SSL);
}
@Test
public void test() throws Exception {
String url = "https://localhost:" + port + "/api/test";
ResponseEntity<String> response = this.restTemplate.exchange(
url,
HttpMethod.GET,
null,
String.class
);
Assertions.assertEquals(200, response.getStatusCode().value());
}
private SSLContext createSSLContext(String keystorePath, String keystorePassword,
String truststorePath, String truststorePassword) throws Exception {
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(new FileInputStream(new ClassPathResource(keystorePath).getFile()), keystorePassword.toCharArray());
KeyStore trustStore = KeyStore.getInstance("PKCS12");
trustStore.load(new FileInputStream(new ClassPathResource(truststorePath).getFile()), truststorePassword.toCharArray());
return SSLContexts.custom()
.loadKeyMaterial(keyStore, keystorePassword.toCharArray())
.loadTrustMaterial(trustStore, null)
.build();
}
}
Here is the console output when running the previous test:
org.springframework.web.client.ResourceAccessException: I/O error on HEAD request for "https://localhost:56110/test": Received fatal alert: bad_certificate
at org.springframework.web.client.RestTemplate.createResourceAccessException(RestTemplate.java:915)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:895)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:790)
at org.springframework.web.client.RestTemplate.exchange(RestTemplate.java:672)
at org.springframework.boot.test.web.client.TestRestTemplate.exchange(TestRestTemplate.java:710)
at com.ahmed.demo.ClientCertificateValidationFilterTests.test(ClientCertificateValidationFilterTests.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
Caused by: javax.net.ssl.SSLHandshakeException: Received fatal alert: bad_certificate
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:130)
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:117)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:370)
at java.base/sun.security.ssl.Alert$AlertConsumer.consume(Alert.java:287)
at java.base/sun.security.ssl.TransportContext.dispatch(TransportContext.java:209)
at java.base/sun.security.ssl.SSLTransport.decode(SSLTransport.java:172)
at java.base/sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1509)
at java.base/sun.security.ssl.SSLSocketImpl.readApplicationRecord(SSLSocketImpl.java:1480)
at java.base/sun.security.ssl.SSLSocketImpl$AppInputStream.read(SSLSocketImpl.java:1066)
at org.apache.hc.core5.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:149)
at org.apache.hc.core5.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:280)
at org.apache.hc.core5.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:247)
at org.apache.hc.core5.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:54)
at org.apache.hc.core5.http.impl.io.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:304)
at org.apache.hc.core5.http.impl.io.HttpRequestExecutor.execute(HttpRequestExecutor.java:175)
at org.apache.hc.core5.http.impl.io.HttpRequestExecutor.execute(HttpRequestExecutor.java:218)
at org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager$InternalConnectionEndpoint.execute(PoolingHttpClientConnectionManager.java:712)
at org.apache.hc.client5.http.impl.classic.InternalExecRuntime.execute(InternalExecRuntime.java:216)
at org.apache.hc.client5.http.impl.classic.MainClientExec.execute(MainClientExec.java:116)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.ConnectExec.execute(ConnectExec.java:188)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.ProtocolExec.execute(ProtocolExec.java:192)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.HttpRequestRetryExec.execute(HttpRequestRetryExec.java:96)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.ContentCompressionExec.execute(ContentCompressionExec.java:152)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.RedirectExec.execute(RedirectExec.java:115)
at org.apache.hc.client5.http.impl.classic.ExecChainElement.execute(ExecChainElement.java:51)
at org.apache.hc.client5.http.impl.classic.InternalHttpClient.doExecute(InternalHttpClient.java:170)
at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:87)
at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:55)
at org.apache.hc.client5.http.classic.HttpClient.executeOpen(HttpClient.java:183)
at org.springframework.http.client.HttpComponentsClientHttpRequest.executeInternal(HttpComponentsClientHttpRequest.java:99)
at org.springframework.http.client.AbstractStreamingClientHttpRequest.executeInternal(AbstractStreamingClientHttpRequest.java:70)
at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:66)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:889)
... 7 more
Keep in mind that I'm attaching the correct truststore.p12
, keystore.p12
and I'm sure they that they are working properly as they are already tested on my terminal using curl command on the running application, and the same values used inside application.yaml
are used for test/application.yaml
.
I had a try with MockMvc
but unfortunately didn't find a way to configure it to use SSL context.
Upvotes: 0
Views: 48