Bypassing Client Authentication for Specific Endpoints in Spring Boot with mTLS(2 way SSL) enabled

In Spring Boot applications secured with SSL, enabling client authentication (configured as server.ssl.client-auth: need) typically requires that all incoming requests provide a valid client certificate. However, there are scenarios where certain endpoints require bypassing this client authentication while maintaining SSL security. For instance, consider a SOAP service where the operations exposed necessitate 2-way SSL, but the WSDL endpoints do not require client certificates and may pose usability challenges for clients. In this post, we'll explore how to achieve this by customizing Spring Security's X509 pre-authentication mechanism. We'll develop a custom filter to selectively bypass client authentication for specific endpoints while still extracting the username from client certificates for pre-authentication purposes. Let's delve into the implementation details.

server.ssl.client-auth

The configuration parameter server.ssl.client-auth typically refers to the server's requirement for client authentication during SSL/TLS (Secure Sockets Layer/Transport Layer Security) handshake.
server.ssl.client-auth:need
When set to need, it means that the server requires clients to present a valid certificate for authentication. If the client fails to provide a valid certificate or if the certificate verification fails for any reason, the server will terminate the SSL/TLS handshake, and the connection will not be established.
This setting adds an extra layer of security to server-client communication, ensuring that only authorized clients with valid certificates can access the server's resources. It's commonly used in scenarios where strong authentication and security are paramount, such as in financial institutions, government agencies, or any other environment where sensitive data is exchanged.
server.ssl.client-auth:want
When server.ssl.client-auth is set to want, it signifies that the server requests, but does not require, client authentication during the SSL/TLS handshake.
In this configuration:
If the client presents a valid certificate, the server will verify it and proceed with the handshake.
If the client does not present a certificate, the server will still continue with the handshake, allowing the connection to be established without client authentication.
This setup provides flexibility, allowing clients to authenticate themselves if they possess a certificate, but not enforcing it as a strict requirement. It's often used in situations where optional client authentication is desired, such as in web applications where some resources are restricted to authenticated users but others are publicly accessible.

So we will change server.ssl.client-auth from 'need' to 'want' and implement a custom X509 filter to change the behaviour for pre authentication by the username provided in the client cert.

Custom X509AuthenticationFilter Implementation

public class CustomX509AuthenticationFilter extends X509AuthenticationFilter {
    private X509PrincipalExtractor principalExtractor = new SubjectDnX509PrincipalExtractor();
    public CustomX509AuthenticationFilter() {}

    @Override
    protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        X509Certificate cert = extractClientCertificate(request);
        return (cert != null) ? this.principalExtractor.extractPrincipal(cert) : null;
    }

    @Override
    protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        return extractClientCertificate(request);
    }

    private X509Certificate extractClientCertificate(HttpServletRequest request) {
        // for get method, not use the client cert even if it existed
        if (HttpMethod.GET.matches(request.getMethod())) {
            return null;
        }
        X509Certificate[] certs = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate");
        if (certs != null && certs.length > 0) {
            this.logger.debug(LogMessage.format("X.509 client authentication certificate:%s", certs[0]));
            return certs[0];
        }
        this.logger.debug("No client certificate found in request.");
        // for soap request we should reject the request if no client cert found
        if (HttpMethod.POST.matches(request.getMethod())) {
            throw new RequestRejectedException("No client certificate found in request.");
        }
        return null;
    }

    public void setPrincipalExtractor(X509PrincipalExtractor principalExtractor) {
        this.principalExtractor = principalExtractor;
    }

}

In our custom implementation, we intentionally return a null client certificate regardless of whether the client sent one along with the request. This is particularly relevant when accessing WSDL endpoints via a browser, as the browser may automatically include a default client certificate, even if it's not the required one. Since this behavior is beyond our control and can't be reliably managed, we choose to handle it by returning null for any requests using the 'GET' method. However, if the endpoints we want to bypass client authentication for are accessed using methods other than 'GET', we may need to inspect the request URL to determine the appropriate action. so that the username pre-authentication will be skipped since we set client-auth to just 'want', not mandatory required.

WebSecurity Config


@Configuration
@EnableWebSecurity
@Slf4j
public class WebSecurityConfig {

    @Value("${ra.client.username}")
    private String userNameInClientCert;

    @Value("${ra.client.cn-regex}")
    private String subjectPrincipalRegex;

    @Bean
    RequestRejectedHandler customRequestRejectedHandler() {
        return new CustomRequestRejectedHandler();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        CustomX509AuthenticationFilter x509AuthenticationFilter = new CustomX509AuthenticationFilter();
        http
                .authorizeRequests(authorizeRequests -> {
                    try {
                        authorizeRequests.requestMatchers(
                                new AntPathRequestMatcher("/ws/*.wsdl", "GET"),
                                new AntPathRequestMatcher("/ws/*.wsdl", "GET"),
                                new AntPathRequestMatcher("/ws/*.xsd", "GET"))
                                .permitAll().and().x509(AbstractHttpConfigurer::disable);
                        authorizeRequests.requestMatchers(
                                new AntPathRequestMatcher("/ws/op1", "POST"),
                                new AntPathRequestMatcher("/ws/op2", "POST")
                        ).permitAll().and().x509(x -> x.subjectPrincipalRegex(subjectPrincipalRegex)
                                .x509AuthenticationFilter(x509AuthenticationFilter)
                                .userDetailsService(username -> {
                                    log.info("username in client cert is : {}, {}", username, subjectPrincipalRegex);
                                    if (!username.equalsIgnoreCase(userNameInClientCert)) {
                                        throw new RequestRejectedException("no match username found from the client certificate in request.");
                                    }
                                    // TODO... check username against ldap or your username manager service
                                    // should throw new RequestRejectedException in case required username not found
                                    return new UserDetails() {
                                        @Override
                                        public Collection<? extends GrantedAuthority> getAuthorities() {
                                            List result = new ArrayList<>();
                                            result.add(new GrantedAuthority() {
                                                @Override
                                                public String getAuthority() {
                                                    return "read";
                                                }
                                            });
                                            return result;
                                        }

                                        @Override
                                        public String getPassword() {
                                            return null;
                                        }

                                        @Override
                                        public String getUsername() {
                                            return username;
                                        }

                                        @Override
                                        public boolean isAccountNonExpired() {
                                            return true;
                                        }

                                        @Override
                                        public boolean isAccountNonLocked() {
                                            return true;
                                        }

                                        @Override
                                        public boolean isCredentialsNonExpired() {
                                            return true;
                                        }

                                        @Override
                                        public boolean isEnabled() {
                                            return true;
                                        }
                                    };
                                })).csrf(c -> c.disable());
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                });
        SecurityFilterChain sc = http.build();
        x509AuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        return sc;
    }
}


In this part of the implementation, there are a few key considerations. Firstly, we ensure that the AuthenticationManager is set to the custom X509 filter after the http.build() statement. This ensures that the AuthenticationManager is properly initialized before it's used by the filter. Secondly, we always throw a RequestRejectedException, regardless of whether a client certificate is found in the CustomX509AuthenticationFilter for protected requests or if the username doesn't match. This is because, in the filter chain of the Spring Boot web or WebFlux framework, only this exception will be caught and result in an error response being returned to the client when client authentication is set to 'want'. I've experimented with throwing a custom Runtime exception, but it gets caught as well. However, in this case, it's only logged and the SSL handshake continues because the client certificate is not required.

Custom RequestRejectedHandler

by default, HttpStatusRequestRejectedHandler is used to send error response for RequestRejectedException. it only set a status 400 BAD_REQUEST. but does not have any body. in case you want to set different status code or want tell the client the specific reason, we can customize it by implement a custom RequestRejectedHandler and initialize it as a Bean.

@Slf4j
public class CustomRequestRejectedHandler implements RequestRejectedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, RequestRejectedException requestRejectedException) throws IOException, ServletException {
        log.info("Rejecting request due to: {}", requestRejectedException.getMessage(), requestRejectedException);
        // response.sendError(HttpServletResponse.SC_BAD_REQUEST, requestRejectedException.getMessage());
        // Set the status code
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        // Write a custom response body
        response.getWriter().write(requestRejectedException.getMessage());
    }
}

    @Bean
    RequestRejectedHandler customRequestRejectedHandler() {
        return new CustomRequestRejectedHandler();
    }

Conclusion

In scenarios where you need to selectively bypass client authentication for specific endpoints in your Spring Boot application secured with SSL, Spring Security's X509 pre-authentication mechanism offers a robust solution. By customizing the X509AuthenticationFilter, you can configure your application to extract the username from client certificates while bypassing authentication for designated endpoints. This approach ensures flexibility in managing security requirements while maintaining the integrity of your application's authentication process.

Subscribe to Post, Code and Quiet Time.

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe