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  package org.apache.hc.client5.http.impl.auth;
28  
29  import java.io.IOException;
30  import java.io.ObjectInputStream;
31  import java.io.ObjectOutputStream;
32  import java.io.Serializable;
33  import java.nio.charset.Charset;
34  import java.nio.charset.StandardCharsets;
35  import java.security.MessageDigest;
36  import java.security.Principal;
37  import java.security.SecureRandom;
38  import java.util.ArrayList;
39  import java.util.Formatter;
40  import java.util.HashMap;
41  import java.util.HashSet;
42  import java.util.List;
43  import java.util.Locale;
44  import java.util.Map;
45  import java.util.Set;
46  import java.util.StringTokenizer;
47  
48  import org.apache.hc.client5.http.auth.AuthChallenge;
49  import org.apache.hc.client5.http.auth.AuthScheme;
50  import org.apache.hc.client5.http.auth.AuthScope;
51  import org.apache.hc.client5.http.auth.AuthenticationException;
52  import org.apache.hc.client5.http.auth.Credentials;
53  import org.apache.hc.client5.http.auth.CredentialsProvider;
54  import org.apache.hc.client5.http.auth.MalformedChallengeException;
55  import org.apache.hc.client5.http.auth.StandardAuthScheme;
56  import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
57  import org.apache.hc.client5.http.protocol.HttpClientContext;
58  import org.apache.hc.client5.http.utils.ByteArrayBuilder;
59  import org.apache.hc.core5.annotation.Internal;
60  import org.apache.hc.core5.http.ClassicHttpRequest;
61  import org.apache.hc.core5.http.HttpEntity;
62  import org.apache.hc.core5.http.HttpHost;
63  import org.apache.hc.core5.http.HttpRequest;
64  import org.apache.hc.core5.http.NameValuePair;
65  import org.apache.hc.core5.http.message.BasicHeaderValueFormatter;
66  import org.apache.hc.core5.http.message.BasicNameValuePair;
67  import org.apache.hc.core5.http.protocol.HttpContext;
68  import org.apache.hc.core5.net.PercentCodec;
69  import org.apache.hc.core5.util.Args;
70  import org.apache.hc.core5.util.CharArrayBuffer;
71  import org.slf4j.Logger;
72  import org.slf4j.LoggerFactory;
73  
74  /**
75   * Digest authentication scheme.
76   * Both MD5 (default) and MD5-sess are supported.
77   * Currently only qop=auth or no qop is supported. qop=auth-int
78   * is unsupported. If auth and auth-int are provided, auth is
79   * used.
80   * <p>
81   * Since the digest username is included as clear text in the generated
82   * Authentication header, the charset of the username must be compatible
83   * with the HTTP element charset used by the connection.
84   * </p>
85   *
86   * @since 4.0
87   */
88  public class DigestScheme implements AuthScheme, Serializable {
89  
90      private static final long serialVersionUID = 3883908186234566916L;
91  
92      private static final Logger LOG = LoggerFactory.getLogger(DigestScheme.class);
93  
94      /**
95       * Hexa values used when creating 32 character long digest in HTTP DigestScheme
96       * in case of authentication.
97       *
98       * @see #formatHex(byte[])
99       */
100     private static final char[] HEXADECIMAL = {
101         '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
102         'e', 'f'
103     };
104 
105     /**
106      * Represent the possible values of quality of protection.
107      */
108     private enum QualityOfProtection {
109         UNKNOWN, MISSING, AUTH_INT, AUTH
110     }
111 
112     private transient Charset defaultCharset;
113     private final Map<String, String> paramMap;
114     private boolean complete;
115     private transient ByteArrayBuilder buffer;
116 
117     /**
118      * Flag indicating whether username hashing is supported.
119      * <p>
120      * This flag is used to determine if the server supports hashing of the username
121      * as part of the Digest Access Authentication process. When set to {@code true},
122      * the client is expected to hash the username using the same algorithm used for
123      * hashing the credentials. This is in accordance with Section 3.4.4 of RFC 7616.
124      * </p>
125      * <p>
126      * The default value is {@code false}, indicating that username hashing is not
127      * supported. If the server requires username hashing (indicated by the
128      * {@code userhash} parameter in the  a header set to {@code true}),
129      * this flag should be set to {@code true} to comply with the server's requirements.
130      * </p>
131      */
132     private boolean userhashSupported = false;
133 
134 
135     private String lastNonce;
136     private long nounceCount;
137     private String cnonce;
138     private byte[] a1;
139     private byte[] a2;
140 
141     private UsernamePasswordCredentials credentials;
142 
143     public DigestScheme() {
144         this.defaultCharset =  StandardCharsets.UTF_8;
145         this.paramMap = new HashMap<>();
146         this.complete = false;
147     }
148 
149     /**
150      * @deprecated This constructor is deprecated to enforce the use of {@link StandardCharsets#UTF_8} encoding
151      * in compliance with RFC 7616 for HTTP Digest Access Authentication. Use the default constructor {@link #DigestScheme()} instead.
152      *
153      * @param charset the {@link Charset} set to be used for encoding credentials. This parameter is ignored as UTF-8 is always used.
154      */
155     @Deprecated
156     public DigestScheme(final Charset charset) {
157         this();
158     }
159 
160     public void initPreemptive(final Credentials credentials, final String cnonce, final String realm) {
161         Args.notNull(credentials, "Credentials");
162         Args.check(credentials instanceof UsernamePasswordCredentials,
163                 "Unsupported credential type: " + credentials.getClass());
164         this.credentials = (UsernamePasswordCredentials) credentials;
165         this.paramMap.put("cnonce", cnonce);
166         this.paramMap.put("realm", realm);
167     }
168 
169     @Override
170     public String getName() {
171         return StandardAuthScheme.DIGEST;
172     }
173 
174     @Override
175     public boolean isConnectionBased() {
176         return false;
177     }
178 
179     @Override
180     public String getRealm() {
181         return this.paramMap.get("realm");
182     }
183 
184     @Override
185     public void processChallenge(
186             final AuthChallenge authChallenge,
187             final HttpContext context) throws MalformedChallengeException {
188         Args.notNull(authChallenge, "AuthChallenge");
189         this.paramMap.clear();
190         final List<NameValuePair> params = authChallenge.getParams();
191         if (params != null) {
192             for (final NameValuePair param: params) {
193                 this.paramMap.put(param.getName().toLowerCase(Locale.ROOT), param.getValue());
194             }
195         }
196         if (this.paramMap.isEmpty()) {
197             throw new MalformedChallengeException("Missing digest auth parameters");
198         }
199 
200         final String userHashValue = this.paramMap.get("userhash");
201         this.userhashSupported = "true".equalsIgnoreCase(userHashValue);
202 
203         this.complete = true;
204     }
205 
206     @Override
207     public boolean isChallengeComplete() {
208         final String s = this.paramMap.get("stale");
209         return !"true".equalsIgnoreCase(s) && this.complete;
210     }
211 
212     @Override
213     public boolean isResponseReady(
214             final HttpHost host,
215             final CredentialsProvider credentialsProvider,
216             final HttpContext context) throws AuthenticationException {
217 
218         Args.notNull(host, "Auth host");
219         Args.notNull(credentialsProvider, "CredentialsProvider");
220 
221         final AuthScope authScope = new AuthScope(host, getRealm(), getName());
222         final Credentials credentials = credentialsProvider.getCredentials(
223                 authScope, context);
224         if (credentials instanceof UsernamePasswordCredentials) {
225             this.credentials = (UsernamePasswordCredentials) credentials;
226             return true;
227         }
228 
229         if (LOG.isDebugEnabled()) {
230             final HttpClientContext clientContext = HttpClientContext.adapt(context);
231             final String exchangeId = clientContext.getExchangeId();
232             LOG.debug("{} No credentials found for auth scope [{}]", exchangeId, authScope);
233         }
234         this.credentials = null;
235         return false;
236     }
237 
238     @Override
239     public Principal getPrincipal() {
240         return null;
241     }
242 
243     @Override
244     public String generateAuthResponse(
245             final HttpHost host,
246             final HttpRequest request,
247             final HttpContext context) throws AuthenticationException {
248 
249         Args.notNull(request, "HTTP request");
250         if (this.paramMap.get("realm") == null) {
251             throw new AuthenticationException("missing realm");
252         }
253         if (this.paramMap.get("nonce") == null) {
254             throw new AuthenticationException("missing nonce");
255         }
256         return createDigestResponse(request);
257     }
258 
259     private static MessageDigest createMessageDigest(
260             final String digAlg) throws UnsupportedDigestAlgorithmException {
261         try {
262             return MessageDigest.getInstance(digAlg);
263         } catch (final Exception e) {
264             throw new UnsupportedDigestAlgorithmException(
265               "Unsupported algorithm in HTTP Digest authentication: "
266                + digAlg);
267         }
268     }
269 
270     private String createDigestResponse(final HttpRequest request) throws AuthenticationException {
271         if (credentials == null) {
272             throw new AuthenticationException("User credentials have not been provided");
273         }
274         final String uri = request.getRequestUri();
275         final String method = request.getMethod();
276         final String realm = this.paramMap.get("realm");
277         final String nonce = this.paramMap.get("nonce");
278         final String opaque = this.paramMap.get("opaque");
279         final String algorithm = this.paramMap.get("algorithm");
280 
281         final Set<String> qopset = new HashSet<>(8);
282         QualityOfProtection qop = QualityOfProtection.UNKNOWN;
283         final String qoplist = this.paramMap.get("qop");
284         if (qoplist != null) {
285             final StringTokenizer tok = new StringTokenizer(qoplist, ",");
286             while (tok.hasMoreTokens()) {
287                 final String variant = tok.nextToken().trim();
288                 qopset.add(variant.toLowerCase(Locale.ROOT));
289             }
290             final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
291             if (entity != null && qopset.contains("auth-int")) {
292                 qop = QualityOfProtection.AUTH_INT;
293             } else if (qopset.contains("auth")) {
294                 qop = QualityOfProtection.AUTH;
295             } else if (qopset.contains("auth-int")) {
296                 qop = QualityOfProtection.AUTH_INT;
297             }
298         } else {
299             qop = QualityOfProtection.MISSING;
300         }
301 
302         if (qop == QualityOfProtection.UNKNOWN) {
303             throw new AuthenticationException("None of the qop methods is supported: " + qoplist);
304         }
305 
306         final Charset charset = AuthSchemeSupport.parseCharset(paramMap.get("charset"), defaultCharset);
307         String digAlg = algorithm;
308         // If an algorithm is not specified, default to MD5.
309         if (digAlg == null || digAlg.equalsIgnoreCase("MD5-sess")) {
310             digAlg = "MD5";
311         }
312 
313         final MessageDigest digester;
314         try {
315             digester = createMessageDigest(digAlg);
316         } catch (final UnsupportedDigestAlgorithmException ex) {
317             throw new AuthenticationException("Unsupported digest algorithm: " + digAlg);
318         }
319 
320         if (nonce.equals(this.lastNonce)) {
321             nounceCount++;
322         } else {
323             nounceCount = 1;
324             cnonce = null;
325             lastNonce = nonce;
326         }
327 
328         final StringBuilder sb = new StringBuilder(8);
329         try (final Formatter formatter = new Formatter(sb, Locale.ROOT)) {
330             formatter.format("%08x", nounceCount);
331         }
332         final String nc = sb.toString();
333 
334         if (cnonce == null) {
335             cnonce = formatHex(createCnonce());
336         }
337 
338         if (buffer == null) {
339             buffer = new ByteArrayBuilder(128);
340         } else {
341             buffer.reset();
342         }
343         buffer.charset(charset);
344 
345         a1 = null;
346         a2 = null;
347 
348 
349         // Extract username and username*
350         String username = credentials.getUserName();
351         String encodedUsername = null;
352         // Check if 'username' has invalid characters and use 'username*'
353         if (username != null && containsInvalidABNFChars(username)) {
354             encodedUsername = "UTF-8''" + PercentCodec.RFC5987.encode(username);
355         }
356 
357         final String usernameForDigest;
358         if (this.userhashSupported) {
359             final String usernameRealm = username + ":" + realm;
360             final byte[] hashedBytes = digester.digest(usernameRealm.getBytes(StandardCharsets.UTF_8));
361             usernameForDigest = formatHex(hashedBytes); // Use hashed username for digest
362             username = usernameForDigest;
363         } else if (encodedUsername != null) {
364             usernameForDigest = encodedUsername; // Use encoded username for digest
365         } else {
366             usernameForDigest = username; // Use regular username for digest
367         }
368 
369         // 3.2.2.2: Calculating digest
370         if ("MD5-sess".equalsIgnoreCase(algorithm)) {
371             // H( unq(username-value) ":" unq(realm-value) ":" passwd )
372             //      ":" unq(nonce-value)
373             //      ":" unq(cnonce-value)
374 
375             // calculated one per session
376             buffer.append(username).append(":").append(realm).append(":").append(credentials.getUserPassword());
377             final String checksum = formatHex(digester.digest(this.buffer.toByteArray()));
378             buffer.reset();
379             buffer.append(checksum).append(":").append(nonce).append(":").append(cnonce);
380         } else {
381             // unq(username-value) ":" unq(realm-value) ":" passwd
382             buffer.append(username).append(":").append(realm).append(":").append(credentials.getUserPassword());
383         }
384         a1 = buffer.toByteArray();
385 
386         final String hasha1 = formatHex(digester.digest(a1));
387         buffer.reset();
388 
389         if (qop == QualityOfProtection.AUTH) {
390             // Method ":" digest-uri-value
391             a2 = buffer.append(method).append(":").append(uri).toByteArray();
392         } else if (qop == QualityOfProtection.AUTH_INT) {
393             // Method ":" digest-uri-value ":" H(entity-body)
394             final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
395             if (entity != null && !entity.isRepeatable()) {
396                 // If the entity is not repeatable, try falling back onto QOP_AUTH
397                 if (qopset.contains("auth")) {
398                     qop = QualityOfProtection.AUTH;
399                     a2 = buffer.append(method).append(":").append(uri).toByteArray();
400                 } else {
401                     throw new AuthenticationException("Qop auth-int cannot be used with " +
402                             "a non-repeatable entity");
403                 }
404             } else {
405                 final HttpEntityDigester entityDigester = new HttpEntityDigester(digester);
406                 try {
407                     if (entity != null) {
408                         entity.writeTo(entityDigester);
409                     }
410                     entityDigester.close();
411                 } catch (final IOException ex) {
412                     throw new AuthenticationException("I/O error reading entity content", ex);
413                 }
414                 a2 = buffer.append(method).append(":").append(uri)
415                         .append(":").append(formatHex(entityDigester.getDigest())).toByteArray();
416             }
417         } else {
418             a2 = buffer.append(method).append(":").append(uri).toByteArray();
419         }
420 
421         final String hasha2 = formatHex(digester.digest(a2));
422         buffer.reset();
423 
424         // 3.2.2.1
425 
426         final byte[] digestInput;
427         if (qop == QualityOfProtection.MISSING) {
428             buffer.append(hasha1).append(":").append(nonce).append(":").append(hasha2);
429         } else {
430             buffer.append(hasha1).append(":").append(nonce).append(":").append(nc).append(":")
431                 .append(cnonce).append(":").append(qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth")
432                 .append(":").append(hasha2);
433         }
434         digestInput = buffer.toByteArray();
435         buffer.reset();
436 
437         final String digest = formatHex(digester.digest(digestInput));
438 
439         final CharArrayBuffer buffer = new CharArrayBuffer(128);
440         buffer.append(StandardAuthScheme.DIGEST + " ");
441 
442         final List<BasicNameValuePair> params = new ArrayList<>(20);
443         if (this.userhashSupported) {
444             // Use hashed username for the 'username' parameter
445             params.add(new BasicNameValuePair("username", usernameForDigest));
446             params.add(new BasicNameValuePair("userhash", "true"));
447         } else if (encodedUsername != null) {
448             // Use encoded 'username*' parameter
449             params.add(new BasicNameValuePair("username*", encodedUsername));
450         } else {
451             // Use regular 'username' parameter
452             params.add(new BasicNameValuePair("username", username));
453         }
454         params.add(new BasicNameValuePair("realm", realm));
455         params.add(new BasicNameValuePair("nonce", nonce));
456         params.add(new BasicNameValuePair("uri", uri));
457         params.add(new BasicNameValuePair("response", digest));
458 
459         if (qop != QualityOfProtection.MISSING) {
460             params.add(new BasicNameValuePair("qop", qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth"));
461             params.add(new BasicNameValuePair("nc", nc));
462             params.add(new BasicNameValuePair("cnonce", cnonce));
463         }
464         if (algorithm != null) {
465             params.add(new BasicNameValuePair("algorithm", algorithm));
466         }
467         if (opaque != null) {
468             params.add(new BasicNameValuePair("opaque", opaque));
469         }
470 
471         for (int i = 0; i < params.size(); i++) {
472             final BasicNameValuePair param = params.get(i);
473             if (i > 0) {
474                 buffer.append(", ");
475             }
476             final String name = param.getName();
477             final boolean noQuotes = ("nc".equals(name) || "qop".equals(name)
478                     || "algorithm".equals(name));
479             BasicHeaderValueFormatter.INSTANCE.formatNameValuePair(buffer, param, !noQuotes);
480         }
481         return buffer.toString();
482     }
483 
484     @Internal
485     public String getNonce() {
486         return lastNonce;
487     }
488 
489     @Internal
490     public long getNounceCount() {
491         return nounceCount;
492     }
493 
494     @Internal
495     public String getCnonce() {
496         return cnonce;
497     }
498 
499     String getA1() {
500         return a1 != null ? new String(a1, StandardCharsets.US_ASCII) : null;
501     }
502 
503     String getA2() {
504         return a2 != null ? new String(a2, StandardCharsets.US_ASCII) : null;
505     }
506 
507     /**
508      * Encodes the 128 bit (16 bytes) MD5 digest into a 32 characters long string.
509      *
510      * @param binaryData array containing the digest
511      * @return encoded MD5, or {@code null} if encoding failed
512      */
513     static String formatHex(final byte[] binaryData) {
514         final int n = binaryData.length;
515         final char[] buffer = new char[n * 2];
516         for (int i = 0; i < n; i++) {
517             final int low = (binaryData[i] & 0x0f);
518             final int high = ((binaryData[i] & 0xf0) >> 4);
519             buffer[i * 2] = HEXADECIMAL[high];
520             buffer[(i * 2) + 1] = HEXADECIMAL[low];
521         }
522 
523         return new String(buffer);
524     }
525 
526     /**
527      * Creates a random cnonce value based on the current time.
528      *
529      * @return The cnonce value as String.
530      */
531     static byte[] createCnonce() {
532         final SecureRandom rnd = new SecureRandom();
533         final byte[] tmp = new byte[8];
534         rnd.nextBytes(tmp);
535         return tmp;
536     }
537 
538     private void writeObject(final ObjectOutputStream out) throws IOException {
539         out.defaultWriteObject();
540         out.writeUTF(defaultCharset.name());
541     }
542 
543     private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
544         in.defaultReadObject();
545         this.defaultCharset = Charset.forName(in.readUTF());
546     }
547 
548     @Override
549     public String toString() {
550         return getName() + this.paramMap;
551     }
552 
553     /**
554      * Checks if a given string contains characters that are not allowed
555      * in an ABNF quoted-string as per standard specifications.
556      * <p>
557      * The method checks for:
558      * - Control characters (ASCII 0x00 to 0x1F and 0x7F).
559      * - Characters outside the printable ASCII range (above 0x7E).
560      * - Double quotes (&quot;) and backslashes (\), which are not allowed.
561      * </p>
562      *
563      * @param value The string to be checked for invalid ABNF characters.
564      * @return {@code true} if invalid characters are found, {@code false} otherwise.
565      * @throws IllegalArgumentException if the input string is null.
566      */
567     private boolean containsInvalidABNFChars(final String value) {
568         if (value == null) {
569             throw new IllegalArgumentException("Input string should not be null.");
570         }
571 
572         for (int i = 0; i < value.length(); i++) {
573             final char c = value.charAt(i);
574 
575             // Check for control characters and DEL
576             if (c <= 0x1F || c == 0x7F) {
577                 return true;
578             }
579 
580             // Check for characters outside the range 0x20 to 0x7E
581             if (c > 0x7E) {
582                 return true;
583             }
584 
585             // Exclude double quotes and backslash
586             if (c == '"' || c == '\\') {
587                 return true;
588             }
589         }
590         return false;
591     }
592 }