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.Iterator;
32  import java.util.LinkedList;
33  import java.util.List;
34  import java.util.Locale;
35  import java.util.Map;
36  import java.util.Queue;
37  
38  import org.apache.hc.client5.http.AuthenticationStrategy;
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.AuthenticationException;
43  import org.apache.hc.client5.http.auth.ChallengeType;
44  import org.apache.hc.client5.http.auth.CredentialsProvider;
45  import org.apache.hc.client5.http.auth.MalformedChallengeException;
46  import org.apache.hc.client5.http.protocol.HttpClientContext;
47  import org.apache.hc.core5.annotation.Contract;
48  import org.apache.hc.core5.annotation.Internal;
49  import org.apache.hc.core5.annotation.ThreadingBehavior;
50  import org.apache.hc.core5.http.FormattedHeader;
51  import org.apache.hc.core5.http.Header;
52  import org.apache.hc.core5.http.HttpHeaders;
53  import org.apache.hc.core5.http.HttpHost;
54  import org.apache.hc.core5.http.HttpRequest;
55  import org.apache.hc.core5.http.HttpResponse;
56  import org.apache.hc.core5.http.HttpStatus;
57  import org.apache.hc.core5.http.ParseException;
58  import org.apache.hc.core5.http.message.BasicHeader;
59  import org.apache.hc.core5.http.message.ParserCursor;
60  import org.apache.hc.core5.http.protocol.HttpContext;
61  import org.apache.hc.core5.util.Asserts;
62  import org.apache.hc.core5.util.CharArrayBuffer;
63  import org.slf4j.Logger;
64  import org.slf4j.LoggerFactory;
65  
66  /**
67   * Internal utility class that implements commons aspects of the client side
68   * HTTP authentication.
69   *
70   * @since 5.4
71   */
72  @Internal
73  @Contract(threading = ThreadingBehavior.STATELESS)
74  public class AuthenticationHandler {
75  
76      private static final Logger LOG = LoggerFactory.getLogger(AuthenticationHandler.class);
77  
78      private final AuthChallengeParser parser;
79  
80      public AuthenticationHandler() {
81          this.parser = new AuthChallengeParser();
82      }
83  
84      /**
85       * Determines whether the given response represents an authentication challenge, and updates
86       * the {@link AuthExchange} state.
87       *
88       * @param host the hostname of the opposite endpoint.
89       * @param challengeType the challenge type (target or proxy).
90       * @param response the response message head.
91       * @param authExchange the current authentication exchange state. Gets updated.
92       * @param context the current execution context.
93       * @return {@code true} if the response message represents an authentication challenge,
94       *   {@code false} otherwise.
95       */
96      public boolean isChallenged(
97              final HttpHost host,
98              final ChallengeType challengeType,
99              final HttpResponse response,
100             final AuthExchange authExchange,
101             final HttpContext context) {
102         final HttpClientContext clientContext = HttpClientContext.cast(context);
103         if (checkChallenged(challengeType, response, clientContext)) {
104             return true;
105         }
106         switch (authExchange.getState()) {
107         case CHALLENGED:
108         case HANDSHAKE:
109             if (LOG.isDebugEnabled()) {
110                 // The mutual auth may still fail
111                 LOG.debug("{} Server has accepted authentication", clientContext.getExchangeId());
112             }
113             authExchange.setState(AuthExchange.State.SUCCESS);
114             break;
115         case SUCCESS:
116             break;
117         default:
118             authExchange.setState(AuthExchange.State.UNCHALLENGED);
119         }
120         return false;
121     }
122 
123     /**
124      * Determines whether the given response represents an authentication challenge, without
125      * changing the {@link AuthExchange} state.
126      *
127      * @param challengeType the challenge type (target or proxy).
128      * @param response the response message head.
129      * @param clientContext the current execution context.
130      * @return {@code true} if the response message represents an authentication challenge,
131      *   {@code false} otherwise.
132      */
133     private boolean checkChallenged(
134             final ChallengeType challengeType,
135             final HttpResponse response,
136             final HttpClientContext clientContext) {
137         final int challengeCode;
138         switch (challengeType) {
139             case TARGET:
140                 challengeCode = HttpStatus.SC_UNAUTHORIZED;
141                 break;
142             case PROXY:
143                 challengeCode = HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED;
144                 break;
145             default:
146                 throw new IllegalStateException("Unexpected challenge type: " + challengeType);
147         }
148 
149         if (response.getCode() == challengeCode) {
150             if (LOG.isDebugEnabled()) {
151                 LOG.debug("{} Authentication required", clientContext.getExchangeId());
152             }
153             return true;
154         }
155         return false;
156     }
157 
158     /**
159      * Determines if the scheme requires an auth challenge for responses that do not
160      * have a challenge HTTP code. (i.e whether it needs a mutual authentication token)
161      */
162     public boolean isChallengeExpected(final AuthExchange authExchange) {
163         final AuthScheme authScheme = authExchange.getAuthScheme();
164         return authScheme != null && authScheme.isChallengeExpected();
165     }
166 
167     public Map<String, AuthChallenge> extractChallengeMap(
168             final ChallengeType challengeType,
169             final HttpResponse response,
170             final HttpClientContext context) {
171         final Map<String, AuthChallenge> challengeMap = new HashMap<>();
172         final Iterator<Header> headerIterator = response.headerIterator(
173                 challengeType == ChallengeType.PROXY ? HttpHeaders.PROXY_AUTHENTICATE : HttpHeaders.WWW_AUTHENTICATE);
174         while (headerIterator.hasNext()) {
175             final Header header = headerIterator.next();
176             final CharArrayBuffer buffer;
177             final int pos;
178             if (header instanceof FormattedHeader) {
179                 buffer = ((FormattedHeader) header).getBuffer();
180                 pos = ((FormattedHeader) header).getValuePos();
181             } else {
182                 final String s = header.getValue();
183                 if (s == null) {
184                     continue;
185                 }
186                 buffer = new CharArrayBuffer(s.length());
187                 buffer.append(s);
188                 pos = 0;
189             }
190             final ParserCursor cursor = new ParserCursor(pos, buffer.length());
191             final List<AuthChallenge> authChallenges;
192             try {
193                 authChallenges = parser.parse(challengeType, buffer, cursor);
194             } catch (final ParseException ex) {
195                 if (LOG.isWarnEnabled()) {
196                     final HttpClientContext clientContext = HttpClientContext.cast(context);
197                     final String exchangeId = clientContext.getExchangeId();
198                     LOG.warn("{} Malformed challenge: {}", exchangeId, header.getValue());
199                 }
200                 continue;
201             }
202             for (final AuthChallenge authChallenge : authChallenges) {
203                 final String schemeName = authChallenge.getSchemeName().toLowerCase(Locale.ROOT);
204                 if (!challengeMap.containsKey(schemeName)) {
205                     challengeMap.put(schemeName, authChallenge);
206                 }
207             }
208         }
209         return challengeMap;
210     }
211 
212     /**
213      * Handles the response from the opposite endpoint and updates the {@link AuthExchange}
214      * state based on the challenge presented in the response message using the given
215      * {@link AuthenticationStrategy}.
216      *
217      * @param host the hostname of the opposite endpoint.
218      * @param challengeType the challenge type (target or proxy).
219      * @param response the response message head.
220      * @param authStrategy the authentication strategy.
221      * @param authExchange the current authentication exchange state.
222      * @param context the current execution context.
223      * @return {@code true} if the request needs-to be re-sent,
224      *   {@code false} if the authentication is complete (successful or not).
225      *
226      * @throws AuthenticationException if the AuthScheme throws one. In most cases this indicates a
227      * client side problem, as final server error responses are simply returned.
228      * @throws MalformedChallengeException if the AuthScheme throws one. In most cases this indicates a
229      * client side problem, as final server error responses are simply returned.
230      */
231     public boolean handleResponse(
232             final HttpHost host,
233             final ChallengeType challengeType,
234             final HttpResponse response,
235             final AuthenticationStrategy authStrategy,
236             final AuthExchange authExchange,
237             final HttpContext context) throws AuthenticationException, MalformedChallengeException {
238 
239     final HttpClientContext clientContext = HttpClientContext.cast(context);
240         final String exchangeId = clientContext.getExchangeId();
241         final boolean challenged = checkChallenged(challengeType, response, clientContext);
242         final boolean isChallengeExpected = isChallengeExpected(authExchange);
243 
244         if (LOG.isDebugEnabled()) {
245             LOG.debug("{} {} requested authentication", exchangeId, host.toHostString());
246         }
247 
248         final Map<String, AuthChallenge> challengeMap = extractChallengeMap(challengeType, response, clientContext);
249 
250         if (challengeMap.isEmpty()) {
251             if (LOG.isDebugEnabled()) {
252                 LOG.debug("{} Response contains no valid authentication challenges", exchangeId);
253             }
254             if (!isChallengeExpected) {
255                 authExchange.reset();
256                 return false;
257             }
258         }
259 
260         switch (authExchange.getState()) {
261             case FAILURE:
262                 return false;
263             case SUCCESS:
264                 if (!isChallengeExpected) {
265                     authExchange.reset();
266                     break;
267                 }
268                 // otherwise fall through
269             case CHALLENGED:
270                 // fall through
271             case HANDSHAKE:
272                 Asserts.notNull(authExchange.getAuthScheme(), "AuthScheme");
273                 // fall through
274             case UNCHALLENGED:
275                 final AuthScheme authScheme = authExchange.getAuthScheme();
276                 // AuthScheme is only set if we have already sent an auth response, either
277                 // because we have received a challenge for it, or preemptively.
278                 if (authScheme != null) {
279                     final String schemeName = authScheme.getName();
280                     final AuthChallenge challenge = challengeMap.get(schemeName.toLowerCase(Locale.ROOT));
281                     if (challenge != null || isChallengeExpected) {
282                         if (LOG.isDebugEnabled()) {
283                             LOG.debug("{} Processing authentication challenge {}", exchangeId, challenge);
284                         }
285                         try {
286                             authScheme.processChallenge(host, challenged, challenge, clientContext);
287                         } catch (final AuthenticationException | MalformedChallengeException ex) {
288                             if (LOG.isWarnEnabled()) {
289                                 LOG.warn("Exception processing challenge {}", exchangeId, ex);
290                             }
291                             authExchange.reset();
292                             authExchange.setState(AuthExchange.State.FAILURE);
293                             if (isChallengeExpected) {
294                                 throw ex;
295                             }
296                         }
297                         if (authScheme.isChallengeComplete()) {
298                             if (LOG.isDebugEnabled()) {
299                                 LOG.debug("{} Authentication failed", exchangeId);
300                             }
301                             authExchange.reset();
302                             authExchange.setState(AuthExchange.State.FAILURE);
303                             return false;
304                         }
305                         if (!challenged) {
306                             // There are no more challanges sent after the 200 message,
307                             // and if we get here, then the mutual auth phase has succeeded.
308                             authExchange.setState(AuthExchange.State.SUCCESS);
309                             return false;
310                         } else {
311                             authExchange.setState(AuthExchange.State.HANDSHAKE);
312                         }
313                         return true;
314                     }
315                     authExchange.reset();
316                     // Retry authentication with a different scheme
317                 }
318         }
319 
320         // We reach this if we fell through above because the authScheme has not yet been set, or if
321         // we receive a 401/407 response for an unexpected scheme. Normally this processes the first
322         // 401/407 response
323         final List<AuthScheme> preferredSchemes = authStrategy.select(challengeType, challengeMap, clientContext);
324         final CredentialsProvider credsProvider = clientContext.getCredentialsProvider();
325         if (credsProvider == null) {
326             if (LOG.isDebugEnabled()) {
327                 LOG.debug("{} Credentials provider not set in the context", exchangeId);
328             }
329             return false;
330         }
331 
332         final Queue<AuthScheme> authOptions = new LinkedList<>();
333         if (LOG.isDebugEnabled()) {
334             LOG.debug("{} Selecting authentication options", exchangeId);
335         }
336         for (final AuthScheme authScheme: preferredSchemes) {
337             // We only respond to the first successfully processed challenge. However, the
338             // original AuthScheme API does not really process the challenge at this point,
339             // so we need to process/store each challenge here anyway.
340             try {
341                 final String schemeName = authScheme.getName();
342                 final AuthChallenge challenge = challengeMap.get(schemeName.toLowerCase(Locale.ROOT));
343                 authScheme.processChallenge(host, challenged, challenge, clientContext);
344                 if (authScheme.isResponseReady(host, credsProvider, clientContext)) {
345                     authOptions.add(authScheme);
346                 }
347             } catch (final AuthenticationException | MalformedChallengeException ex) {
348                 if (LOG.isWarnEnabled()) {
349                     LOG.warn("Exception while processing Challange", ex);
350                 }
351             }
352         }
353         if (!authOptions.isEmpty()) {
354             if (LOG.isDebugEnabled()) {
355                 LOG.debug("{} Selected authentication options: {}", exchangeId, authOptions);
356             }
357             authExchange.reset();
358             authExchange.setState(AuthExchange.State.CHALLENGED);
359             authExchange.setOptions(authOptions);
360             return true;
361         }
362         return false;
363     }
364 
365     /**
366      * Generates a response to the authentication challenge based on the actual {@link AuthExchange} state
367      * and adds it to the given {@link HttpRequest} message .
368      *
369      * @param host the hostname of the opposite endpoint.
370      * @param challengeType the challenge type (target or proxy).
371      * @param request the request message head.
372      * @param authExchange the current authentication exchange state.
373      * @param context the current execution context.
374      */
375     public void addAuthResponse(
376             final HttpHost host,
377             final ChallengeType challengeType,
378             final HttpRequest request,
379             final AuthExchange authExchange,
380             final HttpContext context) {
381         final HttpClientContext clientContext = HttpClientContext.cast(context);
382         final String exchangeId = clientContext.getExchangeId();
383         AuthScheme authScheme = authExchange.getAuthScheme();
384         switch (authExchange.getState()) {
385         case FAILURE:
386             return;
387         case SUCCESS:
388             Asserts.notNull(authScheme, "AuthScheme");
389             if (authScheme.isConnectionBased()) {
390                 return;
391             }
392             break;
393         case HANDSHAKE:
394             Asserts.notNull(authScheme, "AuthScheme");
395             break;
396         case CHALLENGED:
397             final Queue<AuthScheme> authOptions = authExchange.getAuthOptions();
398             if (authOptions != null) {
399                 while (!authOptions.isEmpty()) {
400                     authScheme = authOptions.remove();
401                     authExchange.select(authScheme);
402                     if (LOG.isDebugEnabled()) {
403                         LOG.debug("{} Generating response to an authentication challenge using {} scheme",
404                                 exchangeId, authScheme.getName());
405                     }
406                     try {
407                         final String authResponse = authScheme.generateAuthResponse(host, request, clientContext);
408                         if (authResponse != null) {
409                             final Header header = new BasicHeader(
410                                     challengeType == ChallengeType.TARGET ? HttpHeaders.AUTHORIZATION : HttpHeaders.PROXY_AUTHORIZATION,
411                                     authResponse);
412                             request.addHeader(header);
413                         }
414                         break;
415                     } catch (final AuthenticationException ex) {
416                         if (LOG.isWarnEnabled()) {
417                             LOG.warn("{} {} authentication error: {}", exchangeId, authScheme, ex.getMessage());
418                         }
419                     }
420                 }
421                 return;
422             }
423             Asserts.notNull(authScheme, "AuthScheme");
424         default:
425         }
426         // This is the SUCCESS and HANDSHAKE states, same as the initial response.
427         // This only happens if the handshake requires multiple requests, which is
428         // unlikely in practice.
429         if (authScheme != null) {
430             try {
431                 final String authResponse = authScheme.generateAuthResponse(host, request, clientContext);
432                 final Header header = new BasicHeader(
433                         challengeType == ChallengeType.TARGET ? HttpHeaders.AUTHORIZATION : HttpHeaders.PROXY_AUTHORIZATION,
434                         authResponse);
435                 request.addHeader(header);
436             } catch (final AuthenticationException ex) {
437                 if (LOG.isErrorEnabled()) {
438                     LOG.error("{} {} authentication error: {}", exchangeId, authScheme, ex.getMessage());
439                 }
440             }
441         }
442     }
443 
444 }