Added NextNonceInterceptor to check the `Authentication-Info` header in HTTP Digest Access Authentication, extracting the `nextnonce` parameter when present and storing it in `HttpClientContext`. This supports compliance with RFC 7616, enhancing client authentication continuity. (#596)

This commit is contained in:
Arturo Bernal 2024-11-03 15:50:09 +01:00 committed by GitHub
parent 28c3ea0fd2
commit 40d6ba4ee5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 357 additions and 0 deletions

View File

@ -68,6 +68,7 @@ import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.net.PercentCodec; import org.apache.hc.core5.net.PercentCodec;
import org.apache.hc.core5.util.Args; import org.apache.hc.core5.util.Args;
import org.apache.hc.core5.util.CharArrayBuffer; import org.apache.hc.core5.util.CharArrayBuffer;
import org.apache.hc.core5.util.TextUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -253,6 +254,16 @@ public class DigestScheme implements AuthScheme, Serializable {
if (this.paramMap.get("nonce") == null) { if (this.paramMap.get("nonce") == null) {
throw new AuthenticationException("missing nonce"); throw new AuthenticationException("missing nonce");
} }
if (context != null) {
final HttpClientContext clientContext = HttpClientContext.cast(context);
final String nextNonce = clientContext.getNextNonce();
if (!TextUtils.isBlank(nextNonce)) {
this.paramMap.put("nonce", nextNonce);
clientContext.setNextNonce(null);
}
}
return createDigestResponse(request); return createDigestResponse(request);
} }

View File

@ -202,6 +202,23 @@ public class HttpClientContext extends HttpCoreContext {
private Object userToken; private Object userToken;
private RequestConfig requestConfig; private RequestConfig requestConfig;
/**
* Stores the {@code nextnonce} value provided by the server in an HTTP response.
* <p>
* In the context of HTTP Digest Access Authentication, the {@code nextnonce} parameter
* is used by the client in subsequent requests to ensure one-time or session-bound usage
* of nonce values, enhancing security by preventing replay attacks.
* </p>
* <p>
* This field is set by an interceptor or other component that processes the server's
* response containing the {@code Authentication-Info} header. Once used, this value
* may be cleared from the context to avoid reuse.
* </p>
*
* @since 5.5
*/
private String nextNonce;
public HttpClientContext(final HttpContext context) { public HttpClientContext(final HttpContext context) {
super(context); super(context);
} }
@ -449,6 +466,28 @@ public class HttpClientContext extends HttpCoreContext {
this.exchangeId = exchangeId; this.exchangeId = exchangeId;
} }
/**
* Retrieves the stored {@code nextnonce} value.
*
* @return the {@code nextnonce} parameter value, or {@code null} if not set
* @since 5.5
*/
@Internal
public String getNextNonce() {
return nextNonce;
}
/**
* Sets the {@code nextnonce} value directly as an instance attribute.
*
* @param nextNonce the nonce value to set
* @since 5.5
*/
@Internal
public void setNextNonce(final String nextNonce) {
this.nextNonce = nextNonce;
}
/** /**
* Internal adaptor class that delegates all its method calls to a plain {@link HttpContext}. * Internal adaptor class that delegates all its method calls to a plain {@link HttpContext}.
* To be removed in the future. * To be removed in the future.

View File

@ -0,0 +1,142 @@
/*
* ====================================================================
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
* ====================================================================
*
* This software consists of voluntary contributions made by many
* individuals on behalf of the Apache Software Foundation. For more
* information on the Apache Software Foundation, please see
* <http://www.apache.org/>.
*
*/
package org.apache.hc.client5.http.protocol;
import org.apache.hc.core5.annotation.Contract;
import org.apache.hc.core5.annotation.ThreadingBehavior;
import org.apache.hc.core5.http.EntityDetails;
import org.apache.hc.core5.http.FormattedHeader;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.HttpResponseInterceptor;
import org.apache.hc.core5.http.message.ParserCursor;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.util.Args;
import org.apache.hc.core5.util.CharArrayBuffer;
import org.apache.hc.core5.util.TextUtils;
import org.apache.hc.core5.util.Tokenizer;
/**
* {@code NextNonceInterceptor} is an HTTP response interceptor that extracts the {@code nextnonce}
* parameter from the {@code Authentication-Info} header of an HTTP response. This parameter is used
* in HTTP Digest Access Authentication to provide an additional nonce value that the client is expected
* to use in subsequent authentication requests. By retrieving and storing this {@code nextnonce} value,
* the interceptor facilitates one-time nonce implementations and prevents replay attacks by ensuring that
* each request/response interaction includes a fresh nonce.
* <p>
* If present, the extracted {@code nextnonce} value is stored in the {@link HttpContext} under the attribute
* {@code auth-nextnonce}, allowing it to be accessed in subsequent requests. If the header does not contain
* the {@code nextnonce} parameter, no context attribute is set.
* </p>
*
* <p>This implementation adheres to the HTTP/1.1 specification, particularly focusing on the {@code Digest}
* scheme as defined in HTTP Digest Authentication, and parses header tokens using the {@link Tokenizer}
* utility class for robust token parsing.</p>
*
* <p>In the context of HTTP Digest Access Authentication, the {@code nextnonce} parameter is
* a critical part of the security mechanism, designed to mitigate replay attacks and enhance mutual
* authentication security. It provides the server with the ability to set and enforce single-use or
* session-bound nonces, prompting the client to use the provided {@code nextnonce} in its next request.
* This setup helps secure communication by forcing new cryptographic material in each transaction.
* </p>
*
* <p>This interceptor is stateless and thread-safe, making it suitable for use across multiple
* threads and HTTP requests. It should be registered with the HTTP client to enable support
* for advanced authentication mechanisms that require tracking of nonce values.</p>
*
* @since 5.5
*/
@Contract(threading = ThreadingBehavior.STATELESS)
public class NextNonceInterceptor implements HttpResponseInterceptor {
public static final HttpResponseInterceptor INSTANCE = new NextNonceInterceptor();
private final Tokenizer tokenParser = Tokenizer.INSTANCE;
private static final String AUTHENTICATION_INFO_HEADER = "Authentication-Info";
private static final Tokenizer.Delimiter TOKEN_DELIMS = Tokenizer.delimiters('=', ',');
private static final Tokenizer.Delimiter VALUE_DELIMS = Tokenizer.delimiters(',');
/**
* Processes the HTTP response and extracts the {@code nextnonce} parameter from the
* {@code Authentication-Info} header if available, storing it in the provided {@code context}.
*
* @param response the HTTP response containing the {@code Authentication-Info} header
* @param entity the response entity, ignored by this interceptor
* @param context the HTTP context in which to store the {@code nextnonce} parameter
* @throws NullPointerException if either {@code response} or {@code context} is null
*/
@Override
public void process(final HttpResponse response, final EntityDetails entity, final HttpContext context) {
Args.notNull(response, "HTTP response");
Args.notNull(context, "HTTP context");
final Header header = response.getFirstHeader(AUTHENTICATION_INFO_HEADER);
if (header != null) {
final String nextNonce;
if (header instanceof FormattedHeader) {
final CharSequence buf = ((FormattedHeader) header).getBuffer();
final ParserCursor cursor = new ParserCursor(((FormattedHeader) header).getValuePos(), buf.length());
nextNonce = parseNextNonce(buf, cursor);
} else {
final CharSequence headerValue = header.getValue();
final ParserCursor cursor = new ParserCursor(0, headerValue.length());
nextNonce = parseNextNonce(headerValue, cursor);
}
if (!TextUtils.isBlank(nextNonce)) {
HttpClientContext.castOrCreate(context).setNextNonce(nextNonce);
}
}
}
/**
* Parses the {@code Authentication-Info} header content represented by a {@link CharArrayBuffer}
* to extract the {@code nextnonce} parameter.
*
* @param buffer the {@link CharArrayBuffer} containing the value of the {@code Authentication-Info} header
* @param cursor the {@link ParserCursor} used to navigate through the buffer content
* @return the extracted {@code nextnonce} parameter value, or {@code null} if the parameter is not found
*/
private String parseNextNonce(final CharSequence buffer, final ParserCursor cursor) {
while (!cursor.atEnd()) {
final String name = tokenParser.parseToken(buffer, cursor, TOKEN_DELIMS);
if ("nextnonce".equals(name)) {
cursor.updatePos(cursor.getPos() + 1);
return tokenParser.parseValue(buffer, cursor, VALUE_DELIMS);
}
if (!cursor.atEnd()) {
cursor.updatePos(cursor.getPos() + 1);
}
}
return null;
}
}

View File

@ -45,6 +45,7 @@ import org.apache.hc.client5.http.auth.ChallengeType;
import org.apache.hc.client5.http.auth.CredentialsProvider; import org.apache.hc.client5.http.auth.CredentialsProvider;
import org.apache.hc.client5.http.auth.MalformedChallengeException; import org.apache.hc.client5.http.auth.MalformedChallengeException;
import org.apache.hc.client5.http.auth.StandardAuthScheme; import org.apache.hc.client5.http.auth.StandardAuthScheme;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HeaderElement; import org.apache.hc.core5.http.HeaderElement;
@ -928,4 +929,62 @@ class TestDigestScheme {
Assertions.assertNotNull(table.get("rspauth")); Assertions.assertNotNull(table.get("rspauth"));
} }
@Test
void testNextNonceUsageFromContext() throws Exception {
final HttpRequest request = new BasicHttpRequest("GET", "/");
final HttpHost host = new HttpHost("somehost", 80);
final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create()
.add(new AuthScope(host, "realm1", null), "username", "password".toCharArray())
.build();
final HttpClientContext context = HttpClientContext.create();
context.setNextNonce("sampleNextNonce"); // Set `nextNonce` in the context
final DigestScheme authscheme = new DigestScheme();
final String challenge = StandardAuthScheme.DIGEST + " realm=\"realm1\", nonce=\"initialNonce\"";
final AuthChallenge authChallenge = parse(challenge);
authscheme.processChallenge(authChallenge, context);
// Simulate credentials validation and generate auth response
authscheme.isResponseReady(host, credentialsProvider, context);
final String authResponse = authscheme.generateAuthResponse(host, request, context);
// Validate that `sampleNextNonce` (from context) is used in place of the initial nonce
final Map<String, String> paramMap = parseAuthResponse(authResponse);
Assertions.assertEquals("sampleNextNonce", paramMap.get("nonce"), "The nonce should match 'auth-nextnonce' from the context.");
// Verify that `auth-nextnonce` was removed from context after use
Assertions.assertNull(context.getAttribute("auth-nextnonce"), "The next nonce should be removed from the context after use.");
}
@Test
void testNoNextNonceUsageFromContext() throws Exception {
final HttpRequest request = new BasicHttpRequest("GET", "/");
final HttpHost host = new HttpHost("somehost", 80);
final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create()
.add(new AuthScope(host, "realm1", null), "username", "password".toCharArray())
.build();
final HttpClientContext context = HttpClientContext.create();
// Initialize DigestScheme without setting any `nextNonce`
final DigestScheme authscheme = new DigestScheme();
final String challenge = StandardAuthScheme.DIGEST + " realm=\"realm1\", nonce=\"initialNonce\"";
final AuthChallenge authChallenge = parse(challenge);
authscheme.processChallenge(authChallenge, context);
// Simulate credentials validation and generate auth response
authscheme.isResponseReady(host, credentialsProvider, context);
final String authResponse = authscheme.generateAuthResponse(host, request, context);
// Validate that the nonce in the response matches the initial nonce, not a nextNonce
final Map<String, String> paramMap = parseAuthResponse(authResponse);
Assertions.assertEquals("initialNonce", paramMap.get("nonce"), "The nonce should match the initial nonce provided in the challenge.");
// Verify that the context does not contain any `nextNonce` value set
Assertions.assertNull(context.getAttribute("auth-nextnonce"), "The context should not contain a nextNonce attribute.");
}
} }

View File

@ -0,0 +1,106 @@
/*
* ====================================================================
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
* ====================================================================
*
* This software consists of voluntary contributions made by many
* individuals on behalf of the Apache Software Foundation. For more
* information on the Apache Software Foundation, please see
* <http://www.apache.org/>.
*
*/
package org.apache.hc.client5.http.protocol;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.http.message.BasicHttpResponse;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class TestNextNonceInterceptor {
private static final String AUTHENTICATION_INFO_HEADER = "Authentication-Info";
private NextNonceInterceptor interceptor;
private HttpClientContext context;
@BeforeEach
void setUp() {
interceptor = new NextNonceInterceptor();
context = HttpClientContext.create();
}
@Test
void testNoAuthenticationInfoHeader() {
final HttpResponse response = new BasicHttpResponse(200);
interceptor.process(response, null, context);
Assertions.assertNull(context.getNextNonce(),
"Context should not contain nextnonce when the header is missing");
}
@Test
void testAuthenticationInfoHeaderWithoutNextNonce() {
final HttpResponse response = new BasicHttpResponse(200);
response.addHeader(new BasicHeader(AUTHENTICATION_INFO_HEADER, "auth-param=value"));
interceptor.process(response, null, context);
Assertions.assertNull(context.getNextNonce(),
"Context should not contain nextnonce when it is missing in the header value");
}
@Test
void testAuthenticationInfoHeaderWithNextNonce() {
final HttpResponse response = new BasicHttpResponse(200);
response.addHeader(new BasicHeader(AUTHENTICATION_INFO_HEADER, "nextnonce=\"10024b2308596a55d02699c0a0400fb4\",qop=auth,rspauth=\"0386df3cb9effdf08c9e00ab955827f3\",cnonce=\"21558090\",nc=00000001"));
interceptor.process(response, null, context);
Assertions.assertEquals("10024b2308596a55d02699c0a0400fb4", context.getNextNonce(),
"Context should contain the correct nextnonce value when it is present in the header");
}
@Test
void testMultipleAuthenticationInfoHeaders() {
final HttpResponse response = new BasicHttpResponse(200);
response.addHeader(new BasicHeader(AUTHENTICATION_INFO_HEADER, "auth-param=value")); // First header without nextnonce
response.addHeader(new BasicHeader(AUTHENTICATION_INFO_HEADER, "nextnonce=\"10024b2308596a55d02699c0a0400fb4\",qop=auth,rspauth=\"0386df3cb9effdf08c9e00ab955827f3\",cnonce=\"21558090\",nc=00000001")); // Second header with nextnonce
interceptor.process(response, null, context);
// Since only the first header is processed, `auth-nextnonce` should not be set in the context
Assertions.assertNull(context.getNextNonce(),
"Context should not contain nextnonce if it's not in the first Authentication-Info header");
}
@Test
void testAuthenticationInfoHeaderWithEmptyNextNonce() {
final HttpResponse response = new BasicHttpResponse(200);
response.addHeader(new BasicHeader(AUTHENTICATION_INFO_HEADER, "nextnonce=\"\",qop=auth,rspauth=\"0386df3cb9effdf08c9e00ab955827f3\",cnonce=\"21558090\",nc=00000001"));
interceptor.process(response, null, context);
Assertions.assertNull(context.getNextNonce(),
"Context should not contain nextnonce if it is empty in the Authentication-Info header");
}
}