Spring Data MongoDB: Configuring Secure Connection Between Spring Boot and MongoDB over TLS using Certificates

MongoDB is a widely used NoSQL database for building modern web applications. While the autoconfiguration provided by Spring Boot and Spring Data MongoDB Starters are generous enough to establish connectivity between your microservice and the database, this connection is not secure. This is where SSL/TLS come in.

SSL and TLS are often interchangeably used since both the protocols serve the same purpose – they are used to secure an internet connection between two parties – either a client and a server, or a server and another server – so that attackers cannot read or modify the data in transit between the two involved. This is done by scrambling the data using various algorithms. TLS is an updated, more secure version of SSL. Explaining the difference between the two is out of scope for this article but a simple search should get you the data you need.

For the rest of this article, we will be focusing on two important things – the TLS/SSL handshake followed by MongoDB when you establish connectivity, and how to programmatically configure the MongoClient and relevant Beans.


The SSL/TLS Handshake

Before moving ahead, I would request you to visit this blog to read more about Client Authentication and how it is different than traditional TLS/SSL handshake.

Now that you are aware about the handshake process, let’s get started on understanding the configuration. In most organizations when you need to connect with MongoDB over SSL/TLS, you will be provided with two files which contain the following critical information:

  1. ca.crt – This certificate is used to specify the Certificate Authority. SSL/TLS Certificates need to be signed by an authority. This Certificate Authority can be your own organization in which case, your certificate is called a self-signed certificate.
  2. certificateKeyFile.pem – This single PEM-formatted file is used to present the client certificate, along with its private key if any, to the MongoDB server. What this does is it authenticates the client to the MongoDB server. If the private key for the client certificate is also encrypted, another password or passphrase should be provided to decrypt the same.

Once you get these two files, you can no longer rely on Spring Data MongoDB auto-configuration to configure the TLS connection for you. So first things first, you need to disable Mongo auto-configuration at the application level by specifying this in the main class:

@SpringBootApplication(exclude = {MongoAutoConfiguration.class, MongoDataAutoConfiguration.class})

Mongo Configuration

Once auto-configuration is disabled, we can write a custom class annotated with @Configuration to configure the required Beans for us. Let’s take a look at this class step-by-step.

@Configuration
public class MongoConfiguration extends AbstractMongoClientConfiguration { 
    @Value("${spring.data.mongodb.uri}")
    private String mongoDbUri;
    @Value("${spring.data.mongodb.name}")
    private String mongoDbName;

    @Value("${tls.caFile.name}")
    private String caFileName;
    @Value("${tls.certificateKeyFile.name}")
    private String certificateKeyFileName;

    @Override
    public MongoClient mongoClient() { … }

    @Override
    protected String getDatabaseName() { … }
}

We have the blueprint of our class ready. We annotate it with @Configuration. Our class extends AbstractMongoClientConfiguration so instead of declaring individual methods annotated with @Bean and returning the appropriate Beans, we can override required methods from the parent class. Apart from that, we expect some values from the application.properties file – particularly, the MongoDB URI for the replica set or the single node MongoDB server, database name, the name of the CA (Certificate Authority) file, and the name of the Client Certificate Key file.

Next, we will be expanding the mongoClient() method:

@Override
public MongoClient mongoClient() {
        ConnectionString connectionString = new ConnectionString(mongoDbUri);
        SSLContext sslContext = null;
            try {
                sslContext = createSSLContext();
            } catch (Exception e) {
                LOG.error("Error creating SSLContext", e);
                return null;
            }
            SSLContext finalSslContext = sslContext;
            MongoClientSettings settings = MongoClientSettings.builder()
                    .applyConnectionString(connectionString)
                    .applyToSslSettings(builder -> {
                        builder.enabled(true);
                        builder.context(finalSslContext);
                        builder.invalidHostNameAllowed(true);
                    })
                    .build();
            MongoClient client = MongoClients.create(settings);
            return client;
}

We first initialize our ConnectionString with the URI we received from the application.properties file. Next we initialize our SSLContext with a custom createSSLContext() method. Finally, we use MongoClientSettings.Builder to configure the settings for our MongoClient, before creating it using the static create method from MongoClients.

According to the IBM documentation,

“The javax.net.ssl.SSLContext is an engine class for an implementation of a secure socket protocol. An instance of this class acts as a factory for SSL socket factories and SSL engines. An SSLContext holds all of the state information shared across all objects created under that context. For example, session state is associated with the SSLContext when it is negotiated through the handshake protocol by sockets created by socket factories provided by the context. These cached sessions can be reused and shared by other sockets created under the same context.”

This basically means that SSLContext holds application-wide global state information post-authentication, to be shared by any connections created or configured with that context. The SSL Context holds the keys, certificate chains, and root CA certificates – everything it needs to perform authentication. When we applyToSslSettings on top of our MongoClientSettings.Builder class, we are configuring the MongoClient connection to use the state stored in the SSLContext.

Lets have a look at the final piece of our puzzle – building the SSLContext.

public SSLContext createSSLContext() throws Exception {
        // root CA
        TrustManagerFactory tmf;
        ClassPathResource resource = new ClassPathResource(caFileName);
        InputStream is = resource.getInputStream();
        CertificateFactory cf = CertificateFactory.getInstance("X.509");
        X509Certificate caCert = (X509Certificate) cf.generateCertificate(is);
        tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
        ks.load(null); // You don't need the KeyStore instance to come from a file.
        ks.setCertificateEntry("caCert", caCert);
        tmf.init(ks);

        // client key
        KeyManagerFactory keyFac;
        SSLContext sslContext = null;
        try {
            resource = new ClassPathResource(certificateKeyFileName);
            is = resource.getInputStream();
            KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
            keystore.load(null); // needs to be initialised, otherwise throws exception

            @SuppressWarnings("resource")
            PEMParser pemParser = new PEMParser(new InputStreamReader(is));
            PEMKeyPair pemKeyPair = (PEMKeyPair) pemParser.readObject();
            KeyPair kp = new JcaPEMKeyConverter().getKeyPair(pemKeyPair);
            PrivateKey privateKey = kp.getPrivate();
            JcaX509CertificateConverter certConverter = new JcaX509CertificateConverter();
            X509CertificateHolder certificateHolder = (X509CertificateHolder) pemParser.readObject();
            X509Certificate certificate = certConverter.getCertificate(certificateHolder);
            keystore.setKeyEntry("alias", privateKey, "".toCharArray(), new Certificate[]{certificate});
            keyFac = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
            keyFac.init(keystore, "".toCharArray());

            sslContext = SSLContext.getInstance("TLSv1.2");
            sslContext.init(keyFac.getKeyManagers(), tmf.getTrustManagers(), null);
        } catch (Exception e) {
            LOG.error("Error creating SSL context", e);
        } finally {
            is.close();
        }
        return sslContext;
    }

A few important things to note from this method:

  1. The root CA certificate (ca.crt) is used to build the TrustManager – it is built by using the init method of the TrustManagerFactory factory class and passing an initialized keystore that is loaded with the root CA certificate.
  2. Next, the PEM-formatted certificate-key file is used to build a KeyManagerFactory. Make sure that the combined file has the Private key first, followed by the certificate. This is because we use PEMParser provided by bouncycastle to first load a KeyPair and then get the PrivateKey object. Next, the same file is parsed again to get the X.509 Certificate. Finally, the private key and certificate are set as a key entry in an initialized KeyStore.
  3. This loaded keystore is used to initialize a KeyManagerFactory.
  4. At this point, we have a TrustManagerFactory, and a KeyManagerFactory – both of which are required to create the SSL Context.
  5. We create a new instance of the SSLContext factory class and specify TLSv1.2 as our preferred protocol for establishing the connection and initialize it with the key and trust managers from respective factory instances.

And voila, you are now connecting to your MongoDB server from a Spring Boot microservice over TLS version 1.2.

Did you find this article valuable?

Support dev diaries by Jimil by becoming a sponsor. Any amount is appreciated!