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.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.
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     /**
105      * Represent the possible values of quality of protection.
106      */
107     private enum QualityOfProtection {
108         UNKNOWN, MISSING, AUTH_INT, AUTH
109     }
110 
111     private transient Charset defaultCharset;
112     private final Map<String, String> paramMap;
113     private boolean complete;
114     private transient ByteArrayBuilder buffer;
115 
116     private String lastNonce;
117     private long nounceCount;
118     private String cnonce;
119     private byte[] a1;
120     private byte[] a2;
121 
122     private UsernamePasswordCredentials credentials;
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         Args.check(credentials instanceof UsernamePasswordCredentials,
137                 "Unsupported credential type: " + credentials.getClass());
138         this.credentials = (UsernamePasswordCredentials) credentials;
139         this.paramMap.put("cnonce", cnonce);
140         this.paramMap.put("realm", realm);
141     }
142 
143     @Override
144     public String getName() {
145         return StandardAuthScheme.DIGEST;
146     }
147 
148     @Override
149     public boolean isConnectionBased() {
150         return false;
151     }
152 
153     @Override
154     public String getRealm() {
155         return this.paramMap.get("realm");
156     }
157 
158     @Override
159     public void processChallenge(
160             final AuthChallenge authChallenge,
161             final HttpContext context) throws MalformedChallengeException {
162         Args.notNull(authChallenge, "AuthChallenge");
163         this.paramMap.clear();
164         final List<NameValuePair> params = authChallenge.getParams();
165         if (params != null) {
166             for (final NameValuePair param: params) {
167                 this.paramMap.put(param.getName().toLowerCase(Locale.ROOT), param.getValue());
168             }
169         }
170         if (this.paramMap.isEmpty()) {
171             throw new MalformedChallengeException("Missing digest auth parameters");
172         }
173         this.complete = true;
174     }
175 
176     @Override
177     public boolean isChallengeComplete() {
178         final String s = this.paramMap.get("stale");
179         return !"true".equalsIgnoreCase(s) && this.complete;
180     }
181 
182     @Override
183     public boolean isResponseReady(
184             final HttpHost host,
185             final CredentialsProvider credentialsProvider,
186             final HttpContext context) throws AuthenticationException {
187 
188         Args.notNull(host, "Auth host");
189         Args.notNull(credentialsProvider, "CredentialsProvider");
190 
191         final AuthScope authScope = new AuthScope(host, getRealm(), getName());
192         final Credentials credentials = credentialsProvider.getCredentials(
193                 authScope, context);
194         if (credentials instanceof UsernamePasswordCredentials) {
195             this.credentials = (UsernamePasswordCredentials) credentials;
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.credentials = 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         if (credentials == null) {
242             throw new AuthenticationException("User credentials have not been provided");
243         }
244         final String uri = request.getRequestUri();
245         final String method = request.getMethod();
246         final String realm = this.paramMap.get("realm");
247         final String nonce = this.paramMap.get("nonce");
248         final String opaque = this.paramMap.get("opaque");
249         final String algorithm = this.paramMap.get("algorithm");
250 
251         final Set<String> qopset = new HashSet<>(8);
252         QualityOfProtection qop = QualityOfProtection.UNKNOWN;
253         final String qoplist = this.paramMap.get("qop");
254         if (qoplist != null) {
255             final StringTokenizer tok = new StringTokenizer(qoplist, ",");
256             while (tok.hasMoreTokens()) {
257                 final String variant = tok.nextToken().trim();
258                 qopset.add(variant.toLowerCase(Locale.ROOT));
259             }
260             final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
261             if (entity != null && qopset.contains("auth-int")) {
262                 qop = QualityOfProtection.AUTH_INT;
263             } else if (qopset.contains("auth")) {
264                 qop = QualityOfProtection.AUTH;
265             } else if (qopset.contains("auth-int")) {
266                 qop = QualityOfProtection.AUTH_INT;
267             }
268         } else {
269             qop = QualityOfProtection.MISSING;
270         }
271 
272         if (qop == QualityOfProtection.UNKNOWN) {
273             throw new AuthenticationException("None of the qop methods is supported: " + qoplist);
274         }
275 
276         final Charset charset = AuthSchemeSupport.parseCharset(paramMap.get("charset"), defaultCharset);
277         String digAlg = algorithm;
278         // If an algorithm is not specified, default to MD5.
279         if (digAlg == null || digAlg.equalsIgnoreCase("MD5-sess")) {
280             digAlg = "MD5";
281         }
282 
283         final MessageDigest digester;
284         try {
285             digester = createMessageDigest(digAlg);
286         } catch (final UnsupportedDigestAlgorithmException ex) {
287             throw new AuthenticationException("Unsupported digest algorithm: " + digAlg);
288         }
289 
290         if (nonce.equals(this.lastNonce)) {
291             nounceCount++;
292         } else {
293             nounceCount = 1;
294             cnonce = null;
295             lastNonce = nonce;
296         }
297 
298         final StringBuilder sb = new StringBuilder(8);
299         try (final Formatter formatter = new Formatter(sb, Locale.ROOT)) {
300             formatter.format("%08x", nounceCount);
301         }
302         final String nc = sb.toString();
303 
304         if (cnonce == null) {
305             cnonce = formatHex(createCnonce());
306         }
307 
308         if (buffer == null) {
309             buffer = new ByteArrayBuilder(128);
310         } else {
311             buffer.reset();
312         }
313         buffer.charset(charset);
314 
315         a1 = null;
316         a2 = null;
317         // 3.2.2.2: Calculating digest
318         if ("MD5-sess".equalsIgnoreCase(algorithm)) {
319             // H( unq(username-value) ":" unq(realm-value) ":" passwd )
320             //      ":" unq(nonce-value)
321             //      ":" unq(cnonce-value)
322 
323             // calculated one per session
324             buffer.append(credentials.getUserName()).append(":").append(realm).append(":").append(credentials.getUserPassword());
325             final String checksum = formatHex(digester.digest(this.buffer.toByteArray()));
326             buffer.reset();
327             buffer.append(checksum).append(":").append(nonce).append(":").append(cnonce);
328         } else {
329             // unq(username-value) ":" unq(realm-value) ":" passwd
330             buffer.append(credentials.getUserName()).append(":").append(realm).append(":").append(credentials.getUserPassword());
331         }
332         a1 = buffer.toByteArray();
333 
334         final String hasha1 = formatHex(digester.digest(a1));
335         buffer.reset();
336 
337         if (qop == QualityOfProtection.AUTH) {
338             // Method ":" digest-uri-value
339             a2 = buffer.append(method).append(":").append(uri).toByteArray();
340         } else if (qop == QualityOfProtection.AUTH_INT) {
341             // Method ":" digest-uri-value ":" H(entity-body)
342             final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
343             if (entity != null && !entity.isRepeatable()) {
344                 // If the entity is not repeatable, try falling back onto QOP_AUTH
345                 if (qopset.contains("auth")) {
346                     qop = QualityOfProtection.AUTH;
347                     a2 = buffer.append(method).append(":").append(uri).toByteArray();
348                 } else {
349                     throw new AuthenticationException("Qop auth-int cannot be used with " +
350                             "a non-repeatable entity");
351                 }
352             } else {
353                 final HttpEntityDigester entityDigester = new HttpEntityDigester(digester);
354                 try {
355                     if (entity != null) {
356                         entity.writeTo(entityDigester);
357                     }
358                     entityDigester.close();
359                 } catch (final IOException ex) {
360                     throw new AuthenticationException("I/O error reading entity content", ex);
361                 }
362                 a2 = buffer.append(method).append(":").append(uri)
363                         .append(":").append(formatHex(entityDigester.getDigest())).toByteArray();
364             }
365         } else {
366             a2 = buffer.append(method).append(":").append(uri).toByteArray();
367         }
368 
369         final String hasha2 = formatHex(digester.digest(a2));
370         buffer.reset();
371 
372         // 3.2.2.1
373 
374         final byte[] digestInput;
375         if (qop == QualityOfProtection.MISSING) {
376             buffer.append(hasha1).append(":").append(nonce).append(":").append(hasha2);
377         } else {
378             buffer.append(hasha1).append(":").append(nonce).append(":").append(nc).append(":")
379                 .append(cnonce).append(":").append(qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth")
380                 .append(":").append(hasha2);
381         }
382         digestInput = buffer.toByteArray();
383         buffer.reset();
384 
385         final String digest = formatHex(digester.digest(digestInput));
386 
387         final CharArrayBuffer buffer = new CharArrayBuffer(128);
388         buffer.append(StandardAuthScheme.DIGEST + " ");
389 
390         final List<BasicNameValuePair> params = new ArrayList<>(20);
391         params.add(new BasicNameValuePair("username", credentials.getUserName()));
392         params.add(new BasicNameValuePair("realm", realm));
393         params.add(new BasicNameValuePair("nonce", nonce));
394         params.add(new BasicNameValuePair("uri", uri));
395         params.add(new BasicNameValuePair("response", digest));
396 
397         if (qop != QualityOfProtection.MISSING) {
398             params.add(new BasicNameValuePair("qop", qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth"));
399             params.add(new BasicNameValuePair("nc", nc));
400             params.add(new BasicNameValuePair("cnonce", cnonce));
401         }
402         if (algorithm != null) {
403             params.add(new BasicNameValuePair("algorithm", algorithm));
404         }
405         if (opaque != null) {
406             params.add(new BasicNameValuePair("opaque", opaque));
407         }
408 
409         for (int i = 0; i < params.size(); i++) {
410             final BasicNameValuePair param = params.get(i);
411             if (i > 0) {
412                 buffer.append(", ");
413             }
414             final String name = param.getName();
415             final boolean noQuotes = ("nc".equals(name) || "qop".equals(name)
416                     || "algorithm".equals(name));
417             BasicHeaderValueFormatter.INSTANCE.formatNameValuePair(buffer, param, !noQuotes);
418         }
419         return buffer.toString();
420     }
421 
422     @Internal
423     public String getNonce() {
424         return lastNonce;
425     }
426 
427     @Internal
428     public long getNounceCount() {
429         return nounceCount;
430     }
431 
432     @Internal
433     public String getCnonce() {
434         return cnonce;
435     }
436 
437     String getA1() {
438         return a1 != null ? new String(a1, StandardCharsets.US_ASCII) : null;
439     }
440 
441     String getA2() {
442         return a2 != null ? new String(a2, StandardCharsets.US_ASCII) : null;
443     }
444 
445     /**
446      * Encodes the 128 bit (16 bytes) MD5 digest into a 32 characters long string.
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 }