Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,6 @@ public class JwtClientConfig {
**Digest Authentication** is a challenge-response mechanism defined by ***RFC 7616***, where the client proves knowledge of a
password without sending it in plaintext. It's a more secure alternative to Basic Auth, as it protects credentials using
hashing and nonce-based mechanisms.
How it works:
How it Works:
1. **Initial Challenge:**
The client makes an unauthenticated request to the server. The server responds with a ``401 Unauthorized`` status and a
Expand Down Expand Up @@ -384,10 +383,74 @@ httpClient.execute(targetHost, request, reusableContext);
```

## Mutual TLS Authentication Example
**Mutual TLS (mTLS)** is an extension of standard TLS in which both client and server authenticate each other using certificates.
This enhances security by ensuring that not only the server is trusted by the client, but the client is also verified by
the server — enabling strong identity verification and secure communication. How it Works:
1. **TLS Handshake Initialization:**
The client initiates a secure connection over ``HTTPS``. The server responds with its certificate, as in standard ``TLS``.

#### WIP
2. **Client Certificate Request:**
Since mTLS is enabled, the server requests a certificate from the client.

3. **Client Authentication:**
The client sends its certificate (signed by a trusted CA or self-signed for testing), proving its identity.

4. **Verification:**
The server verifies the client certificate against its truststore. If valid, the ``TLS`` handshake completes.

5. **Secure Communication:**
Once mutual authentication succeeds, encrypted communication continues over the established TLS session.

### Implementation Notes:

In this example, we configure a ``WireMock`` server with:

- A keystore containing its private key and certificate.
- A truststore to validate the client certificate.
- Client authentication explicitly required via ``needClientAuth(true)``.

On the client side, we configure the ``Feign HTTP client`` with:
- A truststore to validate the server certificate.
- A keystore with the client's own certificate for mutual authentication.

### WireMock Configuration (Server Side)
```java
mutualTlsMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig()
.httpsPort(8089)
.needClientAuth(true)
.trustStorePath("server-truststore.jks")
.trustStorePassword("changeit")
.trustStoreType("PKCS12")
.keystorePath("server-keystore.jks")
.keystorePassword("changeit")
.keystoreType("PKCS12")
);
mutualTlsMockServer.start();
```

### Feign Client Configuration (Client Side)
The client HTTP configuration is built using Apache ``HttpClient``, enabling ``SS``L context with both keystore and truststore:
```java
SSLContext sslContext = SSLContexts.custom()
.loadTrustMaterial(truststorePath, truststorePassword.toCharArray())
.loadKeyMaterial(keystorePath, keystorePassword.toCharArray(), keystorePassword.toCharArray())
.build();

SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(sslContext);
CloseableHttpClient httpClient = HttpClients.custom()
.setSSLSocketFactory(socketFactory)
.build();
```
Then, this client is injected into a Feign builder:
```java
Feign.builder()
.client(new ApacheHttpClient(httpClient))
.target(MutualTlsClient.class, "https://localhost:8089");
```
### Test Scenario
- The test performs a ``GET /get-data`` request using the mTLS-enabled ``Feign client``.
- The WireMock server is configured to require and validate the client certificate.
- If mutual authentication succeeds, the server returns a ``200 OK`` and a stubbed response body.

## HMAC (Hash-based Message Authentication Code) Example

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package raff.stein.feignclient.client.mutualtls;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import raff.stein.feignclient.client.mutualtls.config.MutualTlsClientConfig;

@FeignClient(
name = "mutualTlsClient",
url = "${spring.application.rest.client.mutual-tls.host}",
configuration = MutualTlsClientConfig.class)
public interface MutualTlsClient {

@GetMapping("/get-data")
String getData();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package raff.stein.feignclient.client.mutualtls.config;

import feign.Client;
import feign.Logger;
import feign.httpclient.ApacheHttpClient;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.HttpClients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.FileInputStream;
import java.security.KeyStore;

public class MutualTlsClientConfig {

// Path to the client keystore file (PKCS#12 or JKS), which contains the client's private key and certificate.
@Value("${spring.application.rest.client.mutual-tls.ssl.keystore.path}")
private String keyStorePath;

// Password protecting the client keystore. Used to unlock and retrieve the client certificate.
@Value("${spring.application.rest.client.mutual-tls.ssl.keystore.password}")
private String keyStorePassword;

// Path to the truststore file, containing trusted CA certificate(s) for server verification.
@Value("${spring.application.rest.client.mutual-tls.ssl.truststore.path}")
private String trustStorePath;

// Password protecting the truststore. Used to load and verify server certificates.
@Value("${spring.application.rest.client.mutual-tls.ssl.truststore.password}")
private String trustStorePassword;

/**
* Defines a Feign Client that uses Apache HttpClient configured for mutual TLS.
* The SSLContext is initialized with both client identity (for client cert) and trust material (for server cert).
*/
@Bean
public Client feignClient() throws Exception {
SSLContext sslContext = buildSslContext();
HttpClient httpClient = HttpClients.custom()
.setSSLContext(sslContext)
.build();
return new ApacheHttpClient(httpClient);
}

/**
* Constructs an SSLContext initialized for mutual TLS:
* - keyStore: provides client-side certificate and private key
* - trustStore: provides CA certificates to verify the server
*/
private SSLContext buildSslContext() throws Exception {
// keystore type used for .p12 files
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(keyStorePath)) {
keyStore.load(fis, keyStorePassword.toCharArray());
}
// generate a X509 key manager (standard used in TLS/SSL)
KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509");
kmf.init(keyStore, keyStorePassword.toCharArray());
// java native keystore for .jks files
KeyStore trustStore = KeyStore.getInstance("JKS");
try (FileInputStream fis = new FileInputStream(trustStorePath)) {
trustStore.load(fis, trustStorePassword.toCharArray());
}
// X509 trust manager for server certificate chain check
TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509");
tmf.init(trustStore);
// with TLS protocol
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);

return sslContext;
}

@Bean
public Logger.Level mutualTlsClientLoggerLevel() {return Logger.Level.FULL;}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,10 @@ public ResponseEntity<String> getDataWithDigest() {
return ResponseEntity.ok(responseString);
}

@GetMapping("/mutual-tls")
public ResponseEntity<String> getDataWithMutualTls() {
final String responseString = feignClientSimpleService.simpleMutualTlsClientCall();
return ResponseEntity.ok(responseString);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import raff.stein.feignclient.client.basicauth.BasicAuthClient;
import raff.stein.feignclient.client.digest.DigestApacheClient;
import raff.stein.feignclient.client.jwt.JwtClient;
import raff.stein.feignclient.client.mutualtls.MutualTlsClient;
import raff.stein.feignclient.client.ntlm.NTLMClient;
import raff.stein.feignclient.client.oauth2.OAuth2Client;

Expand All @@ -21,6 +22,7 @@ public class FeignClientSimpleService {
private final ApiKeyClient apiKeyClient;
private final JwtClient jwtClient;
private final DigestApacheClient digestApacheClient;
private final MutualTlsClient mutualTlsClient;

public String simpleBasicAuthClientCall() {
return basicAuthClient.getData();
Expand Down Expand Up @@ -48,4 +50,9 @@ public String simpleDigestClientCall() {
return firstResult + secondResult;
}

public String simpleMutualTlsClientCall() {
return mutualTlsClient.getData();
}


}
2 changes: 1 addition & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ spring:
username: user
password: password
mutual-tls:
host: http://localhost:8089
host: https://localhost:8089
ssl:
keystore:
path: keystorePath
Expand Down
70 changes: 70 additions & 0 deletions src/test/java/raff/stein/feignclient/FeignClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ class FeignClientTest {
// DIGEST AUTH
static WireMockServer digestMockServer;

// MUTUAL TLS AUTH
static WireMockServer mutualTlsMockServer;


private static final String BASIC_AUTH_200_RESPONSE_STRING = "Basic auth response content";
private static final String OAUTH_200_RESPONSE_STRING = "OAuth2 response content";
Expand All @@ -58,6 +61,8 @@ class FeignClientTest {
private static final String JWT_200_RESPONSE_STRING = "JWT response content";
private static final String DIGEST_200_FIRST_RESPONSE_STRING = "Digest first response content";
private static final String DIGEST_200_SECOND_RESPONSE_STRING = "Digest second response content";
private static final String MUTUAL_TLS_200_SECOND_RESPONSE_STRING = "Mutual TLS response content";




Expand All @@ -69,6 +74,7 @@ static void beforeAll() {
setupAPIKeyServer();
setupJWTServer();
setupDigestServer();
setupMutualTlsServer();
}


Expand Down Expand Up @@ -240,6 +246,27 @@ private static void setupDigestServer() {
);
}

private static void setupMutualTlsServer() {
// Initialize and start a WireMock server on port 8089
mutualTlsMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig()
.httpsPort(8089)
.needClientAuth(true) // Enable Mutual TLS
.trustStorePath("src/test/resources/mutualtls/server-truststore.jks")
.trustStorePassword("changeit")
.trustStoreType("PKCS12")
.keystorePath("src/test/resources/mutualtls/server-keystore.jks")
.keystorePassword("changeit")
.keystoreType("PKCS12")
.keyManagerPassword("changeit"));
mutualTlsMockServer.start();

mutualTlsMockServer.stubFor(
WireMock.get(WireMock.urlEqualTo("/get-data"))
.willReturn(WireMock.aResponse()
.withStatus(200)
.withBody(MUTUAL_TLS_200_SECOND_RESPONSE_STRING)));
}



@AfterAll
Expand All @@ -262,6 +289,8 @@ private static void stopWireMockServers() {
jwtMockServer.stop();
if(digestMockServer.isRunning())
digestMockServer.stop();
if(mutualTlsMockServer.isRunning())
mutualTlsMockServer.stop();
}


Expand Down Expand Up @@ -462,4 +491,45 @@ void testDigestAuthClient() throws Exception {
});
}

@Test
void testMutualTlsAuthClient() throws Exception {
// === Trigger the controller endpoint that internally uses the mutual TLS client ===
mockMvc.perform(MockMvcRequestBuilders
.get("/mutual-tls"))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
.andExpect(MockMvcResultMatchers.content().string(MUTUAL_TLS_200_SECOND_RESPONSE_STRING));

Awaitility.await()
.atMost(Duration.ofSeconds(30))
.pollInterval(Duration.ofSeconds(2))
.untilAsserted(() -> {
// === Collect all requests handled by WireMock ===
List<ServeEvent> events = mutualTlsMockServer.getAllServeEvents();

// Expect exactly 1 request
int numberOfRequestReceived = events.size();

Assertions.assertEquals(1, numberOfRequestReceived);

// Check that no requests returned HTTP 401 Unauthorized (access denied)
long unauthorizedCalls = events.stream()
.filter(event -> event.getResponse().getStatus() == 401)
.count();
Assertions.assertEquals(0, unauthorizedCalls,
"No 401 Unauthorized responses expected");

boolean isHttpsEnabled = mutualTlsMockServer.getOptions().httpsSettings().port() > 0;

boolean allHttps = events.stream()
.allMatch(event -> {
// URL is only path, so no scheme here.
// Just return true if HTTPS is enabled on the server
return isHttpsEnabled;
});
Assertions.assertTrue(allHttps, "All requests should be HTTPS because the server uses HTTPS");

});
}

}
9 changes: 9 additions & 0 deletions src/test/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ spring:
host: http://localhost:8088
username: user
password: password
mutual-tls:
host: https://localhost:8089
ssl:
keystore:
path: src/test/resources/mutualtls/server-keystore.jks
password: changeit
truststore:
path: src/test/resources/mutualtls/server-truststore.jks
password: changeit
logging:
level:
raff:
Expand Down
Binary file added src/test/resources/mutualtls/server-keystore.jks
Binary file not shown.
Binary file not shown.
Binary file added src/test/resources/mutualtls/wiremock-server.crt
Binary file not shown.