Skip to content

Commit 8a85b6b

Browse files
authored
Merge pull request #7 from RaffSStein/digest-auth
Added Digest Auth implementation
2 parents 6797429 + 2a5489c commit 8a85b6b

8 files changed

Lines changed: 360 additions & 15 deletions

File tree

README.md

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ For each subsequent request to protected resources, the client includes the JWT
291291

292292
3. **Server Verification**:
293293
The receiving server verifies the JWT’s signature, expiration time, and claims to ensure the token is valid and trusted.
294-
If valid, access is granted; otherwise, a 401 Unauthorized response is returned.
294+
If valid, access is granted; otherwise, a ``401 Unauthorized`` response is returned.
295295

296296
### Feign Client Configuration (JWT)
297297
In this example, we assume that our Spring Boot application receives a request that already includes a valid ``Authorization: Bearer <token>`` header.
@@ -321,9 +321,67 @@ public class JwtClientConfig {
321321

322322
```
323323
## Digest Authentication Example
324+
**Digest Authentication** is a challenge-response mechanism defined by ***RFC 7616***, where the client proves knowledge of a
325+
password without sending it in plaintext. It's a more secure alternative to Basic Auth, as it protects credentials using
326+
hashing and nonce-based mechanisms.
327+
How it works:
328+
How it Works:
329+
1. **Initial Challenge:**
330+
The client makes an unauthenticated request to the server. The server responds with a ``401 Unauthorized`` status and a
331+
``WWW-Authenticate`` header containing the digest challenge (realm, nonce, opaque, etc.).
324332

325-
#### WIP
333+
2. **Digest Response:**
334+
The client computes a hash (digest) of the username, password, and challenge parameters, and resends the request with
335+
an ``Authorization: Digest ...`` header containing the computed response.
336+
337+
3. **Authentication Success:**
338+
The server verifies the digest response. If valid, it returns a ``200 OK`` and the requested resource.
339+
Otherwise, another ``401`` is issued.
340+
341+
### Implementation Notes:
342+
- In this example, we do not use a Feign client because Apache HttpClient provides more precise control over the
343+
Digest scheme negotiation.
344+
345+
- The client initializes a ``DigestScheme`` using the first ``401`` response and then reuses this authentication context
346+
``(HttpClientContext)`` across subsequent requests using an ``AuthCache``.
326347

348+
- This avoids redundant challenge-response handshakes after the first authenticated request.
349+
350+
### Apache HttpClient Configuration
351+
Here's the key part of the implementation (simplified):
352+
```java
353+
// Provides the credentials (username and password) for a specific AuthScope
354+
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
355+
credentialsProvider.setCredentials(
356+
new AuthScope(host, port),
357+
new UsernamePasswordCredentials(username, password)
358+
);
359+
// Register the Digest authentication scheme (needed explicitly)
360+
Lookup<AuthSchemeProvider> authSchemeRegistry = RegistryBuilder.<AuthSchemeProvider>create()
361+
.register(AuthSchemes.DIGEST, new DigestSchemeFactory())
362+
.build();
363+
// Build the HTTP client with the credentials and scheme
364+
this.httpClient = HttpClients.custom()
365+
.setDefaultCredentialsProvider(credentialsProvider)
366+
.setDefaultAuthSchemeRegistry(authSchemeRegistry)
367+
.build();
368+
```
369+
Then, a first unauthenticated request is performed explicitly to force the ``401 Unauthorized``, and the server's
370+
challenge is processed manually:
371+
```java
372+
DigestScheme digestScheme = new DigestScheme();
373+
digestScheme.processChallenge(wwwAuthHeaderFrom401);
374+
375+
AuthCache authCache = new BasicAuthCache();
376+
authCache.put(targetHost, digestScheme);
377+
378+
HttpClientContext context = HttpClientContext.create();
379+
context.setAuthCache(authCache);
380+
```
381+
Finally, this context is reused in all subsequent authenticated requests:
382+
```java
383+
httpClient.execute(targetHost, request, reusableContext);
384+
```
327385

328386
## Mutual TLS Authentication Example
329387

pom.xml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
<maven.compiler.source>23</maven.compiler.source>
2020
<maven.compiler.target>23</maven.compiler.target>
2121
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
22-
<spring-boot.version>3.4.1</spring-boot.version>
23-
<spring-boot-devtools.version>3.4.1</spring-boot-devtools.version>
24-
<spring-boot-starter-test.version>3.4.1</spring-boot-starter-test.version>
25-
<lombok.version>1.18.36</lombok.version>
26-
<jackson-core-databind.version>2.18.2</jackson-core-databind.version>
22+
<spring-boot.version>3.5.3</spring-boot.version>
23+
<spring-boot-devtools.version>3.5.3</spring-boot-devtools.version>
24+
<spring-boot-starter-test.version>3.5.3</spring-boot-starter-test.version>
25+
<lombok.version>1.18.38</lombok.version>
26+
<jackson-core-databind.version>2.19.1</jackson-core-databind.version>
2727
</properties>
2828

2929
<dependencies>
@@ -77,7 +77,7 @@
7777
<version>3.2.0</version>
7878
</dependency>
7979

80-
<!-- testcontainers probably not needed -->
80+
<!-- testcontainers for Awaitility -->
8181
<dependency>
8282
<groupId>org.testcontainers</groupId>
8383
<artifactId>junit-jupiter</artifactId>
@@ -108,7 +108,7 @@
108108
<type>pom</type>
109109
<scope>import</scope>
110110
</dependency>
111-
<!-- Test containers, probably to remove -->
111+
<!-- Test containers -->
112112
<dependency>
113113
<groupId>org.testcontainers</groupId>
114114
<artifactId>testcontainers-bom</artifactId>
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package raff.stein.feignclient.client.digest;
2+
3+
import org.apache.http.Header;
4+
import org.apache.http.HttpHost;
5+
import org.apache.http.auth.AuthSchemeProvider;
6+
import org.apache.http.auth.AuthScope;
7+
import org.apache.http.auth.MalformedChallengeException;
8+
import org.apache.http.auth.UsernamePasswordCredentials;
9+
import org.apache.http.client.AuthCache;
10+
import org.apache.http.client.CredentialsProvider;
11+
import org.apache.http.client.config.AuthSchemes;
12+
import org.apache.http.client.methods.CloseableHttpResponse;
13+
import org.apache.http.client.methods.HttpGet;
14+
import org.apache.http.client.protocol.HttpClientContext;
15+
import org.apache.http.config.Lookup;
16+
import org.apache.http.config.RegistryBuilder;
17+
import org.apache.http.impl.auth.DigestScheme;
18+
import org.apache.http.impl.auth.DigestSchemeFactory;
19+
import org.apache.http.impl.client.BasicAuthCache;
20+
import org.apache.http.impl.client.BasicCredentialsProvider;
21+
import org.apache.http.impl.client.CloseableHttpClient;
22+
import org.apache.http.impl.client.HttpClients;
23+
import org.apache.http.util.EntityUtils;
24+
import org.springframework.beans.factory.annotation.Value;
25+
import org.springframework.stereotype.Component;
26+
27+
import java.io.IOException;
28+
import java.net.URI;
29+
import java.net.URISyntaxException;
30+
31+
@Component
32+
public class DigestApacheClient {
33+
34+
private final CloseableHttpClient httpClient;
35+
private final String baseUrl;
36+
private final HttpHost targetHost;
37+
private final HttpClientContext reusableContext;
38+
39+
/**
40+
* Constructor that sets up the Apache HTTP client to support Digest Authentication.
41+
* This includes:
42+
* - Extracting the host and port from the base URL.
43+
* - Configuring the CredentialsProvider with username and password.
44+
* - Registering the Digest authentication scheme.
45+
* - Pre-authenticating once to cache the Digest scheme and reuse it for future requests.
46+
*/
47+
public DigestApacheClient(
48+
@Value("${spring.application.rest.client.digest-auth.host}") String baseUrl,
49+
@Value("${spring.application.rest.client.digest-auth.username}") String username,
50+
@Value("${spring.application.rest.client.digest-auth.password}") String password) {
51+
52+
this.baseUrl = baseUrl;
53+
// post and host extraction from URI
54+
URI uri;
55+
try {
56+
uri = new URI(baseUrl);
57+
} catch (URISyntaxException e) {
58+
throw new IllegalArgumentException("Invalid base URL: " + baseUrl, e);
59+
}
60+
61+
String host = uri.getHost();
62+
int port = (uri.getPort() != -1) ? uri.getPort() : ("https".equals(uri.getScheme()) ? 443 : 80);
63+
this.targetHost = new HttpHost(host, port, uri.getScheme());
64+
// Provides the credentials (username and password) for a specific AuthScope
65+
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
66+
credentialsProvider.setCredentials(
67+
new AuthScope(host, port),
68+
new UsernamePasswordCredentials(username, password)
69+
);
70+
// Register the Digest authentication scheme (needed explicitly)
71+
Lookup<AuthSchemeProvider> authSchemeRegistry = RegistryBuilder.<AuthSchemeProvider>create()
72+
.register(AuthSchemes.DIGEST, new DigestSchemeFactory())
73+
.build();
74+
// Build the HTTP client with the credentials and scheme
75+
this.httpClient = HttpClients.custom()
76+
.setDefaultCredentialsProvider(credentialsProvider)
77+
.setDefaultAuthSchemeRegistry(authSchemeRegistry)
78+
.build();
79+
80+
// Force an initial 401 challenge to extract the DigestScheme and cache it
81+
this.reusableContext = preAuthenticate();
82+
}
83+
84+
/**
85+
* Performs an initial HTTP call (without credentials) to force a 401 response.
86+
* This response contains the Digest challenge in the WWW-Authenticate header.
87+
* The DigestScheme is extracted and stored in an AuthCache, which is later reused
88+
* to avoid repeating the 401 round-trip on each request.
89+
*
90+
* @return HttpClientContext containing the cached DigestScheme
91+
*/
92+
private HttpClientContext preAuthenticate() {
93+
// Creates a no-credential client in order to force the 401
94+
try (CloseableHttpClient noCredentialClient = HttpClients.createDefault()) {
95+
// unauthorized request to a specified endpoint
96+
HttpGet unauthorizedRequest = new HttpGet(baseUrl + "/auth");
97+
// empty context
98+
HttpClientContext context = HttpClientContext.create();
99+
try (CloseableHttpResponse response = noCredentialClient.execute(targetHost, unauthorizedRequest, context)) {
100+
if (response.getStatusLine().getStatusCode() == 401) {
101+
Header wwwAuth = response.getFirstHeader("WWW-Authenticate");
102+
if (wwwAuth != null && wwwAuth.getValue().startsWith(AuthSchemes.DIGEST)) {
103+
// Parse the WWW-Authenticate challenge into a DigestScheme
104+
DigestScheme digestScheme = new DigestScheme();
105+
digestScheme.processChallenge(wwwAuth);
106+
// Cache the DigestScheme so it can be reused automatically
107+
AuthCache authCache = new BasicAuthCache();
108+
authCache.put(targetHost, digestScheme);
109+
// insert it into the context, so it can be reused from the next api calls
110+
HttpClientContext authContext = HttpClientContext.create();
111+
authContext.setAuthCache(authCache);
112+
return authContext;
113+
}
114+
} else {
115+
throw new RuntimeException("Expected 401 but got " + response.getStatusLine().getStatusCode());
116+
}
117+
}
118+
} catch (IOException | MalformedChallengeException e) {
119+
throw new RuntimeException("Failed to initialize Digest auth context", e);
120+
}
121+
throw new RuntimeException("Digest challenge was not received from the server");
122+
}
123+
124+
125+
public String getFirstContent() {
126+
HttpGet request = new HttpGet(baseUrl + "/get-first-content");
127+
128+
try (CloseableHttpResponse response = httpClient.execute(targetHost, request, reusableContext)) {
129+
int status = response.getStatusLine().getStatusCode();
130+
if (status == 200) {
131+
return EntityUtils.toString(response.getEntity());
132+
} else {
133+
throw new RuntimeException("HTTP error: " + status);
134+
}
135+
} catch (IOException e) {
136+
throw new RuntimeException("Error during HTTP Digest call: " + e.getMessage(), e);
137+
}
138+
}
139+
140+
public String getSecondContent() {
141+
HttpGet request = new HttpGet(baseUrl + "/get-second-content");
142+
143+
try (CloseableHttpResponse response = httpClient.execute(targetHost, request, reusableContext)) {
144+
int status = response.getStatusLine().getStatusCode();
145+
if (status == 200) {
146+
return EntityUtils.toString(response.getEntity());
147+
} else {
148+
throw new RuntimeException("HTTP error: " + status);
149+
}
150+
} catch (IOException e) {
151+
throw new RuntimeException("Error during HTTP Digest call: " + e.getMessage(), e);
152+
}
153+
}
154+
155+
156+
}

src/main/java/raff/stein/feignclient/controller/FeignClientSimpleController.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,10 @@ public ResponseEntity<String> getDataWithJwt(@RequestHeader("Authorization") Str
4545
return ResponseEntity.ok(responseString);
4646
}
4747

48+
@GetMapping("/digest")
49+
public ResponseEntity<String> getDataWithDigest() {
50+
final String responseString = feignClientSimpleService.simpleDigestClientCall();
51+
return ResponseEntity.ok(responseString);
52+
}
53+
4854
}

src/main/java/raff/stein/feignclient/service/FeignClientSimpleService.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.springframework.stereotype.Service;
66
import raff.stein.feignclient.client.apikey.ApiKeyClient;
77
import raff.stein.feignclient.client.basicauth.BasicAuthClient;
8+
import raff.stein.feignclient.client.digest.DigestApacheClient;
89
import raff.stein.feignclient.client.jwt.JwtClient;
910
import raff.stein.feignclient.client.ntlm.NTLMClient;
1011
import raff.stein.feignclient.client.oauth2.OAuth2Client;
@@ -19,6 +20,7 @@ public class FeignClientSimpleService {
1920
private final NTLMClient ntlmClient;
2021
private final ApiKeyClient apiKeyClient;
2122
private final JwtClient jwtClient;
23+
private final DigestApacheClient digestApacheClient;
2224

2325
public String simpleBasicAuthClientCall() {
2426
return basicAuthClient.getData();
@@ -40,4 +42,10 @@ public String simpleJwtClientCall() {
4042
return jwtClient.getData();
4143
}
4244

45+
public String simpleDigestClientCall() {
46+
String firstResult = digestApacheClient.getFirstContent();
47+
String secondResult = digestApacheClient.getSecondContent();
48+
return firstResult + secondResult;
49+
}
50+
4351
}

src/main/resources/application.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,8 @@ spring:
2323
host: http://localhost:8086
2424
key: key
2525
jwt:
26-
host: http://localhost:8087
26+
host: http://localhost:8087
27+
digest-auth:
28+
host: http://localhost:8088
29+
username: user
30+
password: password

0 commit comments

Comments
 (0)