Ahmed Tharwat
Ahmed Tharwat

Reputation: 19

Writing Integration Tests For Spring Boot App. - mTLS Authentication

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.

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

Answers (0)

Related Questions