|
| 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 | +} |
0 commit comments