From 99a1404ba99ac3b0a3c001daf2838fd55d84535e Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Fri, 1 Nov 2024 13:45:08 +0100 Subject: [PATCH] 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. --- .../client5/http/impl/auth/DigestScheme.java | 11 ++ .../http/protocol/HttpClientContext.java | 39 +++++ .../http/protocol/NextNonceInterceptor.java | 146 ++++++++++++++++++ .../http/impl/auth/TestDigestScheme.java | 59 +++++++ .../protocol/TestNextNonceInterceptor.java | 106 +++++++++++++ 5 files changed, 361 insertions(+) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/protocol/NextNonceInterceptor.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/protocol/TestNextNonceInterceptor.java 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 ec9ea048e0..85ed712d2d 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.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 String generateAuthResponse( 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 f58a8042b2..575f67cd64 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 static HttpClientContext create() { private Object userToken; private RequestConfig requestConfig; + /** + * Stores the {@code nextnonce} value provided by the server in an HTTP response. + *

+ * 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. + *

+ *

+ * 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. + *

+ * + * @since 5.5 + */ + private String nextNonce; + public HttpClientContext(final HttpContext context) { super(context); } @@ -449,6 +466,28 @@ public void setExchangeId(final String 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}. * 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 0000000000..8741798f16 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/NextNonceInterceptor.java @@ -0,0 +1,146 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +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. + *

+ * 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. + *

+ * + *

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.

+ * + *

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. + *

+ * + *

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.

+ * + * @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 CharArrayBuffer buf = ((FormattedHeader) header).getBuffer(); + final ParserCursor cursor = new ParserCursor(0, buf.length()); + cursor.updatePos(((FormattedHeader) header).getValuePos()); + nextNonce = parseNextNonce(buf, cursor); + } else { + final String headerValue = header.getValue(); + final ParserCursor cursor = new ParserCursor(0, headerValue.length()); + final CharArrayBuffer buf = new CharArrayBuffer(headerValue.length()); + buf.append(headerValue); + nextNonce = parseNextNonce(buf, cursor); + } + if (!TextUtils.isBlank(nextNonce)) { + //context.setAttribute("auth-nextnonce", 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 CharArrayBuffer 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 603d8ccc4b..af8204b85d 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.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 @@ void testRspAuthFieldAndQuoting() throws Exception { 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 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 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 0000000000..c6f3b08682 --- /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 + * . + * + */ + +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"); + } + +}