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         String algorithm = this.paramMap.get("algorithm");
249         // If an algorithm is not specified, default to MD5.
250         if (algorithm == null) {
251             algorithm = "MD5";
252         }
253 
254         final Set<String> qopset = new HashSet<>(8);
255         QualityOfProtection qop = QualityOfProtection.UNKNOWN;
256         final String qoplist = this.paramMap.get("qop");
257         if (qoplist != null) {
258             final StringTokenizer tok = new StringTokenizer(qoplist, ",");
259             while (tok.hasMoreTokens()) {
260                 final String variant = tok.nextToken().trim();
261                 qopset.add(variant.toLowerCase(Locale.ROOT));
262             }
263             final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
264             if (entity != null && qopset.contains("auth-int")) {
265                 qop = QualityOfProtection.AUTH_INT;
266             } else if (qopset.contains("auth")) {
267                 qop = QualityOfProtection.AUTH;
268             } else if (qopset.contains("auth-int")) {
269                 qop = QualityOfProtection.AUTH_INT;
270             }
271         } else {
272             qop = QualityOfProtection.MISSING;
273         }
274 
275         if (qop == QualityOfProtection.UNKNOWN) {
276             throw new AuthenticationException("None of the qop methods is supported: " + qoplist);
277         }
278 
279         final Charset charset = AuthSchemeSupport.parseCharset(paramMap.get("charset"), defaultCharset);
280         String digAlg = algorithm;
281         if (digAlg.equalsIgnoreCase("MD5-sess")) {
282             digAlg = "MD5";
283         }
284 
285         final MessageDigest digester;
286         try {
287             digester = createMessageDigest(digAlg);
288         } catch (final UnsupportedDigestAlgorithmException ex) {
289             throw new AuthenticationException("Unsupported digest algorithm: " + digAlg);
290         }
291 
292         if (nonce.equals(this.lastNonce)) {
293             nounceCount++;
294         } else {
295             nounceCount = 1;
296             cnonce = null;
297             lastNonce = nonce;
298         }
299 
300         final StringBuilder sb = new StringBuilder(8);
301         try (final Formatter formatter = new Formatter(sb, Locale.ROOT)) {
302             formatter.format("%08x", nounceCount);
303         }
304         final String nc = sb.toString();
305 
306         if (cnonce == null) {
307             cnonce = formatHex(createCnonce());
308         }
309 
310         if (buffer == null) {
311             buffer = new ByteArrayBuilder(128);
312         } else {
313             buffer.reset();
314         }
315         buffer.charset(charset);
316 
317         a1 = null;
318         a2 = null;
319         // 3.2.2.2: Calculating digest
320         if (algorithm.equalsIgnoreCase("MD5-sess")) {
321             // H( unq(username-value) ":" unq(realm-value) ":" passwd )
322             //      ":" unq(nonce-value)
323             //      ":" unq(cnonce-value)
324 
325             // calculated one per session
326             buffer.append(username).append(":").append(realm).append(":").append(password);
327             final String checksum = formatHex(digester.digest(this.buffer.toByteArray()));
328             buffer.reset();
329             buffer.append(checksum).append(":").append(nonce).append(":").append(cnonce);
330         } else {
331             // unq(username-value) ":" unq(realm-value) ":" passwd
332             buffer.append(username).append(":").append(realm).append(":").append(password);
333         }
334         a1 = buffer.toByteArray();
335 
336         final String hasha1 = formatHex(digester.digest(a1));
337         buffer.reset();
338 
339         if (qop == QualityOfProtection.AUTH) {
340             // Method ":" digest-uri-value
341             a2 = buffer.append(method).append(":").append(uri).toByteArray();
342         } else if (qop == QualityOfProtection.AUTH_INT) {
343             // Method ":" digest-uri-value ":" H(entity-body)
344             final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
345             if (entity != null && !entity.isRepeatable()) {
346                 // If the entity is not repeatable, try falling back onto QOP_AUTH
347                 if (qopset.contains("auth")) {
348                     qop = QualityOfProtection.AUTH;
349                     a2 = buffer.append(method).append(":").append(uri).toByteArray();
350                 } else {
351                     throw new AuthenticationException("Qop auth-int cannot be used with " +
352                             "a non-repeatable entity");
353                 }
354             } else {
355                 final HttpEntityDigester entityDigester = new HttpEntityDigester(digester);
356                 try {
357                     if (entity != null) {
358                         entity.writeTo(entityDigester);
359                     }
360                     entityDigester.close();
361                 } catch (final IOException ex) {
362                     throw new AuthenticationException("I/O error reading entity content", ex);
363                 }
364                 a2 = buffer.append(method).append(":").append(uri)
365                         .append(":").append(formatHex(entityDigester.getDigest())).toByteArray();
366             }
367         } else {
368             a2 = buffer.append(method).append(":").append(uri).toByteArray();
369         }
370 
371         final String hasha2 = formatHex(digester.digest(a2));
372         buffer.reset();
373 
374         // 3.2.2.1
375 
376         final byte[] digestInput;
377         if (qop == QualityOfProtection.MISSING) {
378             buffer.append(hasha1).append(":").append(nonce).append(":").append(hasha2);
379         } else {
380             buffer.append(hasha1).append(":").append(nonce).append(":").append(nc).append(":")
381                 .append(cnonce).append(":").append(qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth")
382                 .append(":").append(hasha2);
383         }
384         digestInput = buffer.toByteArray();
385         buffer.reset();
386 
387         final String digest = formatHex(digester.digest(digestInput));
388 
389         final CharArrayBuffer buffer = new CharArrayBuffer(128);
390         buffer.append(StandardAuthScheme.DIGEST + " ");
391 
392         final List<BasicNameValuePair> params = new ArrayList<>(20);
393         params.add(new BasicNameValuePair("username", username));
394         params.add(new BasicNameValuePair("realm", realm));
395         params.add(new BasicNameValuePair("nonce", nonce));
396         params.add(new BasicNameValuePair("uri", uri));
397         params.add(new BasicNameValuePair("response", digest));
398 
399         if (qop != QualityOfProtection.MISSING) {
400             params.add(new BasicNameValuePair("qop", qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth"));
401             params.add(new BasicNameValuePair("nc", nc));
402             params.add(new BasicNameValuePair("cnonce", cnonce));
403         }
404         // algorithm cannot be null here
405         params.add(new BasicNameValuePair("algorithm", algorithm));
406         if (opaque != null) {
407             params.add(new BasicNameValuePair("opaque", opaque));
408         }
409 
410         for (int i = 0; i < params.size(); i++) {
411             final BasicNameValuePair param = params.get(i);
412             if (i > 0) {
413                 buffer.append(", ");
414             }
415             final String name = param.getName();
416             final boolean noQuotes = ("nc".equals(name) || "qop".equals(name)
417                     || "algorithm".equals(name));
418             BasicHeaderValueFormatter.INSTANCE.formatNameValuePair(buffer, param, !noQuotes);
419         }
420         return buffer.toString();
421     }
422 
423     @Internal
424     public String getNonce() {
425         return lastNonce;
426     }
427 
428     @Internal
429     public long getNounceCount() {
430         return nounceCount;
431     }
432 
433     @Internal
434     public String getCnonce() {
435         return cnonce;
436     }
437 
438     String getA1() {
439         return a1 != null ? new String(a1, StandardCharsets.US_ASCII) : null;
440     }
441 
442     String getA2() {
443         return a2 != null ? new String(a2, StandardCharsets.US_ASCII) : null;
444     }
445 
446     /**
447      * Encodes the 128 bit (16 bytes) MD5 digest into a 32 characters long
448      * {@code String} according to RFC 2617.
449      *
450      * @param binaryData array containing the digest
451      * @return encoded MD5, or {@code null} if encoding failed
452      */
453     static String formatHex(final byte[] binaryData) {
454         final int n = binaryData.length;
455         final char[] buffer = new char[n * 2];
456         for (int i = 0; i < n; i++) {
457             final int low = (binaryData[i] & 0x0f);
458             final int high = ((binaryData[i] & 0xf0) >> 4);
459             buffer[i * 2] = HEXADECIMAL[high];
460             buffer[(i * 2) + 1] = HEXADECIMAL[low];
461         }
462 
463         return new String(buffer);
464     }
465 
466     /**
467      * Creates a random cnonce value based on the current time.
468      *
469      * @return The cnonce value as String.
470      */
471     static byte[] createCnonce() {
472         final SecureRandom rnd = new SecureRandom();
473         final byte[] tmp = new byte[8];
474         rnd.nextBytes(tmp);
475         return tmp;
476     }
477 
478     private void writeObject(final ObjectOutputStream out) throws IOException {
479         out.defaultWriteObject();
480         out.writeUTF(defaultCharset.name());
481     }
482 
483     private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
484         in.defaultReadObject();
485         this.defaultCharset = Charset.forName(in.readUTF());
486     }
487 
488     @Override
489     public String toString() {
490         return getName() + this.paramMap;
491     }
492 
493 }