diff --git a/README.md b/README.md index ff66cbb..065811b 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ For each subsequent request to protected resources, the client includes the JWT 3. **Server Verification**: The receiving server verifies the JWT’s signature, expiration time, and claims to ensure the token is valid and trusted. -If valid, access is granted; otherwise, a 401 Unauthorized response is returned. +If valid, access is granted; otherwise, a ``401 Unauthorized`` response is returned. ### Feign Client Configuration (JWT) In this example, we assume that our Spring Boot application receives a request that already includes a valid ``Authorization: Bearer `` header. @@ -321,9 +321,67 @@ public class JwtClientConfig { ``` ## Digest Authentication Example +**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 +``WWW-Authenticate`` header containing the digest challenge (realm, nonce, opaque, etc.). -#### WIP +2. **Digest Response:** +The client computes a hash (digest) of the username, password, and challenge parameters, and resends the request with +an ``Authorization: Digest ...`` header containing the computed response. + +3. **Authentication Success:** +The server verifies the digest response. If valid, it returns a ``200 OK`` and the requested resource. +Otherwise, another ``401`` is issued. + +### Implementation Notes: +- In this example, we do not use a Feign client because Apache HttpClient provides more precise control over the +Digest scheme negotiation. + +- The client initializes a ``DigestScheme`` using the first ``401`` response and then reuses this authentication context +``(HttpClientContext)`` across subsequent requests using an ``AuthCache``. +- This avoids redundant challenge-response handshakes after the first authenticated request. + +### Apache HttpClient Configuration +Here's the key part of the implementation (simplified): +```java +// Provides the credentials (username and password) for a specific AuthScope + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + new AuthScope(host, port), + new UsernamePasswordCredentials(username, password) + ); + // Register the Digest authentication scheme (needed explicitly) + Lookup authSchemeRegistry = RegistryBuilder.create() + .register(AuthSchemes.DIGEST, new DigestSchemeFactory()) + .build(); + // Build the HTTP client with the credentials and scheme + this.httpClient = HttpClients.custom() + .setDefaultCredentialsProvider(credentialsProvider) + .setDefaultAuthSchemeRegistry(authSchemeRegistry) + .build(); +``` +Then, a first unauthenticated request is performed explicitly to force the ``401 Unauthorized``, and the server's +challenge is processed manually: +```java +DigestScheme digestScheme = new DigestScheme(); +digestScheme.processChallenge(wwwAuthHeaderFrom401); + +AuthCache authCache = new BasicAuthCache(); +authCache.put(targetHost, digestScheme); + +HttpClientContext context = HttpClientContext.create(); +context.setAuthCache(authCache); +``` +Finally, this context is reused in all subsequent authenticated requests: +```java +httpClient.execute(targetHost, request, reusableContext); +``` ## Mutual TLS Authentication Example diff --git a/pom.xml b/pom.xml index aeb28f6..15cf453 100644 --- a/pom.xml +++ b/pom.xml @@ -19,11 +19,11 @@ 23 23 UTF-8 - 3.4.1 - 3.4.1 - 3.4.1 - 1.18.36 - 2.18.2 + 3.5.3 + 3.5.3 + 3.5.3 + 1.18.38 + 2.19.1 @@ -77,7 +77,7 @@ 3.2.0 - + org.testcontainers junit-jupiter @@ -108,7 +108,7 @@ pom import - + org.testcontainers testcontainers-bom diff --git a/src/main/java/raff/stein/feignclient/client/digest/DigestApacheClient.java b/src/main/java/raff/stein/feignclient/client/digest/DigestApacheClient.java new file mode 100644 index 0000000..31b7b9c --- /dev/null +++ b/src/main/java/raff/stein/feignclient/client/digest/DigestApacheClient.java @@ -0,0 +1,156 @@ +package raff.stein.feignclient.client.digest; + +import org.apache.http.Header; +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthSchemeProvider; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.MalformedChallengeException; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.AuthCache; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.AuthSchemes; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.config.Lookup; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.impl.auth.DigestScheme; +import org.apache.http.impl.auth.DigestSchemeFactory; +import org.apache.http.impl.client.BasicAuthCache; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; + +@Component +public class DigestApacheClient { + + private final CloseableHttpClient httpClient; + private final String baseUrl; + private final HttpHost targetHost; + private final HttpClientContext reusableContext; + + /** + * Constructor that sets up the Apache HTTP client to support Digest Authentication. + * This includes: + * - Extracting the host and port from the base URL. + * - Configuring the CredentialsProvider with username and password. + * - Registering the Digest authentication scheme. + * - Pre-authenticating once to cache the Digest scheme and reuse it for future requests. + */ + public DigestApacheClient( + @Value("${spring.application.rest.client.digest-auth.host}") String baseUrl, + @Value("${spring.application.rest.client.digest-auth.username}") String username, + @Value("${spring.application.rest.client.digest-auth.password}") String password) { + + this.baseUrl = baseUrl; + // post and host extraction from URI + URI uri; + try { + uri = new URI(baseUrl); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid base URL: " + baseUrl, e); + } + + String host = uri.getHost(); + int port = (uri.getPort() != -1) ? uri.getPort() : ("https".equals(uri.getScheme()) ? 443 : 80); + this.targetHost = new HttpHost(host, port, uri.getScheme()); + // Provides the credentials (username and password) for a specific AuthScope + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + new AuthScope(host, port), + new UsernamePasswordCredentials(username, password) + ); + // Register the Digest authentication scheme (needed explicitly) + Lookup authSchemeRegistry = RegistryBuilder.create() + .register(AuthSchemes.DIGEST, new DigestSchemeFactory()) + .build(); + // Build the HTTP client with the credentials and scheme + this.httpClient = HttpClients.custom() + .setDefaultCredentialsProvider(credentialsProvider) + .setDefaultAuthSchemeRegistry(authSchemeRegistry) + .build(); + + // Force an initial 401 challenge to extract the DigestScheme and cache it + this.reusableContext = preAuthenticate(); + } + + /** + * Performs an initial HTTP call (without credentials) to force a 401 response. + * This response contains the Digest challenge in the WWW-Authenticate header. + * The DigestScheme is extracted and stored in an AuthCache, which is later reused + * to avoid repeating the 401 round-trip on each request. + * + * @return HttpClientContext containing the cached DigestScheme + */ + private HttpClientContext preAuthenticate() { + // Creates a no-credential client in order to force the 401 + try (CloseableHttpClient noCredentialClient = HttpClients.createDefault()) { + // unauthorized request to a specified endpoint + HttpGet unauthorizedRequest = new HttpGet(baseUrl + "/auth"); + // empty context + HttpClientContext context = HttpClientContext.create(); + try (CloseableHttpResponse response = noCredentialClient.execute(targetHost, unauthorizedRequest, context)) { + if (response.getStatusLine().getStatusCode() == 401) { + Header wwwAuth = response.getFirstHeader("WWW-Authenticate"); + if (wwwAuth != null && wwwAuth.getValue().startsWith(AuthSchemes.DIGEST)) { + // Parse the WWW-Authenticate challenge into a DigestScheme + DigestScheme digestScheme = new DigestScheme(); + digestScheme.processChallenge(wwwAuth); + // Cache the DigestScheme so it can be reused automatically + AuthCache authCache = new BasicAuthCache(); + authCache.put(targetHost, digestScheme); + // insert it into the context, so it can be reused from the next api calls + HttpClientContext authContext = HttpClientContext.create(); + authContext.setAuthCache(authCache); + return authContext; + } + } else { + throw new RuntimeException("Expected 401 but got " + response.getStatusLine().getStatusCode()); + } + } + } catch (IOException | MalformedChallengeException e) { + throw new RuntimeException("Failed to initialize Digest auth context", e); + } + throw new RuntimeException("Digest challenge was not received from the server"); + } + + + public String getFirstContent() { + HttpGet request = new HttpGet(baseUrl + "/get-first-content"); + + try (CloseableHttpResponse response = httpClient.execute(targetHost, request, reusableContext)) { + int status = response.getStatusLine().getStatusCode(); + if (status == 200) { + return EntityUtils.toString(response.getEntity()); + } else { + throw new RuntimeException("HTTP error: " + status); + } + } catch (IOException e) { + throw new RuntimeException("Error during HTTP Digest call: " + e.getMessage(), e); + } + } + + public String getSecondContent() { + HttpGet request = new HttpGet(baseUrl + "/get-second-content"); + + try (CloseableHttpResponse response = httpClient.execute(targetHost, request, reusableContext)) { + int status = response.getStatusLine().getStatusCode(); + if (status == 200) { + return EntityUtils.toString(response.getEntity()); + } else { + throw new RuntimeException("HTTP error: " + status); + } + } catch (IOException e) { + throw new RuntimeException("Error during HTTP Digest call: " + e.getMessage(), e); + } + } + + +} diff --git a/src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java b/src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java index 054903a..882611b 100644 --- a/src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java +++ b/src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java @@ -45,4 +45,10 @@ public ResponseEntity getDataWithJwt(@RequestHeader("Authorization") Str return ResponseEntity.ok(responseString); } + @GetMapping("/digest") + public ResponseEntity getDataWithDigest() { + final String responseString = feignClientSimpleService.simpleDigestClientCall(); + 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 5307cfb..9ff3f37 100644 --- a/src/main/java/raff/stein/feignclient/service/FeignClientSimpleService.java +++ b/src/main/java/raff/stein/feignclient/service/FeignClientSimpleService.java @@ -5,6 +5,7 @@ import org.springframework.stereotype.Service; 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.jwt.JwtClient; import raff.stein.feignclient.client.ntlm.NTLMClient; import raff.stein.feignclient.client.oauth2.OAuth2Client; @@ -19,6 +20,7 @@ public class FeignClientSimpleService { private final NTLMClient ntlmClient; private final ApiKeyClient apiKeyClient; private final JwtClient jwtClient; + private final DigestApacheClient digestApacheClient; public String simpleBasicAuthClientCall() { return basicAuthClient.getData(); @@ -40,4 +42,10 @@ public String simpleJwtClientCall() { return jwtClient.getData(); } + public String simpleDigestClientCall() { + String firstResult = digestApacheClient.getFirstContent(); + String secondResult = digestApacheClient.getSecondContent(); + return firstResult + secondResult; + } + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e7576b8..615594b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -23,4 +23,8 @@ spring: host: http://localhost:8086 key: key jwt: - host: http://localhost:8087 \ No newline at end of file + host: http://localhost:8087 + digest-auth: + host: http://localhost:8088 + username: user + password: password \ 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 caf8d9a..96cb7a1 100644 --- a/src/test/java/raff/stein/feignclient/FeignClientTest.java +++ b/src/test/java/raff/stein/feignclient/FeignClientTest.java @@ -23,7 +23,7 @@ @SpringBootTest @AutoConfigureMockMvc(addFilters = false) -public class FeignClientTest { +class FeignClientTest { @Autowired private MockMvc mockMvc; @@ -47,12 +47,17 @@ public class FeignClientTest { // JWT static WireMockServer jwtMockServer; + // DIGEST AUTH + static WireMockServer digestMockServer; + private static final String BASIC_AUTH_200_RESPONSE_STRING = "Basic auth response content"; private static final String OAUTH_200_RESPONSE_STRING = "OAuth2 response content"; private static final String NTLM_200_RESPONSE_STRING = "NTLM response content"; private static final String API_KEY_200_RESPONSE_STRING = "API KEY response content"; 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"; @@ -63,8 +68,10 @@ static void beforeAll() { setupNTLMServer(); setupAPIKeyServer(); setupJWTServer(); + setupDigestServer(); } + private static void setupBasicAuthServers() { setupBasicAuthContentMockServer(); } @@ -185,6 +192,56 @@ private static void setupJWTServer() { .withBody(JWT_200_RESPONSE_STRING))); } + private static void setupDigestServer() { + // Initialize and start a WireMock server on port 8088 + digestMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().port(8088)); + digestMockServer.start(); + // === STEP 1: Simulate an initial Digest Authentication challenge === + digestMockServer.stubFor( + WireMock.get(WireMock.urlEqualTo("/auth")) + // Define a WireMock scenario to manage stateful mocking + .inScenario("Digest Auth") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(WireMock.aResponse() + // Force client to receive 401 Unauthorized + .withStatus(401) + // Digest challenge header, including realm, nonce, and opaque + .withHeader("WWW-Authenticate ", + "Digest realm=\"localhost\", " + + "qop=\"auth\", " + + "nonce=\"testnonce\", " + + "opaque=\"testopaque\"") + .withBody("Unauthorized")) + // Move scenario into authenticated state after this 401 is served + .willSetStateTo("CHALLENGE_SENT") + ); + // === STEP 2: Accept the first authenticated request === + digestMockServer.stubFor( + WireMock.get(WireMock.urlEqualTo("/get-first-content")) + .inScenario("Digest Auth") + // Only allow after challenge sent + .whenScenarioStateIs("CHALLENGE_SENT") + // Must contain Digest Authorization header + .withHeader("Authorization", WireMock.matching("Digest\\s+.*")) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withBody(DIGEST_200_FIRST_RESPONSE_STRING)) + ); + // === STEP 3: Accept the second authenticated request === + digestMockServer.stubFor( + WireMock.get(WireMock.urlEqualTo("/get-second-content")) + .inScenario("Digest Auth") + // Still in authenticated state + .whenScenarioStateIs("CHALLENGE_SENT") + .withHeader("Authorization", WireMock.matching("Digest\\s+.*")) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withBody(DIGEST_200_SECOND_RESPONSE_STRING)) + ); + } + + + @AfterAll static void afterAll() { stopWireMockServers(); @@ -203,6 +260,8 @@ private static void stopWireMockServers() { apiKeyMockServer.stop(); if(jwtMockServer.isRunning()) jwtMockServer.stop(); + if(digestMockServer.isRunning()) + digestMockServer.stop(); } @@ -351,4 +410,56 @@ void testJWTClient() throws Exception { }); } + + @Test + void testDigestAuthClient() throws Exception { + // === Trigger the controller endpoint that internally uses the DigestApacheClient === + mockMvc.perform(MockMvcRequestBuilders + .get("/digest")) + .andDo(MockMvcResultHandlers.print()) + // Expect overall response to be successful (2xx) + .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) + // Expect response body to match concatenation of two successful responses + .andExpect(MockMvcResultMatchers.content().string(DIGEST_200_FIRST_RESPONSE_STRING + DIGEST_200_SECOND_RESPONSE_STRING)); + + // === Awaitility block ensures async events are completed (e.g., all WireMock events) === + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(2)) + .untilAsserted(() -> { + // === Collect all requests handled by WireMock === + List events = digestMockServer.getAllServeEvents(); + + // Expect exactly 3 requests: 1 to /auth, 2 to protected endpoints + int numberOfRequestReceived = events.size(); + + Assertions.assertEquals(3, numberOfRequestReceived); + + // === Assert only one call to /auth for the initial digest challenge === + long authCalls = events.stream() + .filter(event -> event.getRequest().getUrl().equals("/auth")) + .count(); + + Assertions.assertEquals(1, authCalls, "The /auth endpoint should be called exactly once"); + + // === Assert that no 401 errors occurred during /get-first-content and /get-second-content === + long unauthorizedCalls = events.stream() + .filter(event -> (event.getRequest().getUrl().equals("/get-first-content") || + event.getRequest().getUrl().equals("/get-second-content")) && + event.getResponse().getStatus() == 401) + .count(); + + Assertions.assertEquals(0, unauthorizedCalls, "No 401 responses expected on subsequent calls"); + + // === Ensure all protected calls include the Authorization header === + boolean allHaveAuthHeader = events.stream() + .filter(event -> event.getRequest().getUrl().equals("/get-first-content") || + event.getRequest().getUrl().equals("/get-second-content")) + .allMatch(event -> event.getRequest().getHeaders().getHeader("Authorization").isPresent()); + + Assertions.assertTrue(allHaveAuthHeader, "All subsequent requests must include the Authorization header"); + + }); + } + } diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 512e75f..e476f77 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -24,13 +24,15 @@ spring: key: key jwt: host: http://localhost:8087 + digest-auth: + host: http://localhost:8088 + username: user + password: password logging: level: raff: stein: - feignclient: - DEBUG + feignclient: DEBUG org: apache: - http: - TRACE + http: TRACE