Skip to content

Commit 6222d4b

Browse files
committed
Implement HTTP Digest Access Authentication
1 parent 73911eb commit 6222d4b

File tree

7 files changed

+404
-58
lines changed

7 files changed

+404
-58
lines changed

client/src/main/java/org/asynchttpclient/Realm.java

+39-33
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import static org.asynchttpclient.util.MiscUtils.isNonEmpty;
3636
import static org.asynchttpclient.util.StringUtils.appendBase16;
3737
import static org.asynchttpclient.util.StringUtils.toHexString;
38+
import org.asynchttpclient.util.MessageDigestUtils;
3839

3940
/**
4041
* This class is required when authentication is needed. The class support
@@ -452,62 +453,65 @@ public Builder parseProxyAuthenticateHeader(String headerLine) {
452453
return this;
453454
}
454455

456+
/**
457+
* Extracts the value of a token from a WWW-Authenticate or Proxy-Authenticate header line.
458+
* Example: match('Digest realm="test", nonce="abc"', "realm") returns "test"
459+
*/
460+
private static @Nullable String match(String headerLine, String token) {
461+
if (headerLine == null || token == null) return null;
462+
String pattern = token + "=\"";
463+
int start = headerLine.indexOf(pattern);
464+
if (start == -1) return null;
465+
start += pattern.length();
466+
int end = headerLine.indexOf('"', start);
467+
if (end == -1) return null;
468+
return headerLine.substring(start, end);
469+
}
470+
455471
private void newCnonce(MessageDigest md) {
456472
byte[] b = new byte[8];
457473
ThreadLocalRandom.current().nextBytes(b);
458474
b = md.digest(b);
459475
cnonce = toHexString(b);
460476
}
461477

462-
/**
463-
* TODO: A Pattern/Matcher may be better.
464-
*/
465-
private static @Nullable String match(String headerLine, String token) {
466-
if (headerLine == null) {
467-
return null;
468-
}
469-
470-
int match = headerLine.indexOf(token);
471-
if (match <= 0) {
472-
return null;
473-
}
474-
475-
// = to skip
476-
match += token.length() + 1;
477-
int trailingComa = headerLine.indexOf(',', match);
478-
String value = headerLine.substring(match, trailingComa > 0 ? trailingComa : headerLine.length());
479-
value = value.length() > 0 && value.charAt(value.length() - 1) == '"'
480-
? value.substring(0, value.length() - 1)
481-
: value;
482-
return value.charAt(0) == '"' ? value.substring(1) : value;
483-
}
484-
485-
private static byte[] md5FromRecycledStringBuilder(StringBuilder sb, MessageDigest md) {
478+
private static byte[] digestFromRecycledStringBuilder(StringBuilder sb, MessageDigest md) {
486479
md.update(StringUtils.charSequence2ByteBuffer(sb, ISO_8859_1));
487480
sb.setLength(0);
488481
return md.digest();
489482
}
490483

484+
private static MessageDigest getDigestInstance(String algorithm) {
485+
if (algorithm == null || "MD5".equalsIgnoreCase(algorithm) || "MD5-sess".equalsIgnoreCase(algorithm)) {
486+
return MessageDigestUtils.pooledMd5MessageDigest();
487+
} else if ("SHA-256".equalsIgnoreCase(algorithm) || "SHA-256-sess".equalsIgnoreCase(algorithm)) {
488+
return MessageDigestUtils.pooledSha256MessageDigest();
489+
} else if ("SHA-512-256".equalsIgnoreCase(algorithm) || "SHA-512-256-sess".equalsIgnoreCase(algorithm)) {
490+
return MessageDigestUtils.pooledSha512_256MessageDigest();
491+
} else {
492+
throw new UnsupportedOperationException("Digest algorithm not supported: " + algorithm);
493+
}
494+
}
495+
491496
private byte[] ha1(StringBuilder sb, MessageDigest md) {
492497
// if algorithm is "MD5" or is unspecified => A1 = username ":" realm-value ":"
493498
// passwd
494499
// if algorithm is "MD5-sess" => A1 = MD5( username-value ":" realm-value ":"
495500
// passwd ) ":" nonce-value ":" cnonce-value
496501

497502
sb.append(principal).append(':').append(realmName).append(':').append(password);
498-
byte[] core = md5FromRecycledStringBuilder(sb, md);
503+
byte[] core = digestFromRecycledStringBuilder(sb, md);
499504

500-
if (algorithm == null || "MD5".equals(algorithm)) {
505+
if (algorithm == null || "MD5".equalsIgnoreCase(algorithm) || "SHA-256".equalsIgnoreCase(algorithm) || "SHA-512-256".equalsIgnoreCase(algorithm)) {
501506
// A1 = username ":" realm-value ":" passwd
502507
return core;
503508
}
504-
if ("MD5-sess".equals(algorithm)) {
505-
// A1 = MD5(username ":" realm-value ":" passwd ) ":" nonce ":" cnonce
509+
if ("MD5-sess".equalsIgnoreCase(algorithm) || "SHA-256-sess".equalsIgnoreCase(algorithm) || "SHA-512-256-sess".equalsIgnoreCase(algorithm)) {
510+
// A1 = HASH(username ":" realm-value ":" passwd ) ":" nonce ":" cnonce
506511
appendBase16(sb, core);
507512
sb.append(':').append(nonce).append(':').append(cnonce);
508-
return md5FromRecycledStringBuilder(sb, md);
513+
return digestFromRecycledStringBuilder(sb, md);
509514
}
510-
511515
throw new UnsupportedOperationException("Digest algorithm not supported: " + algorithm);
512516
}
513517

@@ -526,7 +530,7 @@ private byte[] ha2(StringBuilder sb, String digestUri, MessageDigest md) {
526530
throw new UnsupportedOperationException("Digest qop not supported: " + qop);
527531
}
528532

529-
return md5FromRecycledStringBuilder(sb, md);
533+
return digestFromRecycledStringBuilder(sb, md);
530534
}
531535

532536
private void appendMiddlePart(StringBuilder sb) {
@@ -553,7 +557,7 @@ private void newResponse(MessageDigest md) {
553557
appendMiddlePart(sb);
554558
appendBase16(sb, ha2);
555559

556-
byte[] responseDigest = md5FromRecycledStringBuilder(sb, md);
560+
byte[] responseDigest = digestFromRecycledStringBuilder(sb, md);
557561
response = toHexString(responseDigest);
558562
}
559563
}
@@ -567,7 +571,9 @@ public Realm build() {
567571

568572
// Avoid generating
569573
if (isNonEmpty(nonce)) {
570-
MessageDigest md = pooledMd5MessageDigest();
574+
// Defensive: if algorithm is null, default to MD5
575+
String algo = (algorithm != null) ? algorithm : "MD5";
576+
MessageDigest md = getDigestInstance(algo);
571577
newCnonce(md);
572578
newResponse(md);
573579
}

client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java

+2-10
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
* and you may not use this file except in compliance with the Apache License Version 2.0.
66
* You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0.
77
*
8-
* Unless required by applicable law or agreed to in writing,
9-
* software distributed under the Apache License Version 2.0 is distributed on an
8+
* Unless required by applicable law or agreed to in writing, software distributed under the Apache License Version 2.0 is distributed on an
109
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1110
* See the Apache License Version 2.0 for the specific language governing permissions and limitations there under.
1211
*/
@@ -70,9 +69,7 @@ public static String computeRealmURI(Uri uri, boolean useAbsoluteURI, boolean om
7069
}
7170

7271
private static String computeDigestAuthentication(Realm realm, Uri uri) {
73-
7472
String realmUri = computeRealmURI(uri, realm.isUseAbsoluteURI(), realm.isOmitQuery());
75-
7673
StringBuilder builder = new StringBuilder().append("Digest ");
7774
append(builder, "username", realm.getPrincipal(), true);
7875
append(builder, "realm", realm.getRealmName(), true);
@@ -81,22 +78,17 @@ private static String computeDigestAuthentication(Realm realm, Uri uri) {
8178
if (isNonEmpty(realm.getAlgorithm())) {
8279
append(builder, "algorithm", realm.getAlgorithm(), false);
8380
}
84-
8581
append(builder, "response", realm.getResponse(), true);
86-
8782
if (realm.getOpaque() != null) {
8883
append(builder, "opaque", realm.getOpaque(), true);
8984
}
90-
9185
if (realm.getQop() != null) {
9286
append(builder, "qop", realm.getQop(), false);
93-
// nc and cnonce only sent if server sent qop
9487
append(builder, "nc", realm.getNc(), false);
9588
append(builder, "cnonce", realm.getCnonce(), true);
9689
}
90+
// RFC7616: userhash parameter (optional, not implemented yet)
9791
builder.setLength(builder.length() - 2); // remove tailing ", "
98-
99-
// FIXME isn't there a more efficient way?
10092
return new String(StringUtils.charSequence2Bytes(builder, ISO_8859_1), StandardCharsets.UTF_8);
10193
}
10294

client/src/main/java/org/asynchttpclient/util/MessageDigestUtils.java

+74-5
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,88 @@ public final class MessageDigestUtils {
3636
}
3737
});
3838

39+
private static final ThreadLocal<MessageDigest> SHA256_MESSAGE_DIGESTS = ThreadLocal.withInitial(() -> {
40+
try {
41+
return MessageDigest.getInstance("SHA-256");
42+
} catch (NoSuchAlgorithmException e) {
43+
throw new InternalError("SHA-256 not supported on this platform");
44+
}
45+
});
46+
47+
private static final ThreadLocal<MessageDigest> SHA512_256_MESSAGE_DIGESTS = ThreadLocal.withInitial(() -> {
48+
try {
49+
return MessageDigest.getInstance("SHA-512/256");
50+
} catch (NoSuchAlgorithmException e) {
51+
throw new InternalError("SHA-512/256 not supported on this platform");
52+
}
53+
});
54+
3955
private MessageDigestUtils() {
4056
// Prevent outside initialization
4157
}
4258

43-
public static MessageDigest pooledMd5MessageDigest() {
44-
MessageDigest md = MD5_MESSAGE_DIGESTS.get();
59+
/**
60+
* Returns a pooled MessageDigest instance for the given algorithm name.
61+
* Supported: "MD5", "SHA-1", "SHA-256", "SHA-512/256" (and aliases).
62+
* The returned instance is thread-local and reset before use.
63+
*
64+
* @param algorithm the algorithm name (e.g., "MD5", "SHA-256", "SHA-512/256")
65+
* @return a reset MessageDigest instance for the algorithm
66+
* @throws IllegalArgumentException if the algorithm is not supported
67+
*/
68+
public static MessageDigest pooledMessageDigest(String algorithm) {
69+
String alg = algorithm.replace("_", "-").toUpperCase();
70+
MessageDigest md;
71+
switch (alg) {
72+
case "MD5":
73+
md = MD5_MESSAGE_DIGESTS.get();
74+
break;
75+
case "SHA1":
76+
case "SHA-1":
77+
md = SHA1_MESSAGE_DIGESTS.get();
78+
break;
79+
case "SHA-256":
80+
md = SHA256_MESSAGE_DIGESTS.get();
81+
break;
82+
case "SHA-512/256":
83+
md = SHA512_256_MESSAGE_DIGESTS.get();
84+
break;
85+
default:
86+
try {
87+
md = MessageDigest.getInstance(algorithm);
88+
} catch (NoSuchAlgorithmException e) {
89+
throw new IllegalArgumentException("Unsupported digest algorithm: " + algorithm, e);
90+
}
91+
}
4592
md.reset();
4693
return md;
4794
}
4895

96+
/**
97+
* @return a pooled, reset MessageDigest for MD5
98+
*/
99+
public static MessageDigest pooledMd5MessageDigest() {
100+
return pooledMessageDigest("MD5");
101+
}
102+
103+
/**
104+
* @return a pooled, reset MessageDigest for SHA-1
105+
*/
49106
public static MessageDigest pooledSha1MessageDigest() {
50-
MessageDigest md = SHA1_MESSAGE_DIGESTS.get();
51-
md.reset();
52-
return md;
107+
return pooledMessageDigest("SHA-1");
108+
}
109+
110+
/**
111+
* @return a pooled, reset MessageDigest for SHA-256
112+
*/
113+
public static MessageDigest pooledSha256MessageDigest() {
114+
return pooledMessageDigest("SHA-256");
115+
}
116+
117+
/**
118+
* @return a pooled, reset MessageDigest for SHA-512/256
119+
*/
120+
public static MessageDigest pooledSha512_256MessageDigest() {
121+
return pooledMessageDigest("SHA-512/256");
53122
}
54123
}

client/src/test/java/org/asynchttpclient/AuthTimeoutTest.java

+49-2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.junit.jupiter.api.AfterEach;
2424
import org.junit.jupiter.api.BeforeEach;
2525
import org.junit.jupiter.api.Disabled;
26+
import org.asynchttpclient.test.ExtendedDigestAuthenticator;
2627

2728
import java.io.IOException;
2829
import java.io.OutputStream;
@@ -39,7 +40,7 @@
3940
import static org.asynchttpclient.test.TestUtils.ADMIN;
4041
import static org.asynchttpclient.test.TestUtils.USER;
4142
import static org.asynchttpclient.test.TestUtils.addBasicAuthHandler;
42-
import static org.asynchttpclient.test.TestUtils.addDigestAuthHandler;
43+
// import static org.asynchttpclient.test.TestUtils.addDigestAuthHandler;
4344
import static org.asynchttpclient.test.TestUtils.addHttpConnector;
4445
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
4546
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -63,7 +64,8 @@ public void setUpGlobal() throws Exception {
6364

6465
server2 = new Server();
6566
ServerConnector connector2 = addHttpConnector(server2);
66-
addDigestAuthHandler(server2, configureHandler());
67+
// Use DigestAuthHandler for server2 (digest tests), otherwise use default handler
68+
server2.setHandler(new DigestAuthHandler());
6769
server2.start();
6870
port2 = connector2.getLocalPort();
6971

@@ -181,6 +183,51 @@ public AbstractHandler configureHandler() throws Exception {
181183
return new IncompleteResponseHandler();
182184
}
183185

186+
// DigestAuthHandler for Digest tests (MD5 only, as in old Jetty default)
187+
private static class DigestAuthHandler extends AbstractHandler {
188+
private final String realm = "MyRealm";
189+
private final String user = USER;
190+
private final String password = ADMIN;
191+
private final ExtendedDigestAuthenticator authenticator = new ExtendedDigestAuthenticator("MD5");
192+
private final String nonce = ExtendedDigestAuthenticator.newNonce();
193+
194+
@Override
195+
public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
196+
String authz = request.getHeader("Authorization");
197+
if (authz == null || !authz.startsWith("Digest ")) {
198+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
199+
response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, false));
200+
response.getOutputStream().close();
201+
return;
202+
}
203+
String credentials = authz.substring("Digest ".length());
204+
if (!user.equals(ExtendedDigestAuthenticator.parseCredentials(credentials).get("username"))) {
205+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
206+
response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, true));
207+
response.getOutputStream().close();
208+
return;
209+
}
210+
boolean ok = ExtendedDigestAuthenticator.validateDigest(request.getMethod(), credentials, password);
211+
if (!ok) {
212+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
213+
response.setHeader("WWW-Authenticate", authenticator.createAuthenticateHeader(realm, nonce, true));
214+
response.getOutputStream().close();
215+
return;
216+
}
217+
// Success: simulate incomplete response for timeout
218+
response.setStatus(200);
219+
OutputStream out = response.getOutputStream();
220+
response.setIntHeader(CONTENT_LENGTH.toString(), 1000);
221+
out.write(0);
222+
out.flush();
223+
try {
224+
Thread.sleep(LONG_FUTURE_TIMEOUT + 100);
225+
} catch (InterruptedException e) {
226+
//
227+
}
228+
}
229+
}
230+
184231
private static class IncompleteResponseHandler extends AbstractHandler {
185232

186233
@Override

0 commit comments

Comments
 (0)