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:
parent
28c3ea0fd2
commit
40d6ba4ee5
|
@ -68,6 +68,7 @@ import org.apache.hc.core5.http.protocol.HttpContext;
|
|||
import org.apache.hc.core5.net.PercentCodec;
|
||||
import org.apache.hc.core5.util.Args;
|
||||
import org.apache.hc.core5.util.CharArrayBuffer;
|
||||
import org.apache.hc.core5.util.TextUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
|
@ -253,6 +254,16 @@ public class DigestScheme implements AuthScheme, Serializable {
|
|||
if (this.paramMap.get("nonce") == null) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -202,6 +202,23 @@ public class HttpClientContext extends HttpCoreContext {
|
|||
private Object userToken;
|
||||
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) {
|
||||
super(context);
|
||||
}
|
||||
|
@ -449,6 +466,28 @@ public class HttpClientContext extends HttpCoreContext {
|
|||
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}.
|
||||
* To be removed in the future.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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.MalformedChallengeException;
|
||||
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.ContentType;
|
||||
import org.apache.hc.core5.http.HeaderElement;
|
||||
|
@ -928,4 +929,62 @@ class TestDigestScheme {
|
|||
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.");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue