View Javadoc
1   /*
2    * ====================================================================
3    * Licensed to the Apache Software Foundation (ASF) under one
4    * or more contributor license agreements.  See the NOTICE file
5    * distributed with this work for additional information
6    * regarding copyright ownership.  The ASF licenses this file
7    * to you under the Apache License, Version 2.0 (the
8    * "License"); you may not use this file except in compliance
9    * with the License.  You may obtain a copy of the License at
10   *
11   *   http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing,
14   * software distributed under the License is distributed on an
15   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16   * KIND, either express or implied.  See the License for the
17   * specific language governing permissions and limitations
18   * under the License.
19   * ====================================================================
20   *
21   * This software consists of voluntary contributions made by many
22   * individuals on behalf of the Apache Software Foundation.  For more
23   * information on the Apache Software Foundation, please see
24   * <http://www.apache.org/>.
25   *
26   */
27  
28  package org.apache.hc.client5.http.impl.auth;
29  
30  import java.util.HashMap;
31  import java.util.LinkedList;
32  import java.util.List;
33  import java.util.Locale;
34  import java.util.Map;
35  import java.util.Queue;
36  
37  import org.apache.hc.client5.http.AuthenticationStrategy;
38  import org.apache.hc.client5.http.auth.AuthCache;
39  import org.apache.hc.client5.http.auth.AuthChallenge;
40  import org.apache.hc.client5.http.auth.AuthExchange;
41  import org.apache.hc.client5.http.auth.AuthScheme;
42  import org.apache.hc.client5.http.auth.AuthStateCacheable;
43  import org.apache.hc.client5.http.auth.AuthenticationException;
44  import org.apache.hc.client5.http.auth.ChallengeType;
45  import org.apache.hc.client5.http.auth.CredentialsProvider;
46  import org.apache.hc.client5.http.auth.MalformedChallengeException;
47  import org.apache.hc.client5.http.protocol.HttpClientContext;
48  import org.apache.hc.core5.annotation.Contract;
49  import org.apache.hc.core5.annotation.Internal;
50  import org.apache.hc.core5.annotation.ThreadingBehavior;
51  import org.apache.hc.core5.http.FormattedHeader;
52  import org.apache.hc.core5.http.Header;
53  import org.apache.hc.core5.http.HttpHeaders;
54  import org.apache.hc.core5.http.HttpHost;
55  import org.apache.hc.core5.http.HttpRequest;
56  import org.apache.hc.core5.http.HttpResponse;
57  import org.apache.hc.core5.http.HttpStatus;
58  import org.apache.hc.core5.http.ParseException;
59  import org.apache.hc.core5.http.message.BasicHeader;
60  import org.apache.hc.core5.http.message.ParserCursor;
61  import org.apache.hc.core5.http.protocol.HttpContext;
62  import org.apache.hc.core5.util.Asserts;
63  import org.apache.hc.core5.util.CharArrayBuffer;
64  import org.slf4j.Logger;
65  import org.slf4j.LoggerFactory;
66  
67  /**
68   * Utility class that implements commons aspects of the client side HTTP authentication.
69   *
70   * @since 4.3
71   */
72  @Contract(threading = ThreadingBehavior.STATELESS)
73  public final class HttpAuthenticator {
74  
75      private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(HttpAuthenticator.class);
76  
77      private final Logger log;
78      private final AuthChallengeParser parser;
79  
80      @Internal
81      public HttpAuthenticator(final Logger log) {
82          super();
83          this.log = log != null ? log : DEFAULT_LOGGER;
84          this.parser = new AuthChallengeParser();
85      }
86  
87      public HttpAuthenticator() {
88          this(null);
89      }
90  
91      /**
92       * Determines whether the given response represents an authentication challenge.
93       *
94       * @param host the hostname of the opposite endpoint.
95       * @param challengeType the challenge type (target or proxy).
96       * @param response the response message head.
97       * @param authExchange the current authentication exchange state.
98       * @param context the current execution context.
99       * @return {@code true} if the response message represents an authentication challenge,
100      *   {@code false} otherwise.
101      */
102     public boolean isChallenged(
103             final HttpHost host,
104             final ChallengeType challengeType,
105             final HttpResponse response,
106             final AuthExchange authExchange,
107             final HttpContext context) {
108         final int challengeCode;
109         switch (challengeType) {
110             case TARGET:
111                 challengeCode = HttpStatus.SC_UNAUTHORIZED;
112                 break;
113             case PROXY:
114                 challengeCode = HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED;
115                 break;
116             default:
117                 throw new IllegalStateException("Unexpected challenge type: " + challengeType);
118         }
119 
120         final HttpClientContext clientContext = HttpClientContext.adapt(context);
121         final String exchangeId = clientContext.getExchangeId();
122 
123         if (response.getCode() == challengeCode) {
124             if (log.isDebugEnabled()) {
125                 log.debug("{} Authentication required", exchangeId);
126             }
127             if (authExchange.getState() == AuthExchange.State.SUCCESS) {
128                 clearCache(host, clientContext);
129             }
130             return true;
131         }
132         switch (authExchange.getState()) {
133         case CHALLENGED:
134         case HANDSHAKE:
135             if (log.isDebugEnabled()) {
136                 log.debug("{} Authentication succeeded", exchangeId);
137             }
138             authExchange.setState(AuthExchange.State.SUCCESS);
139             updateCache(host, authExchange.getAuthScheme(), clientContext);
140             break;
141         case SUCCESS:
142             break;
143         default:
144             authExchange.setState(AuthExchange.State.UNCHALLENGED);
145         }
146         return false;
147     }
148 
149     /**
150      * Updates the {@link AuthExchange} state based on the challenge presented in the response message
151      * using the given {@link AuthenticationStrategy}.
152      *
153      * @param host the hostname of the opposite endpoint.
154      * @param challengeType the challenge type (target or proxy).
155      * @param response the response message head.
156      * @param authStrategy the authentication strategy.
157      * @param authExchange the current authentication exchange state.
158      * @param context the current execution context.
159      * @return {@code true} if the authentication state has been updated,
160      *   {@code false} if unchanged.
161      */
162     public boolean updateAuthState(
163             final HttpHost host,
164             final ChallengeType challengeType,
165             final HttpResponse response,
166             final AuthenticationStrategy authStrategy,
167             final AuthExchange authExchange,
168             final HttpContext context) {
169 
170         final HttpClientContext clientContext = HttpClientContext.adapt(context);
171         final String exchangeId = clientContext.getExchangeId();
172 
173         if (log.isDebugEnabled()) {
174             log.debug("{} {} requested authentication", exchangeId, host.toHostString());
175         }
176 
177         final Header[] headers = response.getHeaders(
178                 challengeType == ChallengeType.PROXY ? HttpHeaders.PROXY_AUTHENTICATE : HttpHeaders.WWW_AUTHENTICATE);
179         final Map<String, AuthChallenge> challengeMap = new HashMap<>();
180         for (final Header header: headers) {
181             final CharArrayBuffer buffer;
182             final int pos;
183             if (header instanceof FormattedHeader) {
184                 buffer = ((FormattedHeader) header).getBuffer();
185                 pos = ((FormattedHeader) header).getValuePos();
186             } else {
187                 final String s = header.getValue();
188                 if (s == null) {
189                     continue;
190                 }
191                 buffer = new CharArrayBuffer(s.length());
192                 buffer.append(s);
193                 pos = 0;
194             }
195             final ParserCursor cursor = new ParserCursor(pos, buffer.length());
196             final List<AuthChallenge> authChallenges;
197             try {
198                 authChallenges = parser.parse(challengeType, buffer, cursor);
199             } catch (final ParseException ex) {
200                 if (log.isWarnEnabled()) {
201                     log.warn("{} Malformed challenge: {}", exchangeId, header.getValue());
202                 }
203                 continue;
204             }
205             for (final AuthChallenge authChallenge: authChallenges) {
206                 final String schemeName = authChallenge.getSchemeName().toLowerCase(Locale.ROOT);
207                 if (!challengeMap.containsKey(schemeName)) {
208                     challengeMap.put(schemeName, authChallenge);
209                 }
210             }
211         }
212         if (challengeMap.isEmpty()) {
213             if (log.isDebugEnabled()) {
214                 log.debug("{} Response contains no valid authentication challenges", exchangeId);
215             }
216             clearCache(host, clientContext);
217             authExchange.reset();
218             return false;
219         }
220 
221         switch (authExchange.getState()) {
222             case FAILURE:
223                 return false;
224             case SUCCESS:
225                 authExchange.reset();
226                 break;
227             case CHALLENGED:
228             case HANDSHAKE:
229                 Asserts.notNull(authExchange.getAuthScheme(), "AuthScheme");
230             case UNCHALLENGED:
231                 final AuthScheme authScheme = authExchange.getAuthScheme();
232                 if (authScheme != null) {
233                     final String schemeName = authScheme.getName();
234                     final AuthChallenge challenge = challengeMap.get(schemeName.toLowerCase(Locale.ROOT));
235                     if (challenge != null) {
236                         if (log.isDebugEnabled()) {
237                             log.debug("{} Authorization challenge processed", exchangeId);
238                         }
239                         try {
240                             authScheme.processChallenge(challenge, context);
241                         } catch (final MalformedChallengeException ex) {
242                             if (log.isWarnEnabled()) {
243                                 log.warn("{} {}", exchangeId, ex.getMessage());
244                             }
245                             clearCache(host, clientContext);
246                             authExchange.reset();
247                             return false;
248                         }
249                         if (authScheme.isChallengeComplete()) {
250                             if (log.isDebugEnabled()) {
251                                 log.debug("{} Authentication failed", exchangeId);
252                             }
253                             clearCache(host, clientContext);
254                             authExchange.reset();
255                             authExchange.setState(AuthExchange.State.FAILURE);
256                             return false;
257                         }
258                         authExchange.setState(AuthExchange.State.HANDSHAKE);
259                         return true;
260                     }
261                     authExchange.reset();
262                     // Retry authentication with a different scheme
263                 }
264         }
265 
266         final List<AuthScheme> preferredSchemes = authStrategy.select(challengeType, challengeMap, context);
267         final CredentialsProvider credsProvider = clientContext.getCredentialsProvider();
268         if (credsProvider == null) {
269             if (log.isDebugEnabled()) {
270                 log.debug("{} Credentials provider not set in the context", exchangeId);
271             }
272             return false;
273         }
274 
275         final Queue<AuthScheme> authOptions = new LinkedList<>();
276         if (log.isDebugEnabled()) {
277             log.debug("{} Selecting authentication options", exchangeId);
278         }
279         for (final AuthScheme authScheme: preferredSchemes) {
280             try {
281                 final String schemeName = authScheme.getName();
282                 final AuthChallenge challenge = challengeMap.get(schemeName.toLowerCase(Locale.ROOT));
283                 authScheme.processChallenge(challenge, context);
284                 if (authScheme.isResponseReady(host, credsProvider, context)) {
285                     authOptions.add(authScheme);
286                 }
287             } catch (final AuthenticationException | MalformedChallengeException ex) {
288                 if (log.isWarnEnabled()) {
289                     log.warn(ex.getMessage());
290                 }
291             }
292         }
293         if (!authOptions.isEmpty()) {
294             if (log.isDebugEnabled()) {
295                 log.debug("{} Selected authentication options: {}", exchangeId, authOptions);
296             }
297             authExchange.reset();
298             authExchange.setState(AuthExchange.State.CHALLENGED);
299             authExchange.setOptions(authOptions);
300             return true;
301         }
302         return false;
303     }
304 
305     /**
306      * Generates a response to the authentication challenge based on the actual {@link AuthExchange} state
307      * and adds it to the given {@link HttpRequest} message .
308      *
309      * @param host the hostname of the opposite endpoint.
310      * @param challengeType the challenge type (target or proxy).
311      * @param request the request message head.
312      * @param authExchange the current authentication exchange state.
313      * @param context the current execution context.
314      */
315     public void addAuthResponse(
316             final HttpHost host,
317             final ChallengeType challengeType,
318             final HttpRequest request,
319             final AuthExchange authExchange,
320             final HttpContext context) {
321         final HttpClientContext clientContext = HttpClientContext.adapt(context);
322         final String exchangeId = clientContext.getExchangeId();
323         AuthScheme authScheme = authExchange.getAuthScheme();
324         switch (authExchange.getState()) {
325         case FAILURE:
326             return;
327         case SUCCESS:
328             Asserts.notNull(authScheme, "AuthScheme");
329             if (authScheme.isConnectionBased()) {
330                 return;
331             }
332             break;
333         case HANDSHAKE:
334             Asserts.notNull(authScheme, "AuthScheme");
335             break;
336         case CHALLENGED:
337             final Queue<AuthScheme> authOptions = authExchange.getAuthOptions();
338             if (authOptions != null) {
339                 while (!authOptions.isEmpty()) {
340                     authScheme = authOptions.remove();
341                     authExchange.select(authScheme);
342                     if (log.isDebugEnabled()) {
343                         log.debug("{} Generating response to an authentication challenge using {} scheme",
344                                 exchangeId, authScheme.getName());
345                     }
346                     try {
347                         final String authResponse = authScheme.generateAuthResponse(host, request, context);
348                         final Header header = new BasicHeader(
349                                 challengeType == ChallengeType.TARGET ? HttpHeaders.AUTHORIZATION : HttpHeaders.PROXY_AUTHORIZATION,
350                                 authResponse);
351                         request.addHeader(header);
352                         break;
353                     } catch (final AuthenticationException ex) {
354                         if (log.isWarnEnabled()) {
355                             log.warn("{} {} authentication error: {}", exchangeId, authScheme, ex.getMessage());
356                         }
357                     }
358                 }
359                 return;
360             }
361             Asserts.notNull(authScheme, "AuthScheme");
362         default:
363         }
364         if (authScheme != null) {
365             try {
366                 final String authResponse = authScheme.generateAuthResponse(host, request, context);
367                 final Header header = new BasicHeader(
368                         challengeType == ChallengeType.TARGET ? HttpHeaders.AUTHORIZATION : HttpHeaders.PROXY_AUTHORIZATION,
369                         authResponse);
370                 request.addHeader(header);
371             } catch (final AuthenticationException ex) {
372                 if (log.isErrorEnabled()) {
373                     log.error("{} {} authentication error: {}", exchangeId, authScheme, ex.getMessage());
374                 }
375             }
376         }
377     }
378 
379     private void updateCache(final HttpHost host, final AuthScheme authScheme, final HttpClientContext clientContext) {
380         final boolean cacheable = authScheme.getClass().getAnnotation(AuthStateCacheable.class) != null;
381         if (cacheable) {
382             AuthCache authCache = clientContext.getAuthCache();
383             if (authCache == null) {
384                 authCache = new BasicAuthCache();
385                 clientContext.setAuthCache(authCache);
386             }
387             if (log.isDebugEnabled()) {
388                 final String exchangeId = clientContext.getExchangeId();
389                 log.debug("{} Caching '{}' auth scheme for {}", exchangeId, authScheme.getName(), host);
390             }
391             authCache.put(host, authScheme);
392         }
393     }
394 
395     private void clearCache(final HttpHost host, final HttpClientContext clientContext) {
396 
397         final AuthCache authCache = clientContext.getAuthCache();
398         if (authCache != null) {
399             if (log.isDebugEnabled()) {
400                 final String exchangeId = clientContext.getExchangeId();
401                 log.debug("{} Clearing cached auth scheme for {}", exchangeId, host);
402             }
403             authCache.remove(host);
404         }
405     }
406 
407 }