sslnettyreactor-nettymutual-authenticationssl-client-authentication

Mutual Authentication with Reactive Netty on restricted urls


I am using spring cloud gateway to front a legacy application so that we can start migrating things behind the scenes. Some of the urls that are hosted by the application are public facing and some are device restricted. We control the devices and they use a browser client to access the restricted urls. We have mutual authentication setup for the device restricted urls on the server using tomcat and security constraints like this in web.xml:

  <security-constraint>
    <web-resource-collection>
      <web-resource-name>Certificate Content</web-resource-name>
      <!-- URL for authentication endpoint - this is locked down with the role assigned by tomcat -->
      <url-pattern>/rest/secure/url1</url-pattern>
      <url-pattern>/rest/secure/url2</url-pattern>
      <url-pattern>/rest/secure/url3</url-pattern>
    </web-resource-collection>
    <auth-constraint>
      <role-name>certificate</role-name>
    </auth-constraint>
      <user-data-constraint>
        <transport-guarantee>CONFIDENTIAL</transport-guarantee>
      </user-data-constraint>
  </security-constraint>

  <!-- All other endpoints- force the switch from http to https with transport-guarantee -->
  <security-constraint>
    <web-resource-collection>
      <web-resource-name>Protected Context</web-resource-name>
      <url-pattern>/*</url-pattern>
    </web-resource-collection>
    <user-data-constraint>
      <transport-guarantee>CONFIDENTIAL</transport-guarantee>
    </user-data-constraint>
  </security-constraint>

  <login-config>
    <auth-method>CLIENT-CERT</auth-method>
  </login-config>

  <security-role>
    <role-name>certificate</role-name>
  </security-role>

That is coupled with a truststore setup in tomcat's server.xml (I can add it, but I don't think that is relevant to this conversation).

My goal is to implement a similar setup in spring cloud gateway which is using reactive netty under-the-hood and remove the web.xml restrictions from the legacy application. I think I could switch it to using tomcat and probably get the web.xml from above to work, but I'd rather stick to the performance benefits of using reactive netty.

Key Goals:

  1. Only deploy one api gateway for the app. The number of urls that require mutual auth is very small so I'd rather not include a whole other container to manage just to support them.
  2. Do not ask for a client cert on the public urls.
  3. Require valid client certs for the restricted urls.

I've setup mutual authentication and can get it to work with need/want/none as expected (truststores setup, etc), but it applies to ALL urls. I've also setup X509 security restrictions and that all seems to work.

I think what I want to setup is tsl renegotiation using the SslHandler after the http request is decrypted (so that I can access the url) based on the path. But I'm having trouble with the details and I've failed at finding any examples that incorporate spring-boot applications using reactive netty to do a tsl renegotiation. Any tips on how to perform a renegotiation of the ssl connection with needClientAuth set to true would be appreciated. I think I need to invalidate the session or something because when I try to do it manually it appears that it is skipping negotiation because the connection is already marked as negotiated in the ssl engine.

This is one of the iterations I've tried (this doesn't restrict on urls, but I plan to add that after I get this working):

@Component
public class NettyWebServerFactoryGatewayCustomizer implements WebServerFactoryCustomizer<NettyReactiveWebServerFactory> {
  private static final Logger LOG = LoggerFactory.getLogger(NettyWebServerFactoryGatewayCustomizer.class);

  @Override
  public void customize(NettyReactiveWebServerFactory serverFactory) {
    serverFactory.addServerCustomizers(httpServer -> {
      httpServer = httpServer.wiretap(true);
      return httpServer.tcpConfiguration(tcpServer -> {
        tcpServer = tcpServer.doOnConnection(connection -> 
            connection.addHandler("request client cert", 
                new SimpleChannelInboundHandler<HttpRequest>() {
                  @Override
                  protected void channelRead0(ChannelHandlerContext ctx, HttpRequest httpRequest) {
                    LOG.error("HttpRequest: {}", httpRequest);
                    final ChannelPipeline pipeline = ctx.pipeline();
                    final SslHandler sslHandler = pipeline.get(SslHandler.class);
                    final SSLEngine sslEngine = sslHandler.engine();
                    sslEngine.setNeedClientAuth(true);
                    sslHandler.renegotiate()
                        .addListener(future -> ctx.fireChannelRead(httpRequest));
                  }
                }
            )
        );
        return tcpServer;
      });
    });
  }
}

I see it performing the renegotiation in the debugger, but it still seems to be set to client auth none (as set in the application.properties) instead of need as set in the code before renegotiation. I've tried sslEngine.getSession().invalidate(); but that didn't help. I've also tried generating a new ssl handler from the ssl provider but that seemed to really screw things up.

Thank you for any help provided.

Edit: Doing more research it appears that this approach is not appropriate going forward since ssl renegotiation is being dropped entirely in tsl 1.3 (see https://security.stackexchange.com/a/230327). Is there a way to perform the equivalent of SSL verify client post handshake as described here: https://www.openssl.org/docs/manmaster/man3/SSL_verify_client_post_handshake.html ?

Edit2: Looks like this was an issue where TLS1.3 post handshake was not supported by the browser I was testing with. Setting the server to just accept TLS 1.2 seemed to work. Not sure if there is a better way to solve this but this is what I added to my application.properties:

server.ssl.enabled-protocols=TLSv1.2

Solution

  • Here is what I used to get it to work. I'm going to leave out the spring security side of it since that is separate from requesting the certificate from the client.

    There are so many ways to configure the child pipeline that is used to process the request. Please let me know if there is a more accepted way to configure it.

    Configure the HttpServer by adding to the bootstrap pipeline that is applied when a connection is established with the client:

    @Component
    public class NettyWebServerFactoryGatewayCustomizer implements WebServerFactoryCustomizer<NettyReactiveWebServerFactory> {
      private static final HttpRenegotiateClientCertHandler HTTP_RENEGOTIATE_CLIENT_CERT_HANDLER =
          new HttpRenegotiateClientCertHandler(SecurityConfig.X509_PROTECTED_ENDPOINTS);
    
      @Override
      public void customize(NettyReactiveWebServerFactory serverFactory) {
        serverFactory.addServerCustomizers(NettyWebServerFactoryGatewayCustomizer::addRenegotiateHandlerToHttpServer);
      }
    
      private static HttpServer addRenegotiateHandlerToHttpServer(HttpServer httpServer) {
        return httpServer.tcpConfiguration(NettyWebServerFactoryGatewayCustomizer::addRenegotiateHandlerToTcpServer);
      }
    
      private static TcpServer addRenegotiateHandlerToTcpServer(TcpServer server) {
        return server.doOnBind(NettyWebServerFactoryGatewayCustomizer::addRenegotiateHandlerToServerBootstrap);
      }
    
      private static void addRenegotiateHandlerToServerBootstrap(ServerBootstrap serverBootstrap) {
        BootstrapHandlers.updateConfiguration(
            serverBootstrap,
            HttpRenegotiateClientCertHandler.NAME,
            NettyWebServerFactoryGatewayCustomizer::addRenegotiateHandlerToChannel
        );
      }
    
      private static void addRenegotiateHandlerToChannel(ConnectionObserver connectionObserver, Channel channel) {
        final ChannelPipeline pipeline = channel.pipeline();
        pipeline.addLast(HttpRenegotiateClientCertHandler.NAME, HTTP_RENEGOTIATE_CLIENT_CERT_HANDLER);
      }
    }
    

    Child Handler that performs the renegotiation:

    @ChannelHandler.Sharable
    public class HttpRenegotiateClientCertHandler extends SimpleChannelInboundHandler<HttpRequest> {
      public static final String NAME = NettyPipeline.LEFT + "clientRenegotiate";
    
      private static final PathPatternParser DEFAULT_PATTERN_PARSER = new PathPatternParser();
    
      private final Collection<PathPattern> pathPatterns;
    
      public HttpRenegotiateClientCertHandler(String ... antPatterns) {
        Assert.notNull(antPatterns, "patterns cannot be null");
        Assert.notEmpty(antPatterns, "patterns cannot be empty");
        Assert.noNullElements(antPatterns, "patterns cannot have null items");
        pathPatterns = Arrays.stream(antPatterns)
            .map(DEFAULT_PATTERN_PARSER::parse)
            .collect(Collectors.toSet());
      }
    
      @Override
      protected void channelRead0(ChannelHandlerContext ctx, HttpRequest request) {
        if (shouldNotRenegotiate(request)) {
          ctx.fireChannelRead(request);
          return;
        }
    
        final ChannelPipeline pipeline = ctx.pipeline();
        final SslHandler sslHandler = pipeline.get(SslHandler.class);
        final SSLEngine sslEngine = sslHandler.engine();
        sslEngine.setNeedClientAuth(true);
    
        sslHandler.renegotiate()
            .addListener(renegotiateFuture -> ctx.fireChannelRead(request));
      }
    
      /**
       * Determine if the request uri matches the configured uris for this handler.
       * @param request to match the path from.
       * @return true if any of the path patterns are matched.
       */
      private boolean shouldNotRenegotiate(HttpRequest request) {
        final String requestUri = request.uri();
        final PathContainer path = PathContainer.parsePath(requestUri);
        return pathPatterns.stream()
            .noneMatch(matcher -> matcher.matches(path));
      }
    }
    

    And these configurations in application.properties:

    # Setup Client Auth Truststore:
    server.ssl.trust-store=<path to truststore>
    server.ssl.trust-store-password=<truststore password>
    server.ssl.trust-store-type=<truststore type>
    # Set to none by default so we do not ask for client auth until needed.
    server.ssl.client-auth=none
    # This is specifically not including TLSv1.3 because there are issues
    # with older browsers' implementation of TLSv1.3 that prevent verify
    # client post handshake client from working.
    server.ssl.enabled-protocols=TLSv1.2
    

    Edit: Updated because handler gateway route code wasn't being invoked properly.