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