Skip to content

Commit d3a6fb5

Browse files
committed
RFC 7616: added support to auth-int (phase-1)#2068
1 parent 3536f37 commit d3a6fb5

File tree

3 files changed

+718
-6
lines changed

3 files changed

+718
-6
lines changed

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

Lines changed: 309 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,27 @@
1111
*/
1212
package org.asynchttpclient.util;
1313

14+
import io.netty.buffer.ByteBuf;
15+
import io.netty.buffer.Unpooled;
1416
import org.asynchttpclient.Realm;
1517
import org.asynchttpclient.Request;
1618
import org.asynchttpclient.ntlm.NtlmEngine;
1719
import org.asynchttpclient.proxy.ProxyServer;
20+
import org.asynchttpclient.request.body.Body;
21+
import org.asynchttpclient.request.body.generator.BodyGenerator;
22+
import org.asynchttpclient.request.body.generator.ByteArrayBodyGenerator;
23+
import org.asynchttpclient.request.body.generator.FileBodyGenerator;
1824
import org.asynchttpclient.spnego.SpnegoEngine;
1925
import org.asynchttpclient.spnego.SpnegoEngineException;
2026
import org.asynchttpclient.uri.Uri;
2127
import org.jetbrains.annotations.Nullable;
2228

29+
import java.io.IOException;
30+
import java.nio.ByteBuffer;
2331
import java.nio.charset.Charset;
2432
import java.nio.charset.StandardCharsets;
33+
import java.nio.file.Files;
34+
import java.security.MessageDigest;
2535
import java.util.Base64;
2636
import java.util.List;
2737

@@ -33,6 +43,7 @@
3343
public final class AuthenticatorUtils {
3444

3545
public static final String NEGOTIATE = "Negotiate";
46+
private static final int MAX_AUTH_INT_BODY_SIZE = 10 * 1024 * 1024;
3647

3748
private AuthenticatorUtils() {
3849
// Prevent outside initialization
@@ -98,6 +109,287 @@ private static String computeDigestAuthentication(Realm realm, Uri uri) {
98109
return new String(StringUtils.charSequence2Bytes(builder, wireCs), wireCs);
99110
}
100111

112+
/**
113+
* Calculates the digest response value for HTTP Digest Authentication.
114+
* This method computes HA1 and HA2 (including entity-body hash for auth-int).
115+
*
116+
* @param realm The authentication realm containing credentials and challenge info
117+
* @param request The HTTP request (needed for method, uri, and body)
118+
* @return The computed response hex string
119+
* @throws UnsupportedOperationException if qop=auth-int but body cannot be hashed
120+
*/
121+
static String computeDigestResponse(Realm realm, Request request) {
122+
String algorithm = realm.getAlgorithm() != null ? realm.getAlgorithm() : "MD5";
123+
String qop = realm.getQop() != null ? realm.getQop() : "auth";
124+
125+
String hashAlgorithm = algorithm.replace("-sess", "");
126+
Charset wireCharset = realm.getCharset() != null ?
127+
realm.getCharset() : StandardCharsets.ISO_8859_1;
128+
129+
// Calculate HA1
130+
String ha1 = calculateHA1(realm, algorithm);
131+
132+
// Get request URI
133+
Uri uri = request.getUri();
134+
String requestUri = uri.getPath() +
135+
(uri.getQuery() != null ? "?" + uri.getQuery() : "");
136+
137+
// Calculate HA2
138+
String ha2;
139+
if ("auth-int".equals(qop)) {
140+
String bodyHash = computeBodyHash(request, realm);
141+
ha2 = calculateHA2AuthInt(request, requestUri, bodyHash, hashAlgorithm, wireCharset);
142+
} else {
143+
// Regular auth: HA2 = H(method:uri)
144+
String a2Plain = request.getMethod() + ":" + requestUri;
145+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
146+
try {
147+
md.update(a2Plain.getBytes(wireCharset));
148+
ha2 = MessageDigestUtils.bytesToHex(md.digest());
149+
} finally {
150+
md.reset();
151+
}
152+
}
153+
154+
// Build final response
155+
String nc = realm.getNc() != null ? realm.getNc() : "00000001";
156+
String cnonce = realm.getCnonce();
157+
String nonce = realm.getNonce();
158+
159+
// response = H(HA1:nonce:nc:cnonce:qop:HA2)
160+
String responseInput = ha1 + ":" + nonce + ":" + nc + ":" +
161+
cnonce + ":" + qop + ":" + ha2;
162+
163+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
164+
try {
165+
md.update(responseInput.getBytes(StandardCharsets.ISO_8859_1));
166+
return MessageDigestUtils.bytesToHex(md.digest());
167+
} finally {
168+
md.reset();
169+
}
170+
}
171+
172+
/**
173+
* Calculates the HA1 value for HTTP Digest Authentication.
174+
* This method handles both regular and session-based HA1 calculations.
175+
*
176+
* @param realm The authentication realm containing credentials and challenge info
177+
* @param algorithm The digest algorithm (e.g., "MD5", "MD5-sess")
178+
* @return The computed HA1 hex string
179+
*/
180+
private static String calculateHA1(Realm realm, String algorithm) {
181+
Charset wireCs = realm.getCharset() != null ? realm.getCharset() : StandardCharsets.ISO_8859_1;
182+
String a1Base = realm.getPrincipal() + ':' + realm.getRealmName() + ':' + realm.getPassword();
183+
String hashAlgorithm = algorithm.replace("-sess", "");
184+
185+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
186+
try {
187+
md.update(a1Base.getBytes(wireCs));
188+
String ha1 = MessageDigestUtils.bytesToHex(md.digest());
189+
190+
191+
if (algorithm.endsWith("-sess")) {
192+
// For -sess: HA1 = H(H(username:realm:password):nonce:cnonce)
193+
String sessInput = ha1 + ":" + realm.getNonce() + ":" + realm.getCnonce();
194+
md.reset();
195+
md.update(sessInput.getBytes(StandardCharsets.ISO_8859_1));
196+
ha1 = MessageDigestUtils.bytesToHex(md.digest());
197+
}
198+
199+
return ha1;
200+
} finally {
201+
md.reset();
202+
}
203+
}
204+
205+
/**
206+
* Calculates the HA2 value for HTTP Digest Authentication.
207+
* This method handles both auth and auth-int cases.
208+
*
209+
* @param request The HTTP request (needed for method, uri, and body)
210+
* @param requestUri The request URI
211+
* @param bodyHash The entity-body hash (for auth-int, can be empty for auth)
212+
* @param hashAlgorithm The digest algorithm (e.g., "MD5")
213+
* @param wireCs The charset used for wire encoding
214+
* @return The computed HA2 hex string
215+
*/
216+
private static String calculateHA2AuthInt(Request request, String requestUri, String bodyHash, String hashAlgorithm, Charset wireCs) {
217+
String a2Plain = request.getMethod() + ':' + requestUri + ':' + bodyHash;
218+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
219+
try {
220+
md.update(a2Plain.getBytes(wireCs));
221+
return MessageDigestUtils.bytesToHex(md.digest());
222+
} finally {
223+
md.reset(); // return clean to pool
224+
}
225+
}
226+
227+
static String computeBodyHash(Request request, Realm realm) {
228+
229+
if (request.getStringData() == null &&
230+
request.getByteData() == null &&
231+
request.getByteBufData() == null &&
232+
request.getByteBufferData() == null &&
233+
request.getBodyGenerator() == null) {
234+
235+
// No body to hash, return hash of empty string
236+
237+
String algorithm = realm.getAlgorithm() != null ? realm.getAlgorithm() : "MD5";
238+
String hashAlgorithm = algorithm.replace("-sess", "");
239+
240+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
241+
try {
242+
return MessageDigestUtils.bytesToHex(md.digest());
243+
} finally {
244+
md.reset();
245+
}
246+
}
247+
248+
String algorithm = realm.getAlgorithm() != null ? realm.getAlgorithm() : "MD5";
249+
String hashAlgorithm = algorithm.replace("-sess", "");
250+
Charset charset = resolveCharset(request, realm);
251+
252+
253+
if (request.getStringData() != null) {
254+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
255+
try {
256+
md.update(request.getStringData().getBytes(charset));
257+
return MessageDigestUtils.bytesToHex(md.digest());
258+
} finally {
259+
md.reset();
260+
}
261+
}
262+
263+
if (request.getByteBufData() != null) {
264+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
265+
try {
266+
ByteBuf buf = request.getByteBufData();
267+
int idx = buf.readerIndex();
268+
int len = buf.readableBytes();
269+
270+
byte[] tmp = new byte[len];
271+
buf.getBytes(idx, tmp); // copy once
272+
md.update(tmp);
273+
274+
return MessageDigestUtils.bytesToHex(md.digest());
275+
} finally {
276+
md.reset();
277+
}
278+
}
279+
280+
281+
if (request.getByteBufferData() != null) {
282+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
283+
try {
284+
ByteBuffer bb = request.getByteBufferData().asReadOnlyBuffer();
285+
bb.position(0);
286+
md.update(bb);
287+
return MessageDigestUtils.bytesToHex(md.digest());
288+
} finally {
289+
md.reset();
290+
}
291+
}
292+
293+
if (request.getByteData() != null) {
294+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
295+
try {
296+
md.update(request.getByteData());
297+
return MessageDigestUtils.bytesToHex(md.digest());
298+
} finally {
299+
md.reset();
300+
}
301+
}
302+
303+
// Handle BodyGenerator
304+
if (request.getBodyGenerator() != null) {
305+
return bufferAndHashBodyGenerator(request.getBodyGenerator(), hashAlgorithm);
306+
}
307+
308+
throw new IllegalStateException("Unexpected request body state");
309+
310+
}
311+
312+
/**
313+
* Resolve the charset used to read / hash a request body.
314+
* Order of precedence:
315+
* 1) request.getCharset() – per-request override
316+
* 2) realm.getCharset() – negotiated via RFC 7616 (e.g. UTF-8)
317+
* 3) ISO-8859-1 – RFC default
318+
*/
319+
private static Charset resolveCharset(Request request, Realm realm) {
320+
Charset cs = request.getCharset();
321+
if (cs != null) {
322+
return cs;
323+
}
324+
cs = realm.getCharset();
325+
return (cs != null) ? cs : StandardCharsets.ISO_8859_1;
326+
}
327+
328+
/**
329+
* Buffers the body from the given BodyGenerator and computes its hash.
330+
* This is used for auth-int where the body needs to be hashed.
331+
*
332+
* @param gen The BodyGenerator to read from
333+
* @param hashAlgorithm The hash algorithm to use (e.g., "MD5", "SHA-256")
334+
* @return The hex string of the computed hash
335+
* @throws UnsupportedOperationException if the body is too large or unsupported type
336+
*/
337+
private static String bufferAndHashBodyGenerator(BodyGenerator gen, String hashAlgorithm) {
338+
MessageDigest md = MessageDigestUtils.pooledMessageDigest(hashAlgorithm);
339+
// Size guard
340+
if (gen instanceof ByteArrayBodyGenerator) {
341+
ByteArrayBodyGenerator bag = (ByteArrayBodyGenerator) gen;
342+
343+
long size = bag.createBody().getContentLength();
344+
if (size > MAX_AUTH_INT_BODY_SIZE) {
345+
throw new UnsupportedOperationException("auth-int not supported for ByteArrayBodyGenerator >10 MB");
346+
}
347+
} else if (gen instanceof FileBodyGenerator) {
348+
FileBodyGenerator fg = (FileBodyGenerator) gen;
349+
350+
long fileSize = fg.getFile().length();
351+
if (fileSize > MAX_AUTH_INT_BODY_SIZE) {
352+
throw new UnsupportedOperationException("auth-int not supported for files > 10 MB");
353+
}
354+
try {
355+
byte[] bytes = Files.readAllBytes(fg.getFile().toPath()); // may throw IOException
356+
md.update(bytes);
357+
return MessageDigestUtils.bytesToHex(md.digest());
358+
} catch (IOException ioe) {
359+
throw new RuntimeException("Failed to read file for auth-int hash", ioe);
360+
}
361+
} else {
362+
throw new UnsupportedOperationException("auth-int currently supports only ByteArrayBodyGenerator and FileBodyGenerator");
363+
}
364+
365+
ByteBuf tmp = Unpooled.buffer(8192);
366+
367+
try (Body body = gen.createBody()) {
368+
Body.BodyState state;
369+
while ((state = body.transferTo(tmp)) != Body.BodyState.STOP) {
370+
if (state == Body.BodyState.SUSPEND) {
371+
continue; // nothing new yet
372+
}
373+
int len = tmp.writerIndex();
374+
byte[] buf = new byte[len];
375+
tmp.getBytes(0, buf);
376+
md.update(buf);
377+
tmp.clear();
378+
}
379+
return MessageDigestUtils.bytesToHex(md.digest());
380+
381+
} catch (IOException ioe) {
382+
throw new RuntimeException("Failed to hash request body", ioe);
383+
} finally {
384+
try {
385+
md.reset();
386+
} finally {
387+
tmp.release();
388+
}
389+
}
390+
}
391+
392+
101393
private static void append(StringBuilder builder, String name, @Nullable String value, boolean quoted) {
102394
builder.append(name).append('=');
103395
if (quoted) {
@@ -141,10 +433,16 @@ private static void append(StringBuilder builder, String name, @Nullable String
141433
if (isNonEmpty(proxyRealm.getNonce())) {
142434
// update realm with request information
143435
final Uri uri = request.getUri();
144-
proxyRealm = realm(proxyRealm)
436+
Realm.Builder realmBuilder = realm(proxyRealm)
145437
.setUri(uri)
146-
.setMethodName(request.getMethod())
147-
.build();
438+
.setMethodName(request.getMethod());
439+
440+
if ("auth-int".equals(proxyRealm.getQop())) {
441+
String response = computeDigestResponse(proxyRealm, request);
442+
realmBuilder.setResponse(response);
443+
}
444+
445+
proxyRealm = realmBuilder.build();
148446
proxyAuthorization = computeDigestAuthentication(proxyRealm, uri);
149447
}
150448
break;
@@ -216,10 +514,15 @@ private static void append(StringBuilder builder, String name, @Nullable String
216514
if (isNonEmpty(realm.getNonce())) {
217515
// update realm with request information
218516
final Uri uri = request.getUri();
219-
realm = realm(realm)
517+
Realm.Builder realmBuilder = realm(realm)
220518
.setUri(uri)
221-
.setMethodName(request.getMethod())
222-
.build();
519+
.setMethodName(request.getMethod());
520+
if ("auth-int".equals(realm.getQop())) {
521+
String response = computeDigestResponse(realmBuilder.build(), request);
522+
realmBuilder.setResponse(response);
523+
}
524+
525+
realm = realmBuilder.build();
223526
authorizationHeader = computeDigestAuthentication(realm, uri);
224527
}
225528
break;

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,29 @@ public static MessageDigest pooledMessageDigest(String algorithm) {
101101
return md;
102102
}
103103

104+
/**
105+
* Converts a byte array to a lower-case hexadecimal String.
106+
* Locale-safe and allocation-free except for the final char[] → String copy.
107+
*
108+
* @param bytes the byte array to convert (must not be null)
109+
* @return 2×length lower-case hex string
110+
* @throws IllegalArgumentException if {@code bytes} is null
111+
*/
112+
public static String bytesToHex(byte[] bytes) {
113+
if (bytes == null) {
114+
throw new IllegalArgumentException("bytes == null");
115+
}
116+
final char[] HEX = "0123456789abcdef".toCharArray();
117+
char[] out = new char[bytes.length << 1];
118+
119+
for (int i = 0, j = 0; i < bytes.length; i++) {
120+
int v = bytes[i] & 0xFF;
121+
out[j++] = HEX[v >>> 4];
122+
out[j++] = HEX[v & 0x0F];
123+
}
124+
return new String(out);
125+
}
126+
104127
/**
105128
* @return a pooled, reset MessageDigest for MD5
106129
*/

0 commit comments

Comments
 (0)