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.protocol.HttpClientContext;
57  import org.apache.hc.client5.http.utils.ByteArrayBuilder;
58  import org.apache.hc.core5.annotation.Internal;
59  import org.apache.hc.core5.http.ClassicHttpRequest;
60  import org.apache.hc.core5.http.HttpEntity;
61  import org.apache.hc.core5.http.HttpHost;
62  import org.apache.hc.core5.http.HttpRequest;
63  import org.apache.hc.core5.http.NameValuePair;
64  import org.apache.hc.core5.http.message.BasicHeaderValueFormatter;
65  import org.apache.hc.core5.http.message.BasicNameValuePair;
66  import org.apache.hc.core5.http.protocol.HttpContext;
67  import org.apache.hc.core5.util.Args;
68  import org.apache.hc.core5.util.CharArrayBuffer;
69  import org.slf4j.Logger;
70  import org.slf4j.LoggerFactory;
71  
72  /**
73   * Digest authentication scheme as defined in RFC 2617.
74   * Both MD5 (default) and MD5-sess are supported.
75   * Currently only qop=auth or no qop is supported. qop=auth-int
76   * is unsupported. If auth and auth-int are provided, auth is
77   * used.
78   * <p>
79   * Since the digest username is included as clear text in the generated
80   * Authentication header, the charset of the username must be compatible
81   * with the HTTP element charset used by the connection.
82   * </p>
83   *
84   * @since 4.0
85   */
86  public class DigestScheme implements AuthScheme, Serializable {
87  
88      private static final long serialVersionUID = 3883908186234566916L;
89  
90      private static final Logger LOG = LoggerFactory.getLogger(DigestScheme.class);
91  
92      /**
93       * Hexa values used when creating 32 character long digest in HTTP DigestScheme
94       * in case of authentication.
95       *
96       * @see #formatHex(byte[])
97       */
98      private static final char[] HEXADECIMAL = {
99          '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
100         'e', 'f'
101     };
102 
103     /**
104      * Represent the possible values of quality of protection.
105      */
106     private enum QualityOfProtection {
107         UNKNOWN, MISSING, AUTH_INT, AUTH
108     }
109 
110     private transient Charset defaultCharset;
111     private final Map<String, String> paramMap;
112     private boolean complete;
113     private transient ByteArrayBuilder buffer;
114 
115     private String lastNonce;
116     private long nounceCount;
117     private String cnonce;
118     private byte[] a1;
119     private byte[] a2;
120 
121     private String username;
122     private char[] password;
123 
124     public DigestScheme() {
125         this(StandardCharsets.ISO_8859_1);
126     }
127 
128     public DigestScheme(final Charset charset) {
129         this.defaultCharset = charset != null ? charset : StandardCharsets.ISO_8859_1;
130         this.paramMap = new HashMap<>();
131         this.complete = false;
132     }
133 
134     public void initPreemptive(final Credentials credentials, final String cnonce, final String realm) {
135         Args.notNull(credentials, "Credentials");
136         this.username = credentials.getUserPrincipal().getName();
137         this.password = credentials.getPassword();
138         this.paramMap.put("cnonce", cnonce);
139         this.paramMap.put("realm", realm);
140     }
141 
142     @Override
143     public String getName() {
144         return StandardAuthScheme.DIGEST;
145     }
146 
147     @Override
148     public boolean isConnectionBased() {
149         return false;
150     }
151 
152     @Override
153     public String getRealm() {
154         return this.paramMap.get("realm");
155     }
156 
157     @Override
158     public void processChallenge(
159             final AuthChallenge authChallenge,
160             final HttpContext context) throws MalformedChallengeException {
161         Args.notNull(authChallenge, "AuthChallenge");
162         this.paramMap.clear();
163         final List<NameValuePair> params = authChallenge.getParams();
164         if (params != null) {
165             for (final NameValuePair param: params) {
166                 this.paramMap.put(param.getName().toLowerCase(Locale.ROOT), param.getValue());
167             }
168         }
169         if (this.paramMap.isEmpty()) {
170             throw new MalformedChallengeException("Missing digest auth parameters");
171         }
172         this.complete = true;
173     }
174 
175     @Override
176     public boolean isChallengeComplete() {
177         final String s = this.paramMap.get("stale");
178         return !"true".equalsIgnoreCase(s) && this.complete;
179     }
180 
181     @Override
182     public boolean isResponseReady(
183             final HttpHost host,
184             final CredentialsProvider credentialsProvider,
185             final HttpContext context) throws AuthenticationException {
186 
187         Args.notNull(host, "Auth host");
188         Args.notNull(credentialsProvider, "CredentialsProvider");
189 
190         final AuthScope authScope = new AuthScope(host, getRealm(), getName());
191         final Credentials credentials = credentialsProvider.getCredentials(
192                 authScope, context);
193         if (credentials != null) {
194             this.username = credentials.getUserPrincipal().getName();
195             this.password = credentials.getPassword();
196             return true;
197         }
198 
199         if (LOG.isDebugEnabled()) {
200             final HttpClientContext clientContext = HttpClientContext.adapt(context);
201             final String exchangeId = clientContext.getExchangeId();
202             LOG.debug("{} No credentials found for auth scope [{}]", exchangeId, authScope);
203         }
204         this.username = null;
205         this.password = null;
206         return false;
207     }
208 
209     @Override
210     public Principal getPrincipal() {
211         return null;
212     }
213 
214     @Override
215     public String generateAuthResponse(
216             final HttpHost host,
217             final HttpRequest request,
218             final HttpContext context) throws AuthenticationException {
219 
220         Args.notNull(request, "HTTP request");
221         if (this.paramMap.get("realm") == null) {
222             throw new AuthenticationException("missing realm");
223         }
224         if (this.paramMap.get("nonce") == null) {
225             throw new AuthenticationException("missing nonce");
226         }
227         return createDigestResponse(request);
228     }
229 
230     private static MessageDigest createMessageDigest(
231             final String digAlg) throws UnsupportedDigestAlgorithmException {
232         try {
233             return MessageDigest.getInstance(digAlg);
234         } catch (final Exception e) {
235             throw new UnsupportedDigestAlgorithmException(
236               "Unsupported algorithm in HTTP Digest authentication: "
237                + digAlg);
238         }
239     }
240 
241     private String createDigestResponse(final HttpRequest request) throws AuthenticationException {
242 
243         final String uri = request.getRequestUri();
244         final String method = request.getMethod();
245         final String realm = this.paramMap.get("realm");
246         final String nonce = this.paramMap.get("nonce");
247         final String opaque = this.paramMap.get("opaque");
248         final String algorithm = this.paramMap.get("algorithm");
249 
250         final Set<String> qopset = new HashSet<>(8);
251         QualityOfProtection qop = QualityOfProtection.UNKNOWN;
252         final String qoplist = this.paramMap.get("qop");
253         if (qoplist != null) {
254             final StringTokenizer tok = new StringTokenizer(qoplist, ",");
255             while (tok.hasMoreTokens()) {
256                 final String variant = tok.nextToken().trim();
257                 qopset.add(variant.toLowerCase(Locale.ROOT));
258             }
259             final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
260             if (entity != null && qopset.contains("auth-int")) {
261                 qop = QualityOfProtection.AUTH_INT;
262             } else if (qopset.contains("auth")) {
263                 qop = QualityOfProtection.AUTH;
264             } else if (qopset.contains("auth-int")) {
265                 qop = QualityOfProtection.AUTH_INT;
266             }
267         } else {
268             qop = QualityOfProtection.MISSING;
269         }
270 
271         if (qop == QualityOfProtection.UNKNOWN) {
272             throw new AuthenticationException("None of the qop methods is supported: " + qoplist);
273         }
274 
275         final Charset charset = AuthSchemeSupport.parseCharset(paramMap.get("charset"), defaultCharset);
276         String digAlg = algorithm;
277         // If an algorithm is not specified, default to MD5.
278         if (digAlg == null || digAlg.equalsIgnoreCase("MD5-sess")) {
279             digAlg = "MD5";
280         }
281 
282         final MessageDigest digester;
283         try {
284             digester = createMessageDigest(digAlg);
285         } catch (final UnsupportedDigestAlgorithmException ex) {
286             throw new AuthenticationException("Unsupported digest algorithm: " + digAlg);
287         }
288 
289         if (nonce.equals(this.lastNonce)) {
290             nounceCount++;
291         } else {
292             nounceCount = 1;
293             cnonce = null;
294             lastNonce = nonce;
295         }
296 
297         final StringBuilder sb = new StringBuilder(8);
298         try (final Formatter formatter = new Formatter(sb, Locale.ROOT)) {
299             formatter.format("%08x", nounceCount);
300         }
301         final String nc = sb.toString();
302 
303         if (cnonce == null) {
304             cnonce = formatHex(createCnonce());
305         }
306 
307         if (buffer == null) {
308             buffer = new ByteArrayBuilder(128);
309         } else {
310             buffer.reset();
311         }
312         buffer.charset(charset);
313 
314         a1 = null;
315         a2 = null;
316         // 3.2.2.2: Calculating digest
317         if ("MD5-sess".equalsIgnoreCase(algorithm)) {
318             // H( unq(username-value) ":" unq(realm-value) ":" passwd )
319             //      ":" unq(nonce-value)
320             //      ":" unq(cnonce-value)
321 
322             // calculated one per session
323             buffer.append(username).append(":").append(realm).append(":").append(password);
324             final String checksum = formatHex(digester.digest(this.buffer.toByteArray()));
325             buffer.reset();
326             buffer.append(checksum).append(":").append(nonce).append(":").append(cnonce);
327         } else {
328             // unq(username-value) ":" unq(realm-value) ":" passwd
329             buffer.append(username).append(":").append(realm).append(":").append(password);
330         }
331         a1 = buffer.toByteArray();
332 
333         final String hasha1 = formatHex(digester.digest(a1));
334         buffer.reset();
335 
336         if (qop == QualityOfProtection.AUTH) {
337             // Method ":" digest-uri-value
338             a2 = buffer.append(method).append(":").append(uri).toByteArray();
339         } else if (qop == QualityOfProtection.AUTH_INT) {
340             // Method ":" digest-uri-value ":" H(entity-body)
341             final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
342             if (entity != null && !entity.isRepeatable()) {
343                 // If the entity is not repeatable, try falling back onto QOP_AUTH
344                 if (qopset.contains("auth")) {
345                     qop = QualityOfProtection.AUTH;
346                     a2 = buffer.append(method).append(":").append(uri).toByteArray();
347                 } else {
348                     throw new AuthenticationException("Qop auth-int cannot be used with " +
349                             "a non-repeatable entity");
350                 }
351             } else {
352                 final HttpEntityDigester entityDigester = new HttpEntityDigester(digester);
353                 try {
354                     if (entity != null) {
355                         entity.writeTo(entityDigester);
356                     }
357                     entityDigester.close();
358                 } catch (final IOException ex) {
359                     throw new AuthenticationException("I/O error reading entity content", ex);
360                 }
361                 a2 = buffer.append(method).append(":").append(uri)
362                         .append(":").append(formatHex(entityDigester.getDigest())).toByteArray();
363             }
364         } else {
365             a2 = buffer.append(method).append(":").append(uri).toByteArray();
366         }
367 
368         final String hasha2 = formatHex(digester.digest(a2));
369         buffer.reset();
370 
371         // 3.2.2.1
372 
373         final byte[] digestInput;
374         if (qop == QualityOfProtection.MISSING) {
375             buffer.append(hasha1).append(":").append(nonce).append(":").append(hasha2);
376         } else {
377             buffer.append(hasha1).append(":").append(nonce).append(":").append(nc).append(":")
378                 .append(cnonce).append(":").append(qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth")
379                 .append(":").append(hasha2);
380         }
381         digestInput = buffer.toByteArray();
382         buffer.reset();
383 
384         final String digest = formatHex(digester.digest(digestInput));
385 
386         final CharArrayBuffer buffer = new CharArrayBuffer(128);
387         buffer.append(StandardAuthScheme.DIGEST + " ");
388 
389         final List<BasicNameValuePair> params = new ArrayList<>(20);
390         params.add(new BasicNameValuePair("username", username));
391         params.add(new BasicNameValuePair("realm", realm));
392         params.add(new BasicNameValuePair("nonce", nonce));
393         params.add(new BasicNameValuePair("uri", uri));
394         params.add(new BasicNameValuePair("response", digest));
395 
396         if (qop != QualityOfProtection.MISSING) {
397             params.add(new BasicNameValuePair("qop", qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth"));
398             params.add(new BasicNameValuePair("nc", nc));
399             params.add(new BasicNameValuePair("cnonce", cnonce));
400         }
401         if (algorithm != null) {
402             params.add(new BasicNameValuePair("algorithm", algorithm));
403         }
404         if (opaque != null) {
405             params.add(new BasicNameValuePair("opaque", opaque));
406         }
407 
408         for (int i = 0; i < params.size(); i++) {
409             final BasicNameValuePair param = params.get(i);
410             if (i > 0) {
411                 buffer.append(", ");
412             }
413             final String name = param.getName();
414             final boolean noQuotes = ("nc".equals(name) || "qop".equals(name)
415                     || "algorithm".equals(name));
416             BasicHeaderValueFormatter.INSTANCE.formatNameValuePair(buffer, param, !noQuotes);
417         }
418         return buffer.toString();
419     }
420 
421     @Internal
422     public String getNonce() {
423         return lastNonce;
424     }
425 
426     @Internal
427     public long getNounceCount() {
428         return nounceCount;
429     }
430 
431     @Internal
432     public String getCnonce() {
433         return cnonce;
434     }
435 
436     String getA1() {
437         return a1 != null ? new String(a1, StandardCharsets.US_ASCII) : null;
438     }
439 
440     String getA2() {
441         return a2 != null ? new String(a2, StandardCharsets.US_ASCII) : null;
442     }
443 
444     /**
445      * Encodes the 128 bit (16 bytes) MD5 digest into a 32 characters long
446      * {@code String} according to RFC 2617.
447      *
448      * @param binaryData array containing the digest
449      * @return encoded MD5, or {@code null} if encoding failed
450      */
451     static String formatHex(final byte[] binaryData) {
452         final int n = binaryData.length;
453         final char[] buffer = new char[n * 2];
454         for (int i = 0; i < n; i++) {
455             final int low = (binaryData[i] & 0x0f);
456             final int high = ((binaryData[i] & 0xf0) >> 4);
457             buffer[i * 2] = HEXADECIMAL[high];
458             buffer[(i * 2) + 1] = HEXADECIMAL[low];
459         }
460 
461         return new String(buffer);
462     }
463 
464     /**
465      * Creates a random cnonce value based on the current time.
466      *
467      * @return The cnonce value as String.
468      */
469     static byte[] createCnonce() {
470         final SecureRandom rnd = new SecureRandom();
471         final byte[] tmp = new byte[8];
472         rnd.nextBytes(tmp);
473         return tmp;
474     }
475 
476     private void writeObject(final ObjectOutputStream out) throws IOException {
477         out.defaultWriteObject();
478         out.writeUTF(defaultCharset.name());
479     }
480 
481     private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
482         in.defaultReadObject();
483         this.defaultCharset = Charset.forName(in.readUTF());
484     }
485 
486     @Override
487     public String toString() {
488         return getName() + this.paramMap;
489     }
490 
491 }