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