Search code examples
sslspring-bootgrailsx509certificategrails-3.3

"CLIENT-CERT" based X509 certificate authentication in Grails 3.3


I have been trying to mirror an implementation I had in Grails 2 for some time now as I try and upgrade to Grails 3.

I need to support X509 certificate based authentication using the "client-cert" auth method, that is, I only want to be prompted for a certificate once a protected resource has been requested. See current implementation below in Application.groovy.

@Bean
EmbeddedServletContainerCustomizer containerCustomizer() throws Exception {

    return new EmbeddedServletContainerCustomizer() {
        @Override
        public void customize(ConfigurableEmbeddedServletContainer container) {
            TomcatEmbeddedServletContainerFactory tomcat = (TomcatEmbeddedServletContainerFactory) container
            tomcat.addConnectorCustomizers(
                    new TomcatConnectorCustomizer() {
                        @Override
                        public void customize(Connector connector) {
                            connector.setPort(8443)
                            connector.setSecure(true)
                            connector.setScheme("https")

                            Http11NioProtocol proto = (Http11NioProtocol) connector.getProtocolHandler()
                            proto.setMinSpareThreads(5)
                            proto.setSSLEnabled(true)
                            proto.setClientAuth("false")

                            proto.setKeystoreFile("/tmp/keys/app.jks")
                            proto.setKeystorePass("changeit")
                            proto.setKeystoreType("JKS")
                            proto.setKeyAlias("ssl_server")
                            proto.setTruststoreFile("/tmp/keys/app.jts")
                            proto.setTruststoreType("JKS")
                            proto.setTruststorePass("changeit")
                        }
                    })
            tomcat.addContextCustomizers(new TomcatContextCustomizer() {
                @Override
                public void customize(Context context) {
                    context.setPath("/myapp")
                    SecurityConstraint sc = new SecurityConstraint()
                    SecurityCollection securityCollection = new SecurityCollection()
                    securityCollection.setName("Protected")
                    securityCollection.addPattern("/*")

                    sc.addCollection(securityCollection)

                    sc.addAuthRole("mySecureConnection")
                    sc.setUserConstraint("CONFIDENTIAL")
                    context.addConstraint(sc)
                    context.addSecurityRole("mySecureConnection")
                    context.setRealm(new MySecurityRealm())

                    LoginConfig loginConfig = new LoginConfig()
                    loginConfig.setAuthMethod("CLIENT-CERT")
                    loginConfig.setRealmName("MySecurityRealm")
                    context.setLoginConfig(loginConfig)
                    sc.setAuthConstraint(true)

                }
            });
        }
    }

But no matter how many different ways I try and cut it, the application will not request a cert upon access (which it should based on my catch all pattern above). Note that this mechanism does work as expected when clientAuth is set to true;

proto.setClientAuth("true")

but this means a cert is always requested which is not ultimately what I am looking for (I intend to update the pattern above). Any help would be much appreciated.


Solution

  • Got this working myself in the after working on a proof of concept at the vanilla tomcat and Spring Boot level before returning to Grails 3.3.x to apply what worked. I think probably the most important piece of the jigsaw was the addition of a tomcat valve component (using the SSLAuthenticator implementation obviously) which was the only way I could manage to get the browser to prompt for a certificate. This then required me to use a custom realm to retrieve the principal from the certificate (I know of no other way around this at present). Code is as follows;

    @Bean
    public EmbeddedServletContainerFactory servletContainer() {
        final TomcatEmbeddedServletContainerFactory tomcat = new TomcatEmbeddedServletContainerFactory();
        tomcat.addContextValves(new SSLAuthenticator());
    
        tomcat.addContextCustomizers(new TomcatContextCustomizer() {
            @Override
            public void customize(Context ctx) {
    
                String AUTH_ROLE = "mySecureRole";
                ctx.addSecurityRole(AUTH_ROLE);
                ctx.setRealm(new MySecurityRealm())
    
                LoginConfig config = new LoginConfig();
                config.setAuthMethod("CLIENT-CERT");
                config.setRealmName("MySecurityRealm");
                ctx.setLoginConfig(config);
    
                SecurityConstraint constraint = new SecurityConstraint();
                constraint.addAuthRole(AUTH_ROLE);
                SecurityCollection collection = new SecurityCollection();
                collection.addPattern("/secure");
                constraint.addCollection(collection);
                ctx.addConstraint(constraint);
            }
        })
    
        tomcat.addAdditionalTomcatConnectors(createConnector());
        return tomcat;
    }
    
    
    private Connector createConnector() {
        Connector connector = new Connector(TomcatEmbeddedServletContainerFactory.DEFAULT_PROTOCOL);
    
        connector.setPort(8443);
        connector.setSecure(true);
        connector.setScheme("https");
    
        Http11NioProtocol proto = (Http11NioProtocol) connector.getProtocolHandler();
        proto.setMinSpareThreads(5);
        proto.setSSLEnabled(true);
        proto.setClientAuth("false");
        proto.setSSLProtocol("all");
    
        proto.setKeystoreFile("/path/store.jks");
        proto.setKeystorePass("changeit");
        proto.setKeystoreType("JKS");
        proto.setKeyAlias("ssl_server");
        proto.setTruststoreFile("/path/store.jts");
        proto.setTruststoreType("JKS");
        proto.setTruststorePass("changeit");
    
        proto.setSSLVerifyDepth(2);
        return connector;
    }
    

    I'm leaving in the connector details for completeness but of course all of the important stuff is happening in the context customizer. Now, when I visit this web application I do not get prompted for a certificate. This only happens when I visit the /secure path which is exactly what I required.