diff --git a/bom-internal/pom.xml b/bom-internal/pom.xml index 2d0db973636b..2c6506dc8275 100644 --- a/bom-internal/pom.xml +++ b/bom-internal/pom.xml @@ -519,6 +519,24 @@ pom import + + org.apache.kerby + kerb-simplekdc + ${kerby.version} + test + + + org.apache.kerby + kerb-client + ${kerby.version} + test + + + org.apache.kerby + kerb-core + ${kerby.version} + test + diff --git a/http-clients/netty-nio-client/pom.xml b/http-clients/netty-nio-client/pom.xml index 323424bab054..e4b878390a10 100644 --- a/http-clients/netty-nio-client/pom.xml +++ b/http-clients/netty-nio-client/pom.xml @@ -233,6 +233,21 @@ jetty-util test + + org.apache.kerby + kerb-simplekdc + test + + + org.apache.kerby + kerb-client + test + + + org.apache.kerby + kerb-core + test + diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyAuthScheme.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyAuthScheme.java new file mode 100644 index 000000000000..05719c612c02 --- /dev/null +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyAuthScheme.java @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package software.amazon.awssdk.http.nio.netty; + +import software.amazon.awssdk.annotations.SdkPublicApi; + +/** + * Supported auth schemes for authentication with a proxy. + */ +@SdkPublicApi +public enum ProxyAuthScheme { + /** + * Basic authentication. + */ + BASIC("Basic"), + + /** + * Kerberos authentication. + */ + NEGOTIATE("Negotiate"), + ; + + private final String value; + + ProxyAuthScheme(String value) { + this.value = value; + } + + public String value() { + return value; + } +} diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyConfiguration.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyConfiguration.java index 3e99406187ba..79697dbf9ebe 100644 --- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyConfiguration.java +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyConfiguration.java @@ -41,6 +41,7 @@ public final class ProxyConfiguration implements ToCopyableBuilder nonProxyHosts; private ProxyConfiguration(BuilderImpl builder) { @@ -56,7 +57,16 @@ private ProxyConfiguration(BuilderImpl builder) { this.port = resolvePort(builder, proxyConfigProvider); this.username = resolveUserName(builder, proxyConfigProvider); this.password = resolvePassword(builder, proxyConfigProvider); + this.proxyAuthScheme = builder.proxyAuthScheme; this.nonProxyHosts = resolveNonProxyHosts(builder, proxyConfigProvider); + validateProxyAuthConfig(proxyAuthScheme, username, password); + } + + private static void validateProxyAuthConfig(ProxyAuthScheme proxyAuthScheme, String username, String password) { + if (proxyAuthScheme == ProxyAuthScheme.BASIC + && (StringUtils.isEmpty(username) || StringUtils.isEmpty(password))) { + throw new IllegalArgumentException("username and password must be configured when using BASIC proxy auth"); + } } private static Set resolveNonProxyHosts(BuilderImpl builder, ProxyConfigProvider proxyConfigProvider) { @@ -151,6 +161,13 @@ public Set nonProxyHosts() { return Collections.unmodifiableSet(nonProxyHosts != null ? nonProxyHosts : Collections.emptySet()); } + /** + * @return The auth scheme to use to authenticate with the proxy. + */ + public ProxyAuthScheme proxyAuthScheme() { + return proxyAuthScheme; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -183,6 +200,10 @@ public boolean equals(Object o) { return false; } + if (proxyAuthScheme != null ? !proxyAuthScheme.equals(that.proxyAuthScheme) : that.proxyAuthScheme != null) { + return false; + } + return nonProxyHosts.equals(that.nonProxyHosts); } @@ -195,6 +216,7 @@ public int hashCode() { result = 31 * result + nonProxyHosts.hashCode(); result = 31 * result + (username != null ? username.hashCode() : 0); result = 31 * result + (password != null ? password.hashCode() : 0); + result = 31 * result + (proxyAuthScheme != null ? proxyAuthScheme.hashCode() : 0); return result; } @@ -247,6 +269,21 @@ public interface Builder extends CopyableBuilder { */ Builder nonProxyHosts(Set nonProxyHosts); + /** + * Configure the auth scheme to use to authenticate with the proxy. + *

+ * If unset and {@link #username(String)} and {@link #password(String)} are set, the client will + * assume {@link ProxyAuthScheme#BASIC} auth. + *

+ * If set to {@link ProxyAuthScheme#BASIC}, {@link #username(String)} and {@link #password(String)} must also be + * configured (directly, or resolved from system properties or environment variables), otherwise + * {@link Builder#build()} throws {@link IllegalArgumentException}. + * + * @param proxyAuthScheme The auth scheme. + * @return This object for method chaining. + */ + Builder proxyAuthScheme(ProxyAuthScheme proxyAuthScheme); + /** * Set the username used to authenticate with the proxy username. * @@ -305,6 +342,7 @@ private static final class BuilderImpl implements Builder { private String scheme = "http"; private String host; private int port = 0; + private ProxyAuthScheme proxyAuthScheme; private String username; private String password; private Set nonProxyHosts; @@ -322,6 +360,7 @@ private BuilderImpl(ProxyConfiguration proxyConfiguration) { this.port = proxyConfiguration.port; this.nonProxyHosts = proxyConfiguration.nonProxyHosts != null ? new HashSet<>(proxyConfiguration.nonProxyHosts) : null; + this.proxyAuthScheme = proxyConfiguration.proxyAuthScheme; this.username = proxyConfiguration.username; this.password = proxyConfiguration.password; } @@ -354,6 +393,12 @@ public Builder nonProxyHosts(Set nonProxyHosts) { return this; } + @Override + public Builder proxyAuthScheme(ProxyAuthScheme proxyAuthScheme) { + this.proxyAuthScheme = proxyAuthScheme; + return this; + } + @Override public Builder username(String username) { this.username = username; diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMap.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMap.java index ff5c87e57038..d9441a2f6ee2 100644 --- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMap.java +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMap.java @@ -36,14 +36,17 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; +import javax.security.auth.login.Configuration; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; import software.amazon.awssdk.http.Protocol; import software.amazon.awssdk.http.ProtocolNegotiation; +import software.amazon.awssdk.http.nio.netty.ProxyAuthScheme; import software.amazon.awssdk.http.nio.netty.ProxyConfiguration; import software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup; import software.amazon.awssdk.http.nio.netty.internal.http2.HttpOrHttp2ChannelPool; import software.amazon.awssdk.http.nio.netty.internal.utils.NettyClientLogger; +import software.amazon.awssdk.utils.StringUtils; /** * Implementation of {@link SdkChannelPoolMap} that awaits channel pools to be closed upon closing. @@ -87,6 +90,8 @@ public void channelCreated(Channel ch) throws Exception { private final SslContextProvider sslContextProvider; private final Boolean useNonBlockingDnsResolver; + private final Configuration negotiateAuthConfig; + private AwaitCloseChannelPoolMap(Builder builder, Function createBootStrapProvider) { this.configuration = builder.configuration; this.protocol = builder.protocol; @@ -99,6 +104,7 @@ private AwaitCloseChannelPoolMap(Builder builder, Function "Closing channel pools"); @@ -293,6 +329,9 @@ public static class Builder { private ProxyConfiguration proxyConfiguration; private Boolean useNonBlockingDnsResolver; + // testing only + private Configuration negotiateAuthConfig; + private Builder() { } @@ -351,6 +390,12 @@ public Builder useNonBlockingDnsResolver(Boolean useNonBlockingDnsResolver) { return this; } + @SdkTestInternalApi + public Builder negotiateAuthConfig(Configuration negotiateAuthConfig) { + this.negotiateAuthConfig = negotiateAuthConfig; + return this; + } + public AwaitCloseChannelPoolMap build() { return new AwaitCloseChannelPoolMap(this); } diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGenerator.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGenerator.java new file mode 100644 index 000000000000..36055cf0b0fe --- /dev/null +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGenerator.java @@ -0,0 +1,50 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package software.amazon.awssdk.http.nio.netty.internal; + +import io.netty.util.CharsetUtil; +import java.net.URI; +import java.util.Base64; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.nio.netty.ProxyAuthScheme; +import software.amazon.awssdk.utils.Validate; + +/** + * Auth param generator for Basic proxy authentication. + *

+ * See https://datatracker.ietf.org/doc/html/rfc7617. + */ +@SdkInternalApi +public class BasicProxyAuthGenerator implements ProxyAuthGenerator { + private final String username; + private final String password; + + public BasicProxyAuthGenerator(String username, String password) { + this.username = Validate.notEmpty(username, "username must not be empty"); + this.password = Validate.notEmpty(password, "password must not be empty"); + } + + @Override + public ProxyAuthScheme scheme() { + return ProxyAuthScheme.BASIC; + } + + @Override + public String generateAuthParams(URI proxyEndpoint) { + String authToken = String.format("%s:%s", this.username, this.password); + return Base64.getEncoder().encodeToString(authToken.getBytes(CharsetUtil.UTF_8)); + } +} diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPool.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPool.java index cc53ed4da46a..0dafa642d6e4 100644 --- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPool.java +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPool.java @@ -49,41 +49,30 @@ public class Http1TunnelConnectionPool implements ChannelPool { private final ChannelPool delegate; private final SslContext sslContext; private final URI proxyAddress; - private final String proxyUser; - private final String proxyPassword; + private final ProxyAuthGenerator proxyAuthGenerator; private final URI remoteAddress; private final ChannelPoolHandler handler; private final InitHandlerSupplier initHandlerSupplier; private final NettyConfiguration nettyConfiguration; public Http1TunnelConnectionPool(EventLoop eventLoop, ChannelPool delegate, SslContext sslContext, - URI proxyAddress, String proxyUsername, String proxyPassword, + URI proxyAddress, ProxyAuthGenerator proxyAuthGenerator, URI remoteAddress, ChannelPoolHandler handler, NettyConfiguration nettyConfiguration) { this(eventLoop, delegate, sslContext, - proxyAddress, proxyUsername, proxyPassword, remoteAddress, handler, + proxyAddress, proxyAuthGenerator, remoteAddress, handler, ProxyTunnelInitHandler::new, nettyConfiguration); } - public Http1TunnelConnectionPool(EventLoop eventLoop, ChannelPool delegate, SslContext sslContext, - URI proxyAddress, URI remoteAddress, ChannelPoolHandler handler, - NettyConfiguration nettyConfiguration) { - this(eventLoop, delegate, sslContext, - proxyAddress, null, null, remoteAddress, handler, - ProxyTunnelInitHandler::new, nettyConfiguration); - - } - @SdkTestInternalApi Http1TunnelConnectionPool(EventLoop eventLoop, ChannelPool delegate, SslContext sslContext, - URI proxyAddress, String proxyUser, String proxyPassword, URI remoteAddress, + URI proxyAddress, ProxyAuthGenerator proxyAuthGenerator, URI remoteAddress, ChannelPoolHandler handler, InitHandlerSupplier initHandlerSupplier, NettyConfiguration nettyConfiguration) { this.eventLoop = eventLoop; this.delegate = delegate; this.sslContext = sslContext; this.proxyAddress = proxyAddress; - this.proxyUser = proxyUser; - this.proxyPassword = proxyPassword; + this.proxyAuthGenerator = proxyAuthGenerator; this.remoteAddress = remoteAddress; this.handler = handler; this.initHandlerSupplier = initHandlerSupplier; @@ -138,7 +127,7 @@ private void setupChannel(Channel ch, Promise acquirePromise) { if (sslHandler != null) { ch.pipeline().addLast(sslHandler); } - ch.pipeline().addLast(initHandlerSupplier.newInitHandler(delegate, proxyUser, proxyPassword, remoteAddress, + ch.pipeline().addLast(initHandlerSupplier.newInitHandler(delegate, proxyAddress, proxyAuthGenerator, remoteAddress, tunnelEstablishedPromise)); tunnelEstablishedPromise.addListener((Future f) -> { if (f.isSuccess()) { @@ -180,7 +169,10 @@ private static boolean isTunnelEstablished(Channel ch) { @SdkTestInternalApi @FunctionalInterface interface InitHandlerSupplier { - ChannelHandler newInitHandler(ChannelPool sourcePool, String proxyUsername, String proxyPassword, URI remoteAddress, + ChannelHandler newInitHandler(ChannelPool sourcePool, + URI proxyAddress, + ProxyAuthGenerator authGenerator, + URI remoteAddress, Promise tunnelInitFuture); } } diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGenerator.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGenerator.java new file mode 100644 index 000000000000..0866073946c2 --- /dev/null +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGenerator.java @@ -0,0 +1,142 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package software.amazon.awssdk.http.nio.netty.internal; + +import java.net.URI; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.HashMap; +import java.util.Map; +import javax.security.auth.Subject; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.nio.netty.ProxyAuthScheme; +import software.amazon.awssdk.utils.BinaryUtils; + +/** + * Auth generator for Kerberos. This does not login/authentication to Kerberos. It expects the ticket cache to be present and + * simply reads that to generate the token. + */ +@SdkInternalApi +public class NegotiateProxyAuthGenerator implements ProxyAuthGenerator { + // SPNEGO pseudo-mechanism OID. Lets the proxy negotiate Kerberos over HTTP "Negotiate". + // See https://www.ietf.org/rfc/rfc4178.txt for more info + private static final String OID = "1.3.6.1.5.5.2"; + private static final String SERVICE_NAME = "HTTP"; + private final Configuration config; + + public NegotiateProxyAuthGenerator() { + this(createDefaultConfig()); + } + + public NegotiateProxyAuthGenerator(Configuration config) { + if (config != null) { + this.config = config; + } else { + this.config = createDefaultConfig(); + } + } + + @Override + public ProxyAuthScheme scheme() { + return ProxyAuthScheme.NEGOTIATE; + } + + @Override + public String generateAuthParams(URI proxyEndpoint) { + try { + Subject subject = getSubject(); + + byte[] token = Subject.doAs(subject, (PrivilegedExceptionAction) () -> { + GSSContext ctx = createGssContext(getManager(), proxyEndpoint); + try { + return ctx.initSecContext(new byte[0], 0, 0); + } finally { + ctx.dispose(); + } + }); + + return BinaryUtils.toBase64(token); + } catch (PrivilegedActionException e) { + throw new RuntimeException(String.format("Unable to generate SPNEGO token for Negotiate proxy authentication " + + "with '%s@%s'. This can happen when a service ticket for the proxy " + + "cannot be obtained from the KDC, e.g. because the ticket-granting " + + "ticket has expired (renew with 'kinit') or the proxy host does not " + + "match its Kerberos service principal name.", + SERVICE_NAME, proxyEndpoint.getHost()), e); + } + } + + private Subject getSubject() { + try { + LoginContext loginContext = new LoginContext("dummy", null, null, config); + loginContext.login(); + return loginContext.getSubject(); + } catch (LoginException e) { + throw new RuntimeException("Unable to perform Kerberos login for Negotiate proxy authentication. This " + + "typically means the Kerberos ticket cache is missing, expired, or not readable. " + + "Ensure a valid ticket-granting ticket exists (e.g., by running 'kinit'), and that " + + "the cache is at the expected location (see the KRB5CCNAME environment variable). " + + "Verify with 'klist'.", e); + } + } + + private GSSContext createGssContext(GSSManager manager, URI endpoint) { + try { + String name = String.format("%s@%s", SERVICE_NAME, endpoint.getHost()); + GSSName serverName = manager.createName(name, GSSName.NT_HOSTBASED_SERVICE); + Oid spnegoOid = new Oid(OID); + return manager.createContext(serverName, spnegoOid, null, + GSSContext.DEFAULT_LIFETIME); + } catch (GSSException e) { + throw new RuntimeException("Unable to create GSSContext", e); + } + } + + private static GSSManager getManager() { + return GSSManager.getInstance(); + } + + /** + * Create a generic {@link Configuration} that instructs the Kerberos login module to simply look in the ticket cache, and + * not to prompt for passwords. + *

+ * See javadoc for {@code com.sun.security.auth.module.Krb5LoginModule} for additional info on the configuration options. + */ + private static Configuration createDefaultConfig() { + return new Configuration() { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + Map opts = new HashMap<>(); + opts.put("useTicketCache", "true"); + opts.put("doNotPrompt", "true"); + return new AppConfigurationEntry[] { + new AppConfigurationEntry( + "com.sun.security.auth.module.Krb5LoginModule", + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, opts) + }; + } + }; + } +} diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyAuthGenerator.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyAuthGenerator.java new file mode 100644 index 000000000000..eeb84fbdb6f5 --- /dev/null +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyAuthGenerator.java @@ -0,0 +1,36 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package software.amazon.awssdk.http.nio.netty.internal; + +import java.net.URI; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.nio.netty.ProxyAuthScheme; + +/** + * Generates the auth params for an {@code Authorization} HTTP header. + */ +@SdkInternalApi +public interface ProxyAuthGenerator { + /** + * The name of the auth scheme this generator supports. + */ + ProxyAuthScheme scheme(); + + /** + * Generate the auth params for this request. + */ + String generateAuthParams(URI proxyEndpoint); +} diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandler.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandler.java index e6f309afbad3..aeda02f02e0f 100644 --- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandler.java +++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandler.java @@ -28,11 +28,9 @@ import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpVersion; -import io.netty.util.CharsetUtil; import io.netty.util.concurrent.Promise; import java.io.IOException; import java.net.URI; -import java.util.Base64; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -47,37 +45,64 @@ public final class ProxyTunnelInitHandler extends ChannelDuplexHandler { public static final NettyClientLogger log = NettyClientLogger.getLogger(ProxyTunnelInitHandler.class); private final ChannelPool sourcePool; - private final String username; - private final String password; + private final URI proxyAddress; + private final ProxyAuthGenerator authGenerator; private final URI remoteHost; private final Promise initPromise; private final Supplier httpCodecSupplier; public ProxyTunnelInitHandler(ChannelPool sourcePool, String proxyUsername, String proxyPassword, URI remoteHost, Promise initPromise) { - this(sourcePool, proxyUsername, proxyPassword, remoteHost, initPromise, HttpClientCodec::new); + this(sourcePool, null, proxyUsername, proxyPassword, remoteHost, initPromise, HttpClientCodec::new); } public ProxyTunnelInitHandler(ChannelPool sourcePool, URI remoteHost, Promise initPromise) { - this(sourcePool, null, null, remoteHost, initPromise, HttpClientCodec::new); + this(sourcePool, null, null, null, remoteHost, initPromise, HttpClientCodec::new); } @SdkTestInternalApi - public ProxyTunnelInitHandler(ChannelPool sourcePool, String prosyUsername, String proxyPassword, + public ProxyTunnelInitHandler(ChannelPool sourcePool, URI proxyAddress, String proxyUsername, String proxyPassword, URI remoteHost, Promise initPromise, Supplier httpCodecSupplier) { this.sourcePool = sourcePool; + this.proxyAddress = proxyAddress; this.remoteHost = remoteHost; this.initPromise = initPromise; - this.username = prosyUsername; - this.password = proxyPassword; + if (!StringUtils.isBlank(proxyUsername) && !StringUtils.isBlank(proxyPassword)) { + this.authGenerator = new BasicProxyAuthGenerator(proxyUsername, proxyPassword); + } else { + this.authGenerator = null; + } + this.httpCodecSupplier = httpCodecSupplier; + } + + public ProxyTunnelInitHandler(ChannelPool sourcePool, URI proxyAddress, ProxyAuthGenerator authGenerator, + URI remoteHost, Promise initPromise, Supplier httpCodecSupplier) { + this.sourcePool = sourcePool; + this.proxyAddress = proxyAddress; + this.remoteHost = remoteHost; + this.initPromise = initPromise; + this.authGenerator = authGenerator; this.httpCodecSupplier = httpCodecSupplier; } + public ProxyTunnelInitHandler(ChannelPool sourcePool, URI proxyAddress, ProxyAuthGenerator authGenerator, + URI remoteHost, Promise initPromise) { + this(sourcePool, proxyAddress, authGenerator, remoteHost, initPromise, HttpClientCodec::new); + } + @Override public void handlerAdded(ChannelHandlerContext ctx) { ChannelPipeline pipeline = ctx.pipeline(); pipeline.addBefore(ctx.name(), null, httpCodecSupplier.get()); - HttpRequest connectRequest = connectRequest(); + + HttpRequest connectRequest; + try { + connectRequest = connectRequest(); + } catch (Throwable t) { + handleConnectRequestFailure(ctx, t); + return; + } + ctx.channel().writeAndFlush(connectRequest).addListener(f -> { if (!f.isSuccess()) { handleConnectRequestFailure(ctx, f.cause()); @@ -151,10 +176,9 @@ private HttpRequest connectRequest() { Unpooled.EMPTY_BUFFER); request.headers().add(HttpHeaderNames.HOST, uri); - if (!StringUtils.isEmpty(this.username) && !StringUtils.isEmpty(this.password)) { - String authToken = String.format("%s:%s", this.username, this.password); - String authB64 = Base64.getEncoder().encodeToString(authToken.getBytes(CharsetUtil.UTF_8)); - request.headers().add(HttpHeaderNames.PROXY_AUTHORIZATION, String.format("Basic %s", authB64)); + if (authGenerator != null) { + String auth = String.format("%s %s", authGenerator.scheme().value(), authGenerator.generateAuthParams(proxyAddress)); + request.headers().add(HttpHeaderNames.PROXY_AUTHORIZATION, auth); } return request; diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/ProxyConfigurationTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/ProxyConfigurationTest.java index 06d57c1aa7d2..b15b4c951db7 100644 --- a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/ProxyConfigurationTest.java +++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/ProxyConfigurationTest.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.http.nio.netty; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -147,6 +148,67 @@ void setNonProxyHostsToNull_createsEmptySet() { assertThat(cfg.nonProxyHosts()).isEmpty(); } + @Test + void build_basicAuthSchemeWithoutCredentials_throws() { + ProxyConfiguration.Builder builder = ProxyConfiguration.builder() + .host("localhost") + .port(8888) + .proxyAuthScheme(ProxyAuthScheme.BASIC); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("username and password must be configured"); + } + + @Test + void build_basicAuthSchemeWithoutPassword_throws() { + ProxyConfiguration.Builder builder = ProxyConfiguration.builder() + .host("localhost") + .port(8888) + .proxyAuthScheme(ProxyAuthScheme.BASIC) + .username("user"); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("username and password must be configured"); + } + + @Test + void build_basicAuthSchemeWithCredentials_doesNotThrow() { + ProxyConfiguration cfg = ProxyConfiguration.builder() + .host("localhost") + .port(8888) + .proxyAuthScheme(ProxyAuthScheme.BASIC) + .username("user") + .password("pass") + .build(); + + assertThat(cfg.proxyAuthScheme()).isEqualTo(ProxyAuthScheme.BASIC); + } + + @Test + void build_basicAuthSchemeWithSystemPropertyCredentials_doesNotThrow() { + setHttpProxyProperties(); + + ProxyConfiguration cfg = ProxyConfiguration.builder() + .proxyAuthScheme(ProxyAuthScheme.BASIC) + .build(); + + assertThat(cfg.username()).isEqualTo(TEST_USER); + assertThat(cfg.password()).isEqualTo(TEST_PASSWORD); + } + + @Test + void build_negotiateAuthSchemeWithoutCredentials_doesNotThrow() { + ProxyConfiguration cfg = ProxyConfiguration.builder() + .host("localhost") + .port(8888) + .proxyAuthScheme(ProxyAuthScheme.NEGOTIATE) + .build(); + + assertThat(cfg.proxyAuthScheme()).isEqualTo(ProxyAuthScheme.NEGOTIATE); + } + @Test void toBuilderModified_doesNotModifySource() { ProxyConfiguration original = allPropertiesSetConfig(); @@ -185,7 +247,11 @@ private void setRandomValue(Object o, Method setter) throws InvocationTargetExce setter.invoke(o, randomSet()); } else if (Boolean.class.equals(paramClass)) { setter.invoke(o, RNG.nextBoolean()); - } else { + } else if (ProxyAuthScheme.class.equals(paramClass)) { + ProxyAuthScheme authScheme = ProxyAuthScheme.values()[RNG.nextInt(ProxyAuthScheme.values().length)]; + setter.invoke(o, authScheme); + } + else { throw new RuntimeException("Don't know how create random value for type " + paramClass); } } diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMapTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMapTest.java index a1d4b9781f35..f1b80597d3c4 100644 --- a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMapTest.java +++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMapTest.java @@ -23,45 +23,83 @@ import static software.amazon.awssdk.http.SdkHttpConfigurationOption.GLOBAL_HTTP_DEFAULTS; import static software.amazon.awssdk.http.SdkHttpConfigurationOption.TLS_KEY_MANAGERS_PROVIDER; -import com.github.tomakehurst.wiremock.junit.WireMockRule; +import com.github.tomakehurst.wiremock.WireMockServer; import io.netty.channel.Channel; import io.netty.channel.pool.ChannelPool; import io.netty.handler.ssl.SslProvider; -import io.netty.util.CharsetUtil; import io.netty.util.concurrent.Future; +import java.net.InetSocketAddress; +import java.net.Socket; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; -import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; import org.apache.commons.lang3.RandomStringUtils; -import org.junit.After; -import org.junit.Rule; -import org.junit.Test; +import org.apache.kerby.kerberos.kerb.client.KrbClient; +import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer; +import org.apache.kerby.kerberos.kerb.type.ticket.TgtTicket; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; import software.amazon.awssdk.http.Protocol; import software.amazon.awssdk.http.ProtocolNegotiation; import software.amazon.awssdk.http.TlsKeyManagersProvider; +import software.amazon.awssdk.http.nio.netty.ProxyAuthScheme; import software.amazon.awssdk.http.nio.netty.ProxyConfiguration; import software.amazon.awssdk.http.nio.netty.RecordingNetworkTrafficListener; import software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup; import software.amazon.awssdk.utils.AttributeMap; public class AwaitCloseChannelPoolMapTest { + private static final String KRB5_PROP = "java.security.krb5.conf"; + private static final RecordingNetworkTrafficListener recorder = new RecordingNetworkTrafficListener(); - private final RecordingNetworkTrafficListener recorder = new RecordingNetworkTrafficListener(); + private static WireMockServer mockProxy; + + private static Path tempDir; + private static Path keytabFile; + private static Path ccacheFile; + private static int port; + + private static SimpleKdcServer kdc; + private static String krb5PropSave; + + private static Configuration negotiateAuthConfig; private AwaitCloseChannelPoolMap channelPoolMap; - @Rule - public WireMockRule mockProxy = new WireMockRule(wireMockConfig() - .dynamicPort() - .networkTrafficListener(recorder)); + @BeforeAll + public static void setup() throws Exception { + mockProxy = new WireMockServer(wireMockConfig().dynamicPort().networkTrafficListener(recorder)); + mockProxy.start(); + + setupMockKerberos(); + } + + @AfterAll + public static void teardown() throws Exception { + if (krb5PropSave != null) { + System.setProperty(KRB5_PROP, krb5PropSave); + } else { + System.clearProperty(KRB5_PROP); + } + mockProxy.stop(); + kdc.stop(); + } - @After + @AfterEach public void methodTeardown() { if (channelPoolMap != null) { channelPoolMap.close(); @@ -71,6 +109,56 @@ public void methodTeardown() { recorder.reset(); } + private static void setupMockKerberos() throws Exception { + tempDir = Files.createTempDirectory(null); + keytabFile = tempDir.resolve("keytab"); + ccacheFile = tempDir.resolve("ccache"); + + try (Socket freePort = new Socket()) { + freePort.setReuseAddress(true); + freePort.bind(new InetSocketAddress(0)); + port = freePort.getLocalPort(); + + kdc = new SimpleKdcServer(); + kdc.setKdcRealm("EXAMPLE.COM"); + kdc.setKdcHost("localhost"); + kdc.setWorkDir(tempDir.toFile()); + kdc.setKdcTcpPort(port); + kdc.setAllowUdp(false); + kdc.init(); + + krb5PropSave = System.getProperty(KRB5_PROP); + + System.setProperty(KRB5_PROP, tempDir.resolve("krb5.conf").toAbsolutePath().toString()); + kdc.start(); + + kdc.createPrincipal("alice@EXAMPLE.COM", "alicePassword"); + kdc.createAndExportPrincipals(keytabFile.toFile(), "HTTP/localhost@EXAMPLE.COM"); + + // initialize the ticket cache + KrbClient krbClient = kdc.getKrbClient(); + TgtTicket tgt = krbClient.requestTgt("alice@EXAMPLE.COM", "alicePassword"); + krbClient.storeTicket(tgt, ccacheFile.toFile()); + + // Override config so we look at the testing cache instead of the real system cache + negotiateAuthConfig = new Configuration() { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + Map opts = new HashMap<>(); + opts.put("useTicketCache", "true"); + opts.put("ticketCache", ccacheFile.toAbsolutePath().toString()); + opts.put("refreshKrb5Config", "true"); + opts.put("doNotPrompt", "true"); + return new AppConfigurationEntry[] { + new AppConfigurationEntry( + "com.sun.security.auth.module.Krb5LoginModule", + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, opts) + }; + } + }; + } + } + @Test public void close_underlyingPoolsShouldBeClosed() { channelPoolMap = AwaitCloseChannelPoolMap.builder() @@ -216,13 +304,16 @@ public void usingProxy_noSchemeGiven_defaultsToHttp() { assertThat(requests).contains("CONNECT some-awesome-service:443"); } - @Test - public void usingProxy_withAuth() { + @ParameterizedTest + @MethodSource("proxyAuthTestParams") + public void usingProxy_authHeaderCorrect(ProxyAuthScheme authScheme, String username, String password, + String proxyAuthHeader) { ProxyConfiguration proxyConfiguration = ProxyConfiguration.builder() .host("localhost") .port(mockProxy.port()) - .username("myuser") - .password("mypassword") + .proxyAuthScheme(authScheme) + .username(username) + .password(password) .build(); channelPoolMap = AwaitCloseChannelPoolMap.builder() @@ -233,6 +324,7 @@ public void usingProxy_withAuth() { .protocol(Protocol.HTTP1_1) .maxStreams(100) .sslProvider(SslProvider.OPENSSL) + .negotiateAuthConfig(negotiateAuthConfig) .build(); SimpleChannelPoolAwareChannelPool simpleChannelPoolAwareChannelPool = channelPoolMap.newPool( @@ -244,9 +336,11 @@ public void usingProxy_withAuth() { assertThat(requests).contains("CONNECT some-awesome-service:443"); - String authB64 = Base64.getEncoder().encodeToString("myuser:mypassword".getBytes(CharsetUtil.UTF_8)); - String authHeaderValue = String.format("Basic %s", authB64); - assertThat(requests).contains(String.format("proxy-authorization: %s", authHeaderValue)); + if (proxyAuthHeader == null) { + assertThat(requests).doesNotContain("proxy-authorization:"); + } else { + assertThat(requests).contains(String.format("proxy-authorization: %s", proxyAuthHeader)); + } } @Test @@ -309,4 +403,14 @@ public void releaseChannel_autoReadEnabled() { assertThat(channel.config().isAutoRead()).isTrue(); } + private static Stream proxyAuthTestParams() { + return Stream.of( + Arguments.of(null, null, null, null), + Arguments.of(null, "user", "pass", "Basic dXNlcjpwYXNz"), + Arguments.of(ProxyAuthScheme.BASIC, "user", "pass", "Basic dXNlcjpwYXNz"), + Arguments.of(ProxyAuthScheme.NEGOTIATE, null, null, "Negotiate YII"), + Arguments.of(ProxyAuthScheme.NEGOTIATE, "user", "pass", "Negotiate YII") + + ); + } } diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGeneratorTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGeneratorTest.java new file mode 100644 index 000000000000..b0294ea768c3 --- /dev/null +++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGeneratorTest.java @@ -0,0 +1,69 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package software.amazon.awssdk.http.nio.netty.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.http.nio.netty.ProxyAuthScheme; + +public class BasicProxyAuthGeneratorTest { + private static final String USERNAME = "user"; + private static final String PASSWORD = "pass"; + + private final BasicProxyAuthGenerator authGenerator = new BasicProxyAuthGenerator(USERNAME, PASSWORD); + + @ParameterizedTest(name = "username = {0}, password = {1}, expected error = {2}") + @MethodSource("invalidCtorParams") + void ctor_paramsInvalid_throws(String username, String password, String errorMessage) { + assertThatThrownBy(() -> new BasicProxyAuthGenerator(username, password)) + .hasMessageContaining(errorMessage); + } + + @Test + void scheme_returnsCorrectValue() { + assertThat(authGenerator.scheme()).isEqualTo(ProxyAuthScheme.BASIC); + } + + @Test + void generateAuthParams_generatedCorrectly() { + String expected = Base64.getEncoder() + .encodeToString(String.format("%s:%s", USERNAME, PASSWORD) + .getBytes(StandardCharsets.UTF_8)); + + assertThat(authGenerator.generateAuthParams(URI.create("http://amazon.com"))).isEqualTo(expected); + } + + private static Stream invalidCtorParams() { + return Stream.of( + Arguments.of(null, null, "username"), + Arguments.of("", "", "username"), + Arguments.of(null, PASSWORD, "username"), + Arguments.of("", PASSWORD, "username"), + Arguments.of(USERNAME, null, "password"), + Arguments.of(USERNAME, "", "password") + + ); + } +} diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPoolTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPoolTest.java index d43b404f3f5f..b61ac05e0b10 100644 --- a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPoolTest.java +++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPoolTest.java @@ -42,6 +42,8 @@ import io.netty.util.concurrent.Promise; import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.List; import java.util.concurrent.CountDownLatch; import javax.net.ssl.SSLEngine; @@ -73,6 +75,8 @@ public class Http1TunnelConnectionPoolTest { private static final String PROXY_PASSWORD = "mypassword"; + private static final ProxyAuthGenerator basicAuth = new BasicProxyAuthGenerator(PROXY_USER, PROXY_PASSWORD); + @Mock private ChannelPool delegatePool; @@ -115,7 +119,7 @@ public static void teardown() { @Test public void tunnelAlreadyEstablished_doesNotAddInitHandler() { Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration); + HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration); when(mockAttr.get()).thenReturn(true); @@ -127,7 +131,7 @@ public void tunnelAlreadyEstablished_doesNotAddInitHandler() { @Test(timeout = 1000) public void tunnelNotEstablished_addsInitHandler() throws InterruptedException { Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration); + HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration); when(mockAttr.get()).thenReturn(false); @@ -149,7 +153,7 @@ public void tunnelInitFails_acquireFutureFails() { }; Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS,null, null, REMOTE_ADDRESS, mockHandler, supplier, configuration); + HTTP_PROXY_ADDRESS,null, REMOTE_ADDRESS, mockHandler, supplier, configuration); Future acquireFuture = tunnelPool.acquire(); @@ -164,7 +168,7 @@ public void tunnelInitSucceeds_acquireFutureSucceeds() { }; Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS, null, null, REMOTE_ADDRESS, mockHandler, supplier, configuration); + HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, supplier, configuration); Future acquireFuture = tunnelPool.acquire(); @@ -174,7 +178,7 @@ public void tunnelInitSucceeds_acquireFutureSucceeds() { @Test public void acquireFromDelegatePoolFails_failsFuture() { Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration); + HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration); when(delegatePool.acquire(any(Promise.class))).thenReturn(GROUP.next().newFailedFuture(new IOException("boom"))); @@ -197,7 +201,7 @@ public void sslContextProvided_andProxyUsingHttps_addsSslHandler() { }; Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, mockSslCtx, - HTTPS_PROXY_ADDRESS, null, null, REMOTE_ADDRESS, mockHandler, supplier, configuration); + HTTPS_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, supplier, configuration); tunnelPool.acquire().awaitUninterruptibly(); @@ -218,7 +222,7 @@ public void sslContextProvided_andProxyNotUsingHttps_doesNotAddSslHandler() { }; Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, mockSslCtx, - HTTP_PROXY_ADDRESS, null, null, REMOTE_ADDRESS, mockHandler, supplier, configuration); + HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, supplier, configuration); tunnelPool.acquire().awaitUninterruptibly(); @@ -231,7 +235,7 @@ public void sslContextProvided_andProxyNotUsingHttps_doesNotAddSslHandler() { @Test public void release_releasedToDelegatePool() { Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration); + HTTP_PROXY_ADDRESS,null, REMOTE_ADDRESS, mockHandler, configuration); tunnelPool.release(mockChannel); verify(delegatePool).release(eq(mockChannel), any(Promise.class)); } @@ -239,7 +243,7 @@ public void release_releasedToDelegatePool() { @Test public void release_withGivenPromise_releasedToDelegatePool() { Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration); + HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration); Promise mockPromise = mock(Promise.class); tunnelPool.release(mockChannel, mockPromise); verify(delegatePool).release(eq(mockChannel), eq(mockPromise)); @@ -248,7 +252,7 @@ public void release_withGivenPromise_releasedToDelegatePool() { @Test public void close_closesDelegatePool() { Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration); + HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration); tunnelPool.close(); verify(delegatePool).close(); } @@ -257,42 +261,33 @@ public void close_closesDelegatePool() { public void proxyAuthProvided_addInitHandler_withAuth(){ TestInitHandlerData data = new TestInitHandlerData(); - Http1TunnelConnectionPool.InitHandlerSupplier supplier = (srcPool, proxyUser, proxyPassword, remoteAddr, initFuture) -> { + Http1TunnelConnectionPool.InitHandlerSupplier supplier = + (srcPool, proxyEndpoint, proxyAuthGenerator, remoteAddr, initFuture) -> { initFuture.setSuccess(mockChannel); - data.proxyUser(proxyUser); - data.proxyPassword(proxyPassword); + data.authHeader = proxyAuthGenerator.generateAuthParams(proxyEndpoint); return mock(ChannelHandler.class); }; Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null, - HTTP_PROXY_ADDRESS, PROXY_USER, PROXY_PASSWORD, REMOTE_ADDRESS, mockHandler, supplier, configuration); + HTTP_PROXY_ADDRESS, basicAuth, REMOTE_ADDRESS, mockHandler, supplier, configuration); tunnelPool.acquire().awaitUninterruptibly(); - assertThat(data.proxyUser()).isEqualTo(PROXY_USER); - assertThat(data.proxyPassword()).isEqualTo(PROXY_PASSWORD); - + String expectedAuthHeader = Base64.getEncoder().encodeToString((PROXY_USER + ":" + PROXY_PASSWORD) + .getBytes(StandardCharsets.UTF_8)); + assertThat(data.authHeader()).isEqualTo(expectedAuthHeader); } private static class TestInitHandlerData { - private String proxyUser; - private String proxyPassword; - - public void proxyUser(String proxyUser) { - this.proxyUser = proxyUser; - } - - public String proxyUser() { - return this.proxyUser; - } + private String authHeader; - public void proxyPassword(String proxyPassword) { - this.proxyPassword = proxyPassword; + public void authHeader(String authHeader) { + this.authHeader = authHeader; } - public String proxyPassword(){ - return this.proxyPassword; + public String authHeader() { + return authHeader; } } diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGeneratorTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGeneratorTest.java new file mode 100644 index 000000000000..218630a46ac4 --- /dev/null +++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGeneratorTest.java @@ -0,0 +1,151 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package software.amazon.awssdk.http.nio.netty.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import org.apache.kerby.kerberos.kerb.KrbException; +import org.apache.kerby.kerberos.kerb.client.KrbClient; +import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer; +import org.apache.kerby.kerberos.kerb.type.ticket.TgtTicket; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.testutils.FileUtils; + +public class NegotiateProxyAuthGeneratorTest { + private static final String KRB5_PROP = "java.security.krb5.conf"; + private static Path tempDir; + private static Path keytabFile; + private static Path ccacheFile; + private static int port; + + private static SimpleKdcServer kdc; + private static String krb5PropSave; + + private static Configuration config; + + @BeforeAll + static void setup() throws IOException, KrbException { + tempDir = Files.createTempDirectory(null); + keytabFile = tempDir.resolve("keytab"); + ccacheFile = tempDir.resolve("ccache"); + + try (Socket freePort = new Socket()) { + freePort.setReuseAddress(true); + freePort.bind(new InetSocketAddress(0)); + port = freePort.getLocalPort(); + + kdc = new SimpleKdcServer(); + kdc.setKdcRealm("EXAMPLE.COM"); + kdc.setKdcHost("localhost"); + kdc.setWorkDir(tempDir.toFile()); + kdc.setKdcTcpPort(port); + kdc.setAllowUdp(false); + kdc.init(); + + krb5PropSave = System.getProperty(KRB5_PROP); + + System.setProperty(KRB5_PROP, tempDir.resolve("krb5.conf").toAbsolutePath().toString()); + + kdc.start(); + + kdc.createPrincipal("alice@EXAMPLE.COM", "alicePassword"); + kdc.createAndExportPrincipals(keytabFile.toFile(), "HTTP/localhost@EXAMPLE.COM"); + + // initialize the ticket cache + KrbClient krbClient = kdc.getKrbClient(); + TgtTicket tgt = krbClient.requestTgt("alice@EXAMPLE.COM", "alicePassword"); + krbClient.storeTicket(tgt, ccacheFile.toFile()); + + // Override config so we look at the testing cache instead of the real system cache + config = new Configuration() { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + Map opts = new HashMap<>(); + opts.put("useTicketCache", "true"); + opts.put("ticketCache", ccacheFile.toAbsolutePath().toString()); + opts.put("doNotPrompt", "true"); + opts.put("refreshKrb5Config", "true"); + return new AppConfigurationEntry[] { + new AppConfigurationEntry( + "com.sun.security.auth.module.Krb5LoginModule", + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, opts) + }; + } + }; + } + + } + + @AfterAll + static void teardown() throws KrbException { + if (krb5PropSave != null) { + System.setProperty(KRB5_PROP, krb5PropSave); + } else { + System.clearProperty(KRB5_PROP); + } + kdc.stop(); + FileUtils.cleanUpTestDirectory(tempDir); + } + + @Test + void generateAuthParams_configValid_successfullyGeneratesToken() { + NegotiateProxyAuthGenerator authGenerator = new NegotiateProxyAuthGenerator(config); + + URI proxyEndpoint = URI.create("https://localhost:8192"); + + assertThat(authGenerator.generateAuthParams(proxyEndpoint)).startsWith("YII"); + } + + @Test + void generateAuthParams_ticketCacheMissing_failsWithActionableMessage() { + Configuration missingCacheConfig = new Configuration() { + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + Map opts = new HashMap<>(); + opts.put("useTicketCache", "true"); + opts.put("ticketCache", tempDir.resolve("nonexistent-cache").toAbsolutePath().toString()); + opts.put("doNotPrompt", "true"); + opts.put("refreshKrb5Config", "true"); + return new AppConfigurationEntry[] { + new AppConfigurationEntry( + "com.sun.security.auth.module.Krb5LoginModule", + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, opts) + }; + } + }; + + NegotiateProxyAuthGenerator authGenerator = new NegotiateProxyAuthGenerator(missingCacheConfig); + + assertThatThrownBy(() -> authGenerator.generateAuthParams(URI.create("https://localhost:8192"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("kinit") + .hasMessageContaining("ticket cache"); + } + +} diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandlerTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandlerTest.java index 9836a953bda9..143cc174701f 100644 --- a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandlerTest.java +++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandlerTest.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.http.nio.netty.internal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -45,6 +46,7 @@ import java.io.IOException; import java.net.URI; import java.util.Base64; +import java.util.concurrent.ExecutionException; import java.util.function.Supplier; import org.junit.AfterClass; import org.junit.Before; @@ -53,6 +55,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.http.nio.netty.ProxyAuthScheme; /** * Unit tests for {@link ProxyTunnelInitHandler}. @@ -95,7 +98,8 @@ public void addedToPipeline_addsCodec() { Supplier codecSupplier = () -> codec; when(mockCtx.name()).thenReturn("foo"); - ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, null, null, REMOTE_HOST, null, codecSupplier); + ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, null, null, null, REMOTE_HOST, null, + codecSupplier); handler.handlerAdded(mockCtx); verify(mockPipeline).addBefore(eq("foo"), eq(null), eq(codec)); @@ -202,7 +206,7 @@ public void handlerRemoved_removesCodec() { } @Test - public void handledAdded_writesRequest_withoutAuth() { + public void handlerAdded_writesRequest_withoutAuth() { Promise promise = GROUP.next().newPromise(); ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, REMOTE_HOST, promise); handler.handlerAdded(mockCtx); @@ -219,7 +223,7 @@ public void handledAdded_writesRequest_withoutAuth() { } @Test - public void handledAdded_writesRequest_withAuth() { + public void handlerAdded_writesRequest_withAuth() { Promise promise = GROUP.next().newPromise(); ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, PROXY_USER, PROXY_PASSWORD, REMOTE_HOST, promise); handler.handlerAdded(mockCtx); @@ -238,6 +242,23 @@ public void handledAdded_writesRequest_withAuth() { assertThat(requestCaptor.getValue()).isEqualTo(expectedRequest); } + @Test + public void handlerAdded_authParamsGeneratorThrows_failsFuture() { + ProxyAuthGenerator authGenerator = mock(ProxyAuthGenerator.class); + when(authGenerator.scheme()).thenReturn(ProxyAuthScheme.BASIC); + when(authGenerator.generateAuthParams(any(URI.class))).thenThrow(new RuntimeException("auth generator error")); + + Promise promise = GROUP.next().newPromise(); + ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, URI.create("https://amazon.com"), + authGenerator, + REMOTE_HOST, + promise); + handler.handlerAdded(mockCtx); + + assertThatThrownBy(promise::get).hasMessageContaining("Unable to send CONNECT request to proxy") + .hasRootCauseMessage("auth generator error"); + } + private void successResponse(ProxyTunnelInitHandler handler) { DefaultHttpResponse resp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); handler.channelRead(mockCtx, resp); diff --git a/pom.xml b/pom.xml index 4714e90d93e6..08510f5e05f4 100644 --- a/pom.xml +++ b/pom.xml @@ -151,6 +151,7 @@ 1.17.5 1.3.0 1.5.4 + 2.0.3 3.1.2