diff --git a/README.md b/README.md index effa940..9568afd 100644 --- a/README.md +++ b/README.md @@ -454,12 +454,36 @@ Feign.builder() ## HMAC (Hash-based Message Authentication Code) Example -#### WIP +HMAC authentication is based on a shared secret key between the client and the server. The client computes a hash (signature) of the request data (such as HTTP method, URL, headers, or payload) using the secret key and sends it along with the request, typically in a custom header (e.g., `X-HMAC-SIGNATURE`). The server recomputes the hash to verify the integrity and authenticity of the request. + +### Feign Client Configuration (HMAC) + +To implement HMAC authentication with Feign, you can use a `RequestInterceptor` that generates the HMAC signature and adds it to the request headers. + +Example configuration: + +```java +public class HmacClientConfig { + + @Value("${spring.application.rest.client.hmac.secret}") + private String secret; + + @Bean + public RequestInterceptor hmacRequestInterceptor() { + return requestTemplate -> { + String dataToSign = requestTemplate.method() + requestTemplate.url(); + String signature = HmacUtils.hmacSha256Hex(secret, dataToSign); + requestTemplate.header("X-HMAC-SIGNATURE", signature); + }; + } + @Bean + public Logger.Level hmacLoggerLevel() {return Logger.Level.FULL;} +} +``` +In the test, the Feign client sends a request with the `X-HMAC-SIGNATURE` header, and the server verifies the signature using the shared secret. ## SAML (Security Assertion Markup Language) Example #### WIP - - \ No newline at end of file diff --git a/src/main/java/raff/stein/feignclient/client/hmac/HashBasedMessageAuthClient.java b/src/main/java/raff/stein/feignclient/client/hmac/HashBasedMessageAuthClient.java new file mode 100644 index 0000000..eaa6c0b --- /dev/null +++ b/src/main/java/raff/stein/feignclient/client/hmac/HashBasedMessageAuthClient.java @@ -0,0 +1,15 @@ +package raff.stein.feignclient.client.hmac; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import raff.stein.feignclient.client.hmac.config.HashBasedMessageAuthClientConfig; + +@FeignClient( + name = "hashBasedMessageAuthClient", + url = "${spring.application.rest.client.hmac.host}", + configuration = HashBasedMessageAuthClientConfig.class) +public interface HashBasedMessageAuthClient { + + @GetMapping("/get-data") + String getData(); +} diff --git a/src/main/java/raff/stein/feignclient/client/hmac/config/HashBasedMessageAuthClientConfig.java b/src/main/java/raff/stein/feignclient/client/hmac/config/HashBasedMessageAuthClientConfig.java new file mode 100644 index 0000000..154df53 --- /dev/null +++ b/src/main/java/raff/stein/feignclient/client/hmac/config/HashBasedMessageAuthClientConfig.java @@ -0,0 +1,34 @@ +package raff.stein.feignclient.client.hmac.config; + +import feign.Logger; +import feign.RequestInterceptor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; + +public class HashBasedMessageAuthClientConfig { + + @Value("${spring.application.rest.client.hmac.secret}") + private String secret; + + @Bean + public RequestInterceptor hmacRequestInterceptor() { + return requestTemplate -> { + try { + String data = requestTemplate.method() + requestTemplate.url(); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); + String signature = Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes())); + requestTemplate.header("X-HMAC-SIGNATURE", signature); + } catch (Exception e) { + throw new RuntimeException("Error during HMAC sign generation", e); + } + }; + } + + @Bean + public Logger.Level hashBasedMessageAuthLoggerLevel() {return Logger.Level.FULL;} +} diff --git a/src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java b/src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java index 4b6e56f..67ba180 100644 --- a/src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java +++ b/src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java @@ -57,4 +57,10 @@ public ResponseEntity getDataWithMutualTls() { return ResponseEntity.ok(responseString); } + @GetMapping("/hmac") + public ResponseEntity getDataWithHmac() { + final String responseString = feignClientSimpleService.simpleHashBasedMessageAuthClientCall(); + return ResponseEntity.ok(responseString); + } + } diff --git a/src/main/java/raff/stein/feignclient/service/FeignClientSimpleService.java b/src/main/java/raff/stein/feignclient/service/FeignClientSimpleService.java index 31eae78..a2a168d 100644 --- a/src/main/java/raff/stein/feignclient/service/FeignClientSimpleService.java +++ b/src/main/java/raff/stein/feignclient/service/FeignClientSimpleService.java @@ -6,6 +6,7 @@ import raff.stein.feignclient.client.apikey.ApiKeyClient; import raff.stein.feignclient.client.basicauth.BasicAuthClient; import raff.stein.feignclient.client.digest.DigestApacheClient; +import raff.stein.feignclient.client.hmac.HashBasedMessageAuthClient; import raff.stein.feignclient.client.jwt.JwtClient; import raff.stein.feignclient.client.mutualtls.MutualTlsClient; import raff.stein.feignclient.client.ntlm.NTLMClient; @@ -23,6 +24,7 @@ public class FeignClientSimpleService { private final JwtClient jwtClient; private final DigestApacheClient digestApacheClient; private final MutualTlsClient mutualTlsClient; + private final HashBasedMessageAuthClient hashBasedMessageAuthClient; public String simpleBasicAuthClientCall() { return basicAuthClient.getData(); @@ -54,5 +56,9 @@ public String simpleMutualTlsClientCall() { return mutualTlsClient.getData(); } + public String simpleHashBasedMessageAuthClientCall() { + return hashBasedMessageAuthClient.getData(); + } + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7668870..8b06215 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -36,4 +36,7 @@ spring: password: keystorePassword truststore: path: truststorePath - password: truststorePassword \ No newline at end of file + password: truststorePassword + hmac: + host: http://localhost:8090 + secret: hmacKey \ No newline at end of file diff --git a/src/test/java/raff/stein/feignclient/FeignClientTest.java b/src/test/java/raff/stein/feignclient/FeignClientTest.java index 554332d..e9e49f9 100644 --- a/src/test/java/raff/stein/feignclient/FeignClientTest.java +++ b/src/test/java/raff/stein/feignclient/FeignClientTest.java @@ -53,6 +53,9 @@ class FeignClientTest { // MUTUAL TLS AUTH static WireMockServer mutualTlsMockServer; + // HMAC AUTH + static WireMockServer hmacMockServer; + private static final String BASIC_AUTH_200_RESPONSE_STRING = "Basic auth response content"; private static final String OAUTH_200_RESPONSE_STRING = "OAuth2 response content"; @@ -62,6 +65,7 @@ class FeignClientTest { 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"; + private static final String HMAC_200_RESPONSE_STRING = "HMAC response content"; @@ -75,6 +79,7 @@ static void beforeAll() { setupJWTServer(); setupDigestServer(); setupMutualTlsServer(); + setupHmacServer(); } @@ -267,6 +272,28 @@ private static void setupMutualTlsServer() { .withBody(MUTUAL_TLS_200_SECOND_RESPONSE_STRING))); } + private static void setupHmacServer() { + hmacMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().port(8090)); + hmacMockServer.start(); + hmacMockServer.stubFor( + WireMock.get(WireMock.urlEqualTo("/get-data")) + .withHeader("X-HMAC-SIGNATURE", WireMock.matching(".+")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withBody(HMAC_200_RESPONSE_STRING) + )); + // Stub for missing HMAC signature + hmacMockServer.stubFor( + WireMock.get(WireMock.urlEqualTo("/get-data")) + .atPriority(1) + .withHeader("X-HMAC-SIGNATURE", WireMock.absent()) + .willReturn(WireMock.aResponse() + .withStatus(401) + .withBody("Missing HMAC signature")) + ); + } + @AfterAll @@ -291,6 +318,8 @@ private static void stopWireMockServers() { digestMockServer.stop(); if(mutualTlsMockServer.isRunning()) mutualTlsMockServer.stop(); + if(hmacMockServer.isRunning()) + hmacMockServer.stop(); } @@ -532,4 +561,34 @@ void testMutualTlsAuthClient() throws Exception { }); } + @Test + void testHmacAuthClient() throws Exception { + mockMvc.perform(MockMvcRequestBuilders + .get("/hmac")) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) + .andExpect(MockMvcResultMatchers.content().string(HMAC_200_RESPONSE_STRING)); + + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(2)) + .untilAsserted(() -> { + List events = hmacMockServer.getAllServeEvents(); + int numberOfRequestReceived = events.size(); + Assertions.assertEquals(1, numberOfRequestReceived); + + boolean requestReceived = events + .stream() + .anyMatch(event -> event.getRequest().getUrl().equals("/get-data") && + event.getRequest().getAbsoluteUrl().contains(":" + hmacMockServer.getOptions().portNumber())); + + Assertions.assertTrue(requestReceived, "No request found on hmacMockServer"); + + // check HMAC header presence and value + String hmacHeader = events.getFirst().getRequest().getHeader("X-HMAC-SIGNATURE"); + Assertions.assertNotNull(hmacHeader, "X-HMAC-SIGNATURE header is missing"); + Assertions.assertFalse(hmacHeader.isBlank(), "X-HMAC-SIGNATURE header is blank"); + }); + } + } diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index e0ec04d..6b15048 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -37,6 +37,9 @@ spring: truststore: path: src/test/resources/mutualtls/server-truststore.jks password: changeit + hmac: + host: http://localhost:8090 + secret: hmacKey logging: level: raff: