You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ab...@apache.org on 2024/11/03 14:50:14 UTC

(httpcomponents-client) branch master updated: 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 is an automated email from the ASF dual-hosted git repository.

abernal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git


The following commit(s) were added to refs/heads/master by this push:
     new 40d6ba4ee 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)
40d6ba4ee is described below

commit 40d6ba4ee5e39a00461a30503ff9aa98a9c8f0a2
Author: Arturo Bernal <ab...@apache.org>
AuthorDate: Sun Nov 3 15:50:09 2024 +0100

    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)
---
 .../hc/client5/http/impl/auth/DigestScheme.java    |  11 ++
 .../client5/http/protocol/HttpClientContext.java   |  39 ++++++
 .../http/protocol/NextNonceInterceptor.java        | 142 +++++++++++++++++++++
 .../client5/http/impl/auth/TestDigestScheme.java   |  59 +++++++++
 .../http/protocol/TestNextNonceInterceptor.java    | 106 +++++++++++++++
 5 files changed, 357 insertions(+)

diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java
index ec9ea048e..85ed712d2 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java
@@ -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);
     }
 
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java
index f58a8042b..575f67cd6 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java
@@ -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.
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/NextNonceInterceptor.java b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/NextNonceInterceptor.java
new file mode 100644
index 000000000..a5bb2a471
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/NextNonceInterceptor.java
@@ -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;
+    }
+
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java
index 603d8ccc4..af8204b85 100644
--- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java
@@ -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.");
+    }
+
+
 }
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/protocol/TestNextNonceInterceptor.java b/httpclient5/src/test/java/org/apache/hc/client5/http/protocol/TestNextNonceInterceptor.java
new file mode 100644
index 000000000..c6f3b0868
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/protocol/TestNextNonceInterceptor.java
@@ -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");
+    }
+
+}