From 341a45800fd2cf8220ae3e3f5086cf54479be836 Mon Sep 17 00:00:00 2001 From: anuchandy Date: Fri, 12 Apr 2024 13:20:45 -0700 Subject: [PATCH] AMQP websocket custom proxy authenticator --- .../transport/proxy/ChallengeResponse.java | 47 +++++++++ .../transport/proxy/ProxyAuthenticator.java | 33 +++++++ .../transport/proxy/ProxyConfiguration.java | 30 ++++++ .../impl/ChallengeResponseAccessHelper.java | 63 ++++++++++++ .../transport/proxy/impl/ProxyImpl.java | 99 +++++++++++++++---- .../transport/proxy/impl/ProxyImplTest.java | 21 +++- 6 files changed, 269 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/microsoft/azure/proton/transport/proxy/ChallengeResponse.java create mode 100644 src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyAuthenticator.java create mode 100644 src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ChallengeResponseAccessHelper.java diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/ChallengeResponse.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/ChallengeResponse.java new file mode 100644 index 0000000..c862cbc --- /dev/null +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/ChallengeResponse.java @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.proton.transport.proxy; + +import com.microsoft.azure.proton.transport.proxy.impl.ChallengeResponseAccessHelper; + +import java.util.List; +import java.util.Map; + +/** + * Represents the 407 challenge response from the proxy server. + */ +public final class ChallengeResponse { + static { + ChallengeResponseAccessHelper.setAccessor(ChallengeResponse::new); + } + private static final String PROXY_AUTHENTICATE = "Proxy-Authenticate"; + private final Map> headers; + + /** + * Creates the ChallengeResponse. + * + * @param headers the response headers + */ + ChallengeResponse(Map> headers) { + this.headers = headers; + } + + /** + * Gets the headers. + * + * @return the headers. + */ + public Map> getHeaders() { + return headers; + } + + /** + * Gets the authentication schemes supported by the proxy server. + * + * @return the authentication schemes supported by the proxy server. + */ + public List getAuthenticationSchemes() { + return headers.get(PROXY_AUTHENTICATE); + } +} diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyAuthenticator.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyAuthenticator.java new file mode 100644 index 0000000..0d21d10 --- /dev/null +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyAuthenticator.java @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.proton.transport.proxy; + +/** + * A contract to authenticate a proxy server to tunnel a websocket connection to an AMQP broker. + */ +public interface ProxyAuthenticator { + /** + * Authenticate a proxy server to tunnel a websocket connection to an AMQP broker. + *

+ * This method is called when the proxy server replies to the CONNECT with 407 (Proxy Authentication Required) + * challenge. The proxy server's challenge response includes a 'Proxy-Authenticate' header indicating + * the authentication scheme(s) that the proxy supports. The implementation of this method should + *

    + *
  • enumerate the schemes using {@link ChallengeResponse#getAuthenticationSchemes()}) and choose the most + * secure scheme the client supports,
  • + *
  • identify the credential for the chosen scheme,
  • + *
  • compute and return authorization value.The RFC7325 defines authorization format as a value that starts + * with the selected scheme, followed by a space and the base64 encoded credentials for the scheme.
  • + *
+ * The returned authorization value will be sent to the proxy server in 'Proxy-Authorization' header to complete + * the authentication. + * @see 407 Proxy Authentication Required + * @see Proxy-Authenticate + * @see RFC7235 + * + * @param response the challenge response from the proxy server. + * @return the authorization value to send to the proxy server using 'Proxy-Authorization' header. + */ + String authenticate(ChallengeResponse response); +} diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyConfiguration.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyConfiguration.java index 99e2a69..447cac4 100644 --- a/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyConfiguration.java +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyConfiguration.java @@ -20,6 +20,7 @@ public class ProxyConfiguration implements AutoCloseable { private final java.net.Proxy proxyAddress; private final ProxyAuthenticationType authentication; + private final ProxyAuthenticator authenticator; private final PasswordAuthentication credentials; /** @@ -32,6 +33,7 @@ public class ProxyConfiguration implements AutoCloseable { */ private ProxyConfiguration() { this.authentication = null; + this.authenticator = null; this.credentials = null; this.proxyAddress = null; } @@ -65,6 +67,21 @@ public ProxyConfiguration(ProxyAuthenticationType authentication, java.net.Proxy this.credentials = null; } + this.authenticator = null; + } + + /** + * Creates a proxy configuration that uses the {@code proxyAddress} and authenticates with provided {@code authenticator}. + * + * @param authenticator the proxy authenticator to use. + * @param proxyAddress Proxy to use. + * @throws NullPointerException if {@code proxyAddress} or {@code proxyAuthenticator} is {@code null}. + */ + public ProxyConfiguration(ProxyAuthenticator authenticator, java.net.Proxy proxyAddress) { + this.authenticator = Objects.requireNonNull(authenticator, "'authenticator' cannot be null."); + this.proxyAddress = Objects.requireNonNull(proxyAddress, "'proxyAddress' cannot be null."); + this.authentication = null; + this.credentials = null; } /** @@ -97,6 +114,19 @@ public ProxyAuthenticationType authentication() { return authentication; } + /** + * Gets the proxy authenticator to set up the web socket connection to the AMQP broker via a proxy. + *

+ * The authenticator is responsible for selecting one of the authorization schemes that the proxy presents, identify + * the credentials for the scheme it selects then compute and return the authorization value to be sent through + * the 'Proxy-Authorization' Header. + *

+ * @return the proxy authenticator. + */ + public ProxyAuthenticator getAuthenticator() { + return this.authenticator; + } + /** * Gets whether the user has defined credentials. * diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ChallengeResponseAccessHelper.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ChallengeResponseAccessHelper.java new file mode 100644 index 0000000..449cf07 --- /dev/null +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ChallengeResponseAccessHelper.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.proton.transport.proxy.impl; + +import java.util.List; +import java.util.Map; + +import com.microsoft.azure.proton.transport.proxy.ChallengeResponse; + +/** + * The accessor helper for {@link ChallengeResponse}. + */ +public final class ChallengeResponseAccessHelper { + private static ChallengeResponseAccessor accessor; + + /** + * The accessor interface for {@link ChallengeResponse} construction. + */ + public interface ChallengeResponseAccessor { + /** + * Create an instance of {@link ChallengeResponse} with the provided headers. + * + * @param headers the headers. + * @return the created instance of {@link ChallengeResponse}. + */ + ChallengeResponse internalCreate(Map> headers); + } + + /** + * Sets the accessor. + * + * @param accessor the accessor. + */ + public static void setAccessor(ChallengeResponseAccessor accessor) { + ChallengeResponseAccessHelper.accessor = accessor; + } + + /** + * Creates an instance of {@link ChallengeResponse} with the provided headers. + * + * @param headers the headers. + * @return the created instance of {@link ChallengeResponse}. + */ + public static ChallengeResponse internalCreate(Map> headers) { + if (accessor == null) { + try { + Class.forName(ChallengeResponse.class.getName(), true, + ChallengeResponseAccessHelper.class.getClassLoader()); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + assert accessor != null; + return accessor.internalCreate(headers); + } + + /** + * Private constructor to prevent instantiation. + */ + private ChallengeResponseAccessHelper() { + } +} diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImpl.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImpl.java index bd19ec3..f5fb1fa 100644 --- a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImpl.java +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImpl.java @@ -22,10 +22,12 @@ import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -253,45 +255,65 @@ public void process() throws TransportException { proxyResponse.set(null); final boolean isSuccess = proxyHandler.validateProxyResponse(connectResponse); - // When connecting to proxy, it does not challenge us for authentication. If the user has specified - // a configuration, and it is not NONE, then we fail due to misconfiguration. if (isSuccess) { - if (proxyConfiguration == null || proxyConfiguration.authentication() == ProxyAuthenticationType.NONE) { - proxyState = ProxyState.PN_PROXY_CONNECTED; - } else { + if (proxyConfiguration != null + && (proxyConfiguration.getAuthenticator() != null + || proxyConfiguration.authentication() != ProxyAuthenticationType.NONE)) { + // The proxy didn't challenge client for authentication in response to CONNECT. Given the user has specified that an + // authentication is required, we fail due to misconfiguration. if (LOGGER.isErrorEnabled()) { LOGGER.error("ProxyConfiguration mismatch. User configured: '{}', but authentication is not required", - proxyConfiguration.authentication()); + proxyConfiguration.getAuthenticator() != null ? "ProxyAuthenticator" : proxyConfiguration.authentication()); } closeTailProxyError(PROXY_CONNECT_USER_ERROR); + } else { + proxyState = ProxyState.PN_PROXY_CONNECTED; } break; } final Map> headers = connectResponse.getHeaders(); - final Set supportedTypes = getAuthenticationTypes(headers); - - // The proxy did not successfully connect, user has specified that they want a particular - // authentication method, but it is not in list of supported authentication methods. - if (proxyConfiguration != null && !supportedTypes.contains(proxyConfiguration.authentication())) { - if (LOGGER.isErrorEnabled()) { - LOGGER.error("Proxy authentication required. User configured: '{}', but supported proxy authentication methods are: {}", - proxyConfiguration.authentication(), - supportedTypes.stream().map(type -> type.toString()).collect(Collectors.joining(","))); + final List challenges = headers.getOrDefault(PROXY_AUTHENTICATE, new ArrayList<>()); + final ProxyChallengeProcessor processor; + if (proxyConfiguration != null && proxyConfiguration.getAuthenticator() != null) { + final boolean is407 = connectResponse.getStatus().getStatusCode() == 407; + if (!is407) { + closeTailProxyError(PROXY_CONNECT_FAILED + connectResponse); + break; + } + if (challenges.isEmpty()) { + closeTailProxyError("'407 Proxy Authentication Required' received without " + PROXY_AUTHENTICATE + + "header or authentication schemes." + connectResponse); + break; } - closeTailProxyError(PROXY_CONNECT_USER_ERROR + PROXY_CONNECT_FAILED + processor = new DelegatedProxyChallengeProcessor(headers, proxyConfiguration.getAuthenticator()); + } else { + final Set supportedTypes = getAuthenticationTypes(headers); + // The proxy did not successfully connect, user has specified that they want a particular + // authentication method, but it is not in list of supported authentication methods. + if (proxyConfiguration != null && !supportedTypes.contains(proxyConfiguration.authentication())) { + if (LOGGER.isErrorEnabled()) { + LOGGER.error( + "Proxy authentication required. User configured: '{}', but supported proxy authentication methods are: {}", + proxyConfiguration.authentication(), + supportedTypes.stream().map(type -> type.toString()).collect(Collectors.joining(","))); + } + closeTailProxyError(PROXY_CONNECT_USER_ERROR + PROXY_CONNECT_FAILED + connectResponse); - break; - } - - final List challenges = headers.getOrDefault(PROXY_AUTHENTICATE, new ArrayList<>()); - final ProxyChallengeProcessor processor = proxyConfiguration != null + break; + } + processor = proxyConfiguration != null ? getChallengeProcessor(host, challenges, proxyConfiguration.authentication()) : getChallengeProcessor(host, challenges, supportedTypes); + } if (processor != null) { proxyState = ProxyState.PN_PROXY_CHALLENGE; ProxyImpl.this.headers = processor.getHeader(); + if (proxyConfiguration != null + && proxyConfiguration.getAuthenticator() != null && !testAuthorizeHeader(challenges, ProxyImpl.this.headers)) { + closeTailProxyError("User error: ProxyAuthenticator did not provide a valid authorization header."); + } } else { LOGGER.warn("Could not get ProxyChallengeProcessor for challenges."); closeTailProxyError(PROXY_CONNECT_FAILED + String.join(";", challenges)); @@ -540,4 +562,39 @@ private ProxyResponse readProxyResponse(ByteBuffer buffer) { return proxyResponse.get(); } } + + private static final class DelegatedProxyChallengeProcessor implements ProxyChallengeProcessor { + private final Map> headers; + private final com.microsoft.azure.proton.transport.proxy.ProxyAuthenticator authenticator; + + DelegatedProxyChallengeProcessor(Map> headers, + com.microsoft.azure.proton.transport.proxy.ProxyAuthenticator authenticator) { + this.headers = Objects.requireNonNull(headers, "'headers' cannot be null."); + this.authenticator = Objects.requireNonNull(authenticator, "'authenticator' cannot be null."); + } + + @Override + public Map getHeader() { + // the call site ensured that the 'headers' contain 'Proxy-Authenticate' header with the authentication schemes. + final String authorizedHeader = authenticator.authenticate(ChallengeResponseAccessHelper.internalCreate(headers)); + final Map headers = new HashMap<>(1); + headers.put(Constants.PROXY_AUTHORIZATION, authorizedHeader); + return headers; + } + } + + private boolean testAuthorizeHeader(List challenges, Map headers) { + assert !challenges.isEmpty(); + final String authorizeHeader = headers.get(Constants.PROXY_AUTHORIZATION); + if (authorizeHeader == null || authorizeHeader.trim().isEmpty()) { + return false; + } + final String value = authorizeHeader.toLowerCase(Locale.ROOT); + for (String scheme : challenges) { + if (value.startsWith(scheme.toLowerCase(Locale.ROOT) + " ")) { + return true; + } + } + return false; + } } diff --git a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImplTest.java b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImplTest.java index 86801c5..733c2dd 100644 --- a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImplTest.java +++ b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImplTest.java @@ -5,6 +5,7 @@ import com.microsoft.azure.proton.transport.proxy.Proxy; import com.microsoft.azure.proton.transport.proxy.ProxyAuthenticationType; +import com.microsoft.azure.proton.transport.proxy.ProxyAuthenticator; import com.microsoft.azure.proton.transport.proxy.ProxyConfiguration; import com.microsoft.azure.proton.transport.proxy.ProxyHandler; import org.apache.qpid.proton.engine.Transport; @@ -17,6 +18,8 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mockito; @@ -35,6 +38,7 @@ import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -45,6 +49,7 @@ import static com.microsoft.azure.proton.transport.proxy.impl.Constants.DIGEST; import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_AUTHENTICATE; import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_AUTHORIZATION; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.mockito.Mockito.any; import static org.mockito.Mockito.argThat; import static org.mockito.Mockito.isA; @@ -718,10 +723,20 @@ public void authenticationTypeNoneClosesTail() { /** * Verifies that if we configure proxy authentication type but the proxy does not ask for verification then we fail. */ - @Test - public void authenticationNoAuthMismatchClosesTail() { + @ParameterizedTest + @ValueSource(strings = { "true", "false" }) + public void authenticationNoAuthMismatchClosesTail(boolean useAuthenticator) { // Arrange - ProxyConfiguration configuration = new ProxyConfiguration(ProxyAuthenticationType.BASIC, PROXY, USERNAME, PASSWORD); + final ProxyConfiguration configuration; + if (useAuthenticator) { + final ProxyAuthenticator authenticator = response -> { + return "Basic" + " " + Base64.getEncoder().encodeToString((USERNAME + ":" + PASSWORD).getBytes(UTF_8)); + }; + configuration = new ProxyConfiguration(authenticator, PROXY); + } else { + configuration = new ProxyConfiguration(ProxyAuthenticationType.BASIC, PROXY, USERNAME, PASSWORD); + } + ProxyImpl proxyImpl = new ProxyImpl(configuration); ProxyHandler handler = mock(ProxyHandler.class); TransportImpl underlyingTransport = mock(TransportImpl.class);