1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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.net.PercentCodec;
69 import org.apache.hc.core5.util.Args;
70 import org.apache.hc.core5.util.CharArrayBuffer;
71 import org.apache.hc.core5.util.TextUtils;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89 public class DigestScheme implements AuthScheme, Serializable {
90
91 private static final long serialVersionUID = 3883908186234566916L;
92
93 private static final Logger LOG = LoggerFactory.getLogger(DigestScheme.class);
94
95
96
97
98
99
100
101 private static final char[] HEXADECIMAL = {
102 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
103 'e', 'f'
104 };
105
106
107
108
109 private enum QualityOfProtection {
110 UNKNOWN, MISSING, AUTH_INT, AUTH
111 }
112
113 private transient Charset defaultCharset;
114 private final Map<String, String> paramMap;
115 private boolean complete;
116 private transient ByteArrayBuilder buffer;
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133 private boolean userhashSupported = false;
134
135
136 private String lastNonce;
137 private long nounceCount;
138 private String cnonce;
139 private byte[] a1;
140 private byte[] a2;
141
142 private UsernamePasswordCredentials credentials;
143
144 public DigestScheme() {
145 this.defaultCharset = StandardCharsets.UTF_8;
146 this.paramMap = new HashMap<>();
147 this.complete = false;
148 }
149
150
151
152
153
154
155
156 @Deprecated
157 public DigestScheme(final Charset charset) {
158 this();
159 }
160
161 public void initPreemptive(final Credentials credentials, final String cnonce, final String realm) {
162 Args.notNull(credentials, "Credentials");
163 Args.check(credentials instanceof UsernamePasswordCredentials,
164 "Unsupported credential type: " + credentials.getClass());
165 this.credentials = (UsernamePasswordCredentials) credentials;
166 this.paramMap.put("cnonce", cnonce);
167 this.paramMap.put("realm", realm);
168 }
169
170 @Override
171 public String getName() {
172 return StandardAuthScheme.DIGEST;
173 }
174
175 @Override
176 public boolean isConnectionBased() {
177 return false;
178 }
179
180 @Override
181 public String getRealm() {
182 return this.paramMap.get("realm");
183 }
184
185 @Override
186 public void processChallenge(
187 final AuthChallenge authChallenge,
188 final HttpContext context) throws MalformedChallengeException {
189 Args.notNull(authChallenge, "AuthChallenge");
190 this.paramMap.clear();
191 final List<NameValuePair> params = authChallenge.getParams();
192 if (params != null) {
193 for (final NameValuePair param: params) {
194 this.paramMap.put(param.getName().toLowerCase(Locale.ROOT), param.getValue());
195 }
196 }
197 if (this.paramMap.isEmpty()) {
198 throw new MalformedChallengeException("Missing digest auth parameters");
199 }
200
201 final String userHashValue = this.paramMap.get("userhash");
202 this.userhashSupported = "true".equalsIgnoreCase(userHashValue);
203
204 this.complete = true;
205 }
206
207 @Override
208 public boolean isChallengeComplete() {
209 final String s = this.paramMap.get("stale");
210 return !"true".equalsIgnoreCase(s) && this.complete;
211 }
212
213 @Override
214 public boolean isResponseReady(
215 final HttpHost host,
216 final CredentialsProvider credentialsProvider,
217 final HttpContext context) throws AuthenticationException {
218
219 Args.notNull(host, "Auth host");
220 Args.notNull(credentialsProvider, "CredentialsProvider");
221
222 final AuthScope authScope = new AuthScope(host, getRealm(), getName());
223 final Credentials credentials = credentialsProvider.getCredentials(
224 authScope, context);
225 if (credentials instanceof UsernamePasswordCredentials) {
226 this.credentials = (UsernamePasswordCredentials) credentials;
227 return true;
228 }
229
230 if (LOG.isDebugEnabled()) {
231 final HttpClientContext clientContext = HttpClientContext.cast(context);
232 final String exchangeId = clientContext.getExchangeId();
233 LOG.debug("{} No credentials found for auth scope [{}]", exchangeId, authScope);
234 }
235 this.credentials = null;
236 return false;
237 }
238
239 @Override
240 public Principal getPrincipal() {
241 return null;
242 }
243
244 @Override
245 public String generateAuthResponse(
246 final HttpHost host,
247 final HttpRequest request,
248 final HttpContext context) throws AuthenticationException {
249
250 Args.notNull(request, "HTTP request");
251 if (this.paramMap.get("realm") == null) {
252 throw new AuthenticationException("missing realm");
253 }
254 if (this.paramMap.get("nonce") == null) {
255 throw new AuthenticationException("missing nonce");
256 }
257
258 if (context != null) {
259 final HttpClientContext clientContext = HttpClientContext.cast(context);
260 final String nextNonce = clientContext.getNextNonce();
261 if (!TextUtils.isBlank(nextNonce)) {
262 this.paramMap.put("nonce", nextNonce);
263 clientContext.setNextNonce(null);
264 }
265 }
266
267 return createDigestResponse(request);
268 }
269
270 private static MessageDigest createMessageDigest(
271 final String digAlg) throws UnsupportedDigestAlgorithmException {
272 try {
273 return MessageDigest.getInstance(digAlg);
274 } catch (final Exception e) {
275 throw new UnsupportedDigestAlgorithmException(
276 "Unsupported algorithm in HTTP Digest authentication: "
277 + digAlg);
278 }
279 }
280
281 private String createDigestResponse(final HttpRequest request) throws AuthenticationException {
282 if (credentials == null) {
283 throw new AuthenticationException("User credentials have not been provided");
284 }
285 final String uri = request.getRequestUri();
286 final String method = request.getMethod();
287 final String realm = this.paramMap.get("realm");
288 final String nonce = this.paramMap.get("nonce");
289 final String opaque = this.paramMap.get("opaque");
290 final String algorithm = this.paramMap.get("algorithm");
291
292 final Set<String> qopset = new HashSet<>(8);
293 QualityOfProtection qop = QualityOfProtection.UNKNOWN;
294 final String qoplist = this.paramMap.get("qop");
295 if (qoplist != null) {
296 final StringTokenizer tok = new StringTokenizer(qoplist, ",");
297 while (tok.hasMoreTokens()) {
298 final String variant = tok.nextToken().trim();
299 qopset.add(variant.toLowerCase(Locale.ROOT));
300 }
301 final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
302 if (entity != null && qopset.contains("auth-int")) {
303 qop = QualityOfProtection.AUTH_INT;
304 } else if (qopset.contains("auth")) {
305 qop = QualityOfProtection.AUTH;
306 } else if (qopset.contains("auth-int")) {
307 qop = QualityOfProtection.AUTH_INT;
308 }
309 } else {
310 qop = QualityOfProtection.MISSING;
311 }
312
313 if (qop == QualityOfProtection.UNKNOWN) {
314 throw new AuthenticationException("None of the qop methods is supported: " + qoplist);
315 }
316
317 final Charset charset = AuthSchemeSupport.parseCharset(paramMap.get("charset"), defaultCharset);
318
319
320
321 DigestAlgorithm digAlg = null;
322
323 final MessageDigest digester;
324 try {
325 digAlg = DigestAlgorithm.fromString(algorithm == null ? "MD5" : algorithm);
326 digester = createMessageDigest(digAlg.getBaseAlgorithm());
327 } catch (final UnsupportedDigestAlgorithmException ex) {
328 throw new AuthenticationException("Unsupported digest algorithm: " + digAlg);
329 }
330
331 if (nonce.equals(this.lastNonce)) {
332 nounceCount++;
333 } else {
334 nounceCount = 1;
335 cnonce = null;
336 lastNonce = nonce;
337 }
338
339 final StringBuilder sb = new StringBuilder(8);
340 try (final Formatter formatter = new Formatter(sb, Locale.ROOT)) {
341 formatter.format("%08x", nounceCount);
342 }
343 final String nc = sb.toString();
344
345 if (cnonce == null) {
346 cnonce = formatHex(createCnonce(digAlg));
347 }
348
349 if (buffer == null) {
350 buffer = new ByteArrayBuilder(128);
351 } else {
352 buffer.reset();
353 }
354 buffer.charset(charset);
355
356 a1 = null;
357 a2 = null;
358
359
360
361 String username = credentials.getUserName();
362 String encodedUsername = null;
363
364 if (username != null && containsInvalidABNFChars(username)) {
365 encodedUsername = "UTF-8''" + PercentCodec.RFC5987.encode(username);
366 }
367
368 final String usernameForDigest;
369 if (this.userhashSupported) {
370 final String usernameRealm = username + ":" + realm;
371 final byte[] hashedBytes = digester.digest(usernameRealm.getBytes(StandardCharsets.UTF_8));
372 usernameForDigest = formatHex(hashedBytes);
373 username = usernameForDigest;
374 } else if (encodedUsername != null) {
375 usernameForDigest = encodedUsername;
376 } else {
377 usernameForDigest = username;
378 }
379
380
381 if (digAlg.isSessionBased()) {
382
383
384
385
386
387 buffer.append(username).append(":").append(realm).append(":").append(credentials.getUserPassword());
388 final String checksum = formatHex(digester.digest(this.buffer.toByteArray()));
389 buffer.reset();
390 buffer.append(checksum).append(":").append(nonce).append(":").append(cnonce);
391 } else {
392
393 buffer.append(username).append(":").append(realm).append(":").append(credentials.getUserPassword());
394 }
395 a1 = buffer.toByteArray();
396
397 final String hasha1 = formatHex(digester.digest(a1));
398 buffer.reset();
399
400 if (qop == QualityOfProtection.AUTH) {
401
402 a2 = buffer.append(method).append(":").append(uri).toByteArray();
403 } else if (qop == QualityOfProtection.AUTH_INT) {
404
405 final HttpEntity entity = request instanceof ClassicHttpRequest ? ((ClassicHttpRequest) request).getEntity() : null;
406 if (entity != null && !entity.isRepeatable()) {
407
408 if (qopset.contains("auth")) {
409 qop = QualityOfProtection.AUTH;
410 a2 = buffer.append(method).append(":").append(uri).toByteArray();
411 } else {
412 throw new AuthenticationException("Qop auth-int cannot be used with " +
413 "a non-repeatable entity");
414 }
415 } else {
416 final HttpEntityDigester entityDigester = new HttpEntityDigester(digester);
417 try {
418 if (entity != null) {
419 entity.writeTo(entityDigester);
420 }
421 entityDigester.close();
422 } catch (final IOException ex) {
423 throw new AuthenticationException("I/O error reading entity content", ex);
424 }
425 a2 = buffer.append(method).append(":").append(uri)
426 .append(":").append(formatHex(entityDigester.getDigest())).toByteArray();
427 }
428 } else {
429 a2 = buffer.append(method).append(":").append(uri).toByteArray();
430 }
431
432 final String hasha2 = formatHex(digester.digest(a2));
433 buffer.reset();
434
435
436
437 final byte[] digestInput;
438 if (qop == QualityOfProtection.MISSING) {
439 buffer.append(hasha1).append(":").append(nonce).append(":").append(hasha2);
440 } else {
441 buffer.append(hasha1).append(":").append(nonce).append(":").append(nc).append(":")
442 .append(cnonce).append(":").append(qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth")
443 .append(":").append(hasha2);
444 }
445 digestInput = buffer.toByteArray();
446 buffer.reset();
447
448 final String digest = formatHex(digester.digest(digestInput));
449
450 final CharArrayBuffer buffer = new CharArrayBuffer(128);
451 buffer.append(StandardAuthScheme.DIGEST + " ");
452
453 final List<BasicNameValuePair> params = new ArrayList<>(20);
454 if (this.userhashSupported) {
455
456 params.add(new BasicNameValuePair("username", usernameForDigest));
457 params.add(new BasicNameValuePair("userhash", "true"));
458 } else if (encodedUsername != null) {
459
460 params.add(new BasicNameValuePair("username*", encodedUsername));
461 } else {
462
463 params.add(new BasicNameValuePair("username", username));
464 }
465 params.add(new BasicNameValuePair("realm", realm));
466 params.add(new BasicNameValuePair("nonce", nonce));
467 params.add(new BasicNameValuePair("uri", uri));
468 params.add(new BasicNameValuePair("response", digest));
469
470 if (qop != QualityOfProtection.MISSING) {
471 params.add(new BasicNameValuePair("qop", qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth"));
472 params.add(new BasicNameValuePair("nc", nc));
473 params.add(new BasicNameValuePair("cnonce", cnonce));
474 params.add(new BasicNameValuePair("rspauth", hasha2));
475 }
476 if (algorithm != null) {
477 params.add(new BasicNameValuePair("algorithm", algorithm));
478 }
479 if (opaque != null) {
480 params.add(new BasicNameValuePair("opaque", opaque));
481 }
482
483 for (int i = 0; i < params.size(); i++) {
484 final BasicNameValuePair param = params.get(i);
485 if (i > 0) {
486 buffer.append(", ");
487 }
488 final String name = param.getName();
489 final boolean noQuotes = "nc".equals(name) || "qop".equals(name)
490 || "algorithm".equals(name);
491 BasicHeaderValueFormatter.INSTANCE.formatNameValuePair(buffer, param, !noQuotes);
492 }
493 return buffer.toString();
494 }
495
496 @Internal
497 public String getNonce() {
498 return lastNonce;
499 }
500
501 @Internal
502 public long getNounceCount() {
503 return nounceCount;
504 }
505
506 @Internal
507 public String getCnonce() {
508 return cnonce;
509 }
510
511 String getA1() {
512 return a1 != null ? new String(a1, StandardCharsets.US_ASCII) : null;
513 }
514
515 String getA2() {
516 return a2 != null ? new String(a2, StandardCharsets.US_ASCII) : null;
517 }
518
519
520
521
522
523
524
525
526
527
528
529
530 static String formatHex(final byte[] binaryData) {
531 final int n = binaryData.length;
532 final char[] buffer = new char[n * 2];
533 for (int i = 0; i < n; i++) {
534 final int low = binaryData[i] & 0x0f;
535 final int high = (binaryData[i] & 0xf0) >> 4;
536 buffer[i * 2] = HEXADECIMAL[high];
537 buffer[(i * 2) + 1] = HEXADECIMAL[low];
538 }
539 return new String(buffer);
540 }
541
542
543
544
545
546
547
548
549
550
551 static byte[] createCnonce(final DigestAlgorithm algorithm) {
552 final SecureRandom rnd = new SecureRandom();
553 final int length;
554 switch (algorithm.name().toUpperCase()) {
555 case "SHA-256":
556 case "SHA-512/256":
557 length = 32;
558 break;
559 case "MD5":
560 default:
561 length = 16;
562 break;
563 }
564 final byte[] tmp = new byte[length];
565 rnd.nextBytes(tmp);
566 return tmp;
567 }
568
569
570 private void writeObject(final ObjectOutputStream out) throws IOException {
571 out.defaultWriteObject();
572 out.writeUTF(defaultCharset.name());
573 }
574
575 private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
576 in.defaultReadObject();
577 this.defaultCharset = Charset.forName(in.readUTF());
578 }
579
580 @Override
581 public String toString() {
582 return getName() + this.paramMap;
583 }
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599 private boolean containsInvalidABNFChars(final String value) {
600 if (value == null) {
601 throw new IllegalArgumentException("Input string should not be null.");
602 }
603
604 for (int i = 0; i < value.length(); i++) {
605 final char c = value.charAt(i);
606
607
608 if (c <= 0x1F || c == 0x7F) {
609 return true;
610 }
611
612
613 if (c > 0x7E) {
614 return true;
615 }
616
617
618 if (c == '"' || c == '\\') {
619 return true;
620 }
621 }
622 return false;
623 }
624
625
626
627
628
629 private enum DigestAlgorithm {
630
631
632
633
634 MD5("MD5", false),
635
636
637
638
639 MD5_SESS("MD5", true),
640
641
642
643
644 SHA_256("SHA-256", false),
645
646
647
648
649 SHA_256_SESS("SHA-256", true),
650
651
652
653
654 SHA_512_256("SHA-512/256", false),
655
656
657
658
659 SHA_512_256_SESS("SHA-512/256", true);
660
661 private final String baseAlgorithm;
662 private final boolean sessionBased;
663
664
665
666
667
668
669
670 DigestAlgorithm(final String baseAlgorithm, final boolean sessionBased) {
671 this.baseAlgorithm = baseAlgorithm;
672 this.sessionBased = sessionBased;
673 }
674
675
676
677
678
679
680 private String getBaseAlgorithm() {
681 return baseAlgorithm;
682 }
683
684
685
686
687
688
689 private boolean isSessionBased() {
690 return sessionBased;
691 }
692
693
694
695
696
697
698
699
700 private static DigestAlgorithm fromString(final String algorithm) {
701 switch (algorithm.toUpperCase(Locale.ROOT)) {
702 case "MD5":
703 return MD5;
704 case "MD5-SESS":
705 return MD5_SESS;
706 case "SHA-256":
707 return SHA_256;
708 case "SHA-256-SESS":
709 return SHA_256_SESS;
710 case "SHA-512/256":
711 case "SHA-512-256":
712 return SHA_512_256;
713 case "SHA-512-256-SESS":
714 return SHA_512_256_SESS;
715 default:
716 throw new UnsupportedDigestAlgorithmException("Unsupported digest algorithm: " + algorithm);
717 }
718 }
719 }
720
721
722 }