diff --git a/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/util/SecurityUtils.java b/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/util/SecurityUtils.java new file mode 100644 index 0000000000..ae3ad76ecc --- /dev/null +++ b/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/util/SecurityUtils.java @@ -0,0 +1,221 @@ +/* + * ==================================================================== + * 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.testing.util; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.security.PrivilegedAction; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletionException; +import javax.security.auth.Subject; + +import org.apache.hc.core5.annotation.Internal; + +/** + * This class is based on SecurityUtils in Apache Avatica which is loosely based on SecurityUtils in + * Jetty 12.0 + *

+ * Collections of utility methods to deal with the scheduled removal of the security classes defined + * by JEP 411. + *

+ */ +@Internal +public class SecurityUtils { + private static final MethodHandle CALL_AS = lookupCallAs(); + private static final MethodHandle CURRENT = lookupCurrent(); + private static final MethodHandle DO_PRIVILEGED = lookupDoPrivileged(); + + private SecurityUtils() { + } + + private static MethodHandle lookupCallAs() { + final MethodHandles.Lookup lookup = MethodHandles.lookup(); + try { + try { + // Subject.doAs() is deprecated for removal and replaced by Subject.callAs(). + // Lookup first the new API, since for Java versions where both exist, the + // new API delegates to the old API (for example Java 18, 19 and 20). + // Otherwise (Java 17), lookup the old API. + return lookup.findStatic(Subject.class, "callAs", + MethodType.methodType(Object.class, Subject.class, Callable.class)); + } catch (final NoSuchMethodException x) { + try { + // Lookup the old API. + final MethodType oldSignature = + MethodType.methodType(Object.class, Subject.class, + PrivilegedExceptionAction.class); + final MethodHandle doAs = + lookup.findStatic(Subject.class, "doAs", oldSignature); + // Convert the Callable used in the new API to the PrivilegedAction used in the + // old + // API. + final MethodType convertSignature = + MethodType.methodType(PrivilegedExceptionAction.class, Callable.class); + final MethodHandle converter = + lookup.findStatic(SecurityUtils.class, + "callableToPrivilegedExceptionAction", convertSignature); + return MethodHandles.filterArguments(doAs, 1, converter); + } catch (final NoSuchMethodException e) { + throw new AssertionError(e); + } + } + } catch (final IllegalAccessException e) { + throw new AssertionError(e); + } + } + + private static MethodHandle lookupDoPrivileged() { + try { + // Use reflection to work with Java versions that have and don't have AccessController. + final Class klass = + ClassLoader.getSystemClassLoader().loadClass("java.security.AccessController"); + final MethodHandles.Lookup lookup = MethodHandles.lookup(); + return lookup.findStatic(klass, "doPrivileged", + MethodType.methodType(Object.class, PrivilegedAction.class)); + } catch (final NoSuchMethodException | IllegalAccessException x) { + // Assume that single methods won't be removed from AcessController + throw new AssertionError(x); + } catch (final ClassNotFoundException e) { + return null; + } + } + + private static MethodHandle lookupCurrent() { + final MethodHandles.Lookup lookup = MethodHandles.lookup(); + try { + // Subject.getSubject(AccessControlContext) is deprecated for removal and replaced by + // Subject.current(). + // Lookup first the new API, since for Java versions where both exists, the + // new API delegates to the old API (for example Java 18, 19 and 20). + // Otherwise (Java 17), lookup the old API. + return lookup.findStatic(Subject.class, "current", + MethodType.methodType(Subject.class)); + } catch (final NoSuchMethodException e) { + final MethodHandle getContext = lookupGetContext(); + final MethodHandle getSubject = lookupGetSubject(); + return MethodHandles.filterReturnValue(getContext, getSubject); + } catch (final IllegalAccessException e) { + throw new AssertionError(e); + } + } + + private static MethodHandle lookupGetSubject() { + final MethodHandles.Lookup lookup = MethodHandles.lookup(); + try { + final Class contextklass = + ClassLoader.getSystemClassLoader() + .loadClass("java.security.AccessControlContext"); + return lookup.findStatic(Subject.class, "getSubject", + MethodType.methodType(Subject.class, contextklass)); + } catch (final ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) { + throw new AssertionError(e); + } + } + + private static MethodHandle lookupGetContext() { + try { + // Use reflection to work with Java versions that have and don't have AccessController. + final Class controllerKlass = + ClassLoader.getSystemClassLoader().loadClass("java.security.AccessController"); + final Class contextklass = + ClassLoader.getSystemClassLoader() + .loadClass("java.security.AccessControlContext"); + + final MethodHandles.Lookup lookup = MethodHandles.lookup(); + return lookup.findStatic(controllerKlass, "getContext", + MethodType.methodType(contextklass)); + } catch (final ClassNotFoundException | NoSuchMethodException | IllegalAccessException e) { + throw new AssertionError(e); + } + } + + /** + * Maps to AccessController#doPrivileged if available, otherwise returns action.run(). + * @param action the action to run + * @return the result of running the action + * @param the type of the result + */ + public static T doPrivileged(final PrivilegedAction action) { + // Keep this method short and inlineable. + if (DO_PRIVILEGED == null) { + return action.run(); + } + return doPrivileged(DO_PRIVILEGED, action); + } + + private static T doPrivileged(final MethodHandle doPrivileged, final PrivilegedAction action) { + try { + return (T) doPrivileged.invoke(action); + } catch (final Throwable t) { + throw sneakyThrow(t); + } + } + + /** + * Maps to Subject.callAs() if available, otherwise maps to Subject.doAs() + * @param subject the subject this action runs as + * @param action the action to run + * @return the result of the action + * @param the type of the result + * @throws CompletionException + */ + public static T callAs(final Subject subject, final Callable action) throws CompletionException { + try { + return (T) CALL_AS.invoke(subject, action); + } catch (final PrivilegedActionException e) { + throw new CompletionException(e.getCause()); + } catch (final Throwable t) { + throw sneakyThrow(t); + } + } + + /** + * Maps to Subject.currect() is available, otherwise maps to Subject.getSubject() + * @return the current subject + */ + public static Subject currentSubject() { + try { + return (Subject) CURRENT.invoke(); + } catch (final Throwable t) { + throw sneakyThrow(t); + } + } + + @SuppressWarnings("unused") + private static PrivilegedExceptionAction + callableToPrivilegedExceptionAction(final Callable callable) { + return callable::call; + } + + @SuppressWarnings("unchecked") + private static RuntimeException sneakyThrow(final Throwable e) throws E { + throw (E) e; + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ApacheHTTPDSquidCompatibilityIT.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ApacheHTTPDSquidCompatibilityIT.java index 68ed99e54b..1600311d84 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ApacheHTTPDSquidCompatibilityIT.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ApacheHTTPDSquidCompatibilityIT.java @@ -26,11 +26,20 @@ */ package org.apache.hc.client5.testing.compatibility; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import javax.security.auth.Subject; + import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.testing.compatibility.async.CachingHttpAsyncClientCompatibilityTest; import org.apache.hc.client5.testing.compatibility.async.HttpAsyncClientCompatibilityTest; import org.apache.hc.client5.testing.compatibility.async.HttpAsyncClientHttp1CompatibilityTest; import org.apache.hc.client5.testing.compatibility.async.HttpAsyncClientProxyCompatibilityTest; +import org.apache.hc.client5.testing.compatibility.spnego.SpnegoTestUtil; import org.apache.hc.client5.testing.compatibility.sync.CachingHttpClientCompatibilityTest; import org.apache.hc.client5.testing.compatibility.sync.HttpClientCompatibilityTest; import org.apache.hc.client5.testing.compatibility.sync.HttpClientProxyCompatibilityTest; @@ -38,6 +47,7 @@ import org.apache.hc.core5.http.URIScheme; import org.apache.hc.core5.http2.HttpVersionPolicy; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.testcontainers.containers.GenericContainer; @@ -49,10 +59,23 @@ class ApacheHTTPDSquidCompatibilityIT { private static Network NETWORK = Network.newNetwork(); + private static final Path KEYTAB_DIR = SpnegoTestUtil.createKeytabDir(); + + @Container + static final GenericContainer KDC = ContainerImages.KDC(NETWORK, KEYTAB_DIR); @Container - static final GenericContainer HTTPD_CONTAINER = ContainerImages.apacheHttpD(NETWORK); + static final GenericContainer HTTPD_CONTAINER = ContainerImages.apacheHttpD(NETWORK, KEYTAB_DIR, KDC); @Container - static final GenericContainer SQUID = ContainerImages.squid(NETWORK); + static final GenericContainer SQUID = ContainerImages.squid(NETWORK, KEYTAB_DIR, KDC); + + private static Path KRB5_CONF_PATH; + private static Subject spnegoSubject; + + @BeforeAll + static void init() throws IOException { + KRB5_CONF_PATH = SpnegoTestUtil.prepareKrb5Conf(KDC.getHost() + ":" + KDC.getMappedPort(ContainerImages.KDC_PORT)); + spnegoSubject = SpnegoTestUtil.loginFromKeytab("testclient", KEYTAB_DIR.resolve("testclient.keytab")); + } static HttpHost targetContainerHost() { return new HttpHost(URIScheme.HTTP.id, HTTPD_CONTAINER.getHost(), HTTPD_CONTAINER.getMappedPort(ContainerImages.HTTP_PORT)); @@ -74,193 +97,411 @@ static HttpHost proxyContainerHost() { return new HttpHost(URIScheme.HTTP.id, SQUID.getHost(), SQUID.getMappedPort(ContainerImages.PROXY_PORT)); } - static HttpHost proxyPwProtectedContainerHost() { + static HttpHost proxyAuthenticatedContainerHost() { return new HttpHost(URIScheme.HTTP.id, SQUID.getHost(), SQUID.getMappedPort(ContainerImages.PROXY_PW_PROTECTED_PORT)); } @AfterAll static void cleanup() { + try { + // Let tail -F for squid logs catch up + Thread.sleep(5 * 1000); + } catch (final InterruptedException e) { + // Do nothring + } SQUID.close(); HTTPD_CONTAINER.close(); + KDC.close(); NETWORK.close(); + try { + Files.delete(KRB5_CONF_PATH); + Files.delete(KRB5_CONF_PATH.getParent()); + try ( Stream dirStream = Files.walk(KEYTAB_DIR)) { + dirStream + .filter(Files::isRegularFile) + .map(Path::toFile) + .forEach(File::delete); + } + } catch (final IOException e) { + //We leave some files around in tmp + } } @Nested - @DisplayName("Classic client: HTTP/1.1, plain, direct connection") - class ClassicDirectHttp extends HttpClientCompatibilityTest { + @DisplayName("Classic client: HTTP/1.1, plain, password, direct connection") + class ClassicDirectHttpPw extends HttpClientCompatibilityTest { + + public ClassicDirectHttpPw() throws Exception { + super(targetContainerHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + null, + null, + null); + } - public ClassicDirectHttp() throws Exception { - super(targetContainerHost(), null, null); + } + + @Nested + @DisplayName("Classic client: HTTP/1.1, plain, SPNEGO, direct connection") + class ClassicDirectHttpSpnego extends HttpClientCompatibilityTest { + + public ClassicDirectHttpSpnego() throws Exception { + super(targetContainerHost(), + SpnegoTestUtil.createCredentials(spnegoSubject), + null, + null, + spnegoSubject); } } @Nested - @DisplayName("Classic client: HTTP/1.1, plain, connection via proxy") + @DisplayName("Classic client: HTTP/1.1, plain, password, connection via proxy") class ClassicViaProxyHttp extends HttpClientCompatibilityTest { public ClassicViaProxyHttp() throws Exception { - super(targetInternalHost(), proxyContainerHost(), null); + super(targetInternalHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyContainerHost(), + null, + null); } } @Nested - @DisplayName("Classic client: HTTP/1.1, plain, connection via password protected proxy") - class ClassicViaPwProtectedProxyHttp extends HttpClientCompatibilityTest { - - public ClassicViaPwProtectedProxyHttp() throws Exception { - super(targetInternalHost(), proxyPwProtectedContainerHost(), new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); + @DisplayName("Classic client: HTTP/1.1, TLS, password, direct connection") + class ClassicDirectHttpTlsPw extends HttpClientCompatibilityTest { + + public ClassicDirectHttpTlsPw() throws Exception { + super(targetContainerTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + null, + null, + null); } } @Nested - @DisplayName("Classic client: HTTP/1.1, TLS, direct connection") - class ClassicDirectHttpTls extends HttpClientCompatibilityTest { - - public ClassicDirectHttpTls() throws Exception { - super(targetContainerTlsHost(), null, null); + @DisplayName("Classic client: HTTP/1.1, TLS, SPNEGO, direct connection") + class ClassicDirectHttpTlsSpnego extends HttpClientCompatibilityTest { + + public ClassicDirectHttpTlsSpnego() throws Exception { + super(targetContainerTlsHost(), + SpnegoTestUtil.createCredentials(spnegoSubject), + null, + null, + spnegoSubject); } } @Nested - @DisplayName("Classic client: HTTP/1.1, TLS, connection via proxy (tunnel)") + @DisplayName("Classic client: HTTP/1.1, TLS, password, connection via proxy (tunnel)") class ClassicViaProxyHttpTls extends HttpClientCompatibilityTest { public ClassicViaProxyHttpTls() throws Exception { - super(targetInternalTlsHost(), proxyContainerHost(), null); + super(targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyContainerHost(), + null, + null); } } @Nested - @DisplayName("Classic client: HTTP/1.1, TLS, connection via password protected proxy (tunnel)") + @DisplayName("Classic client: HTTP/1.1, TLS, password, connection via password protected proxy (tunnel)") class ClassicViaPwProtectedProxyHttpTls extends HttpClientCompatibilityTest { public ClassicViaPwProtectedProxyHttpTls() throws Exception { - super(targetInternalTlsHost(), proxyPwProtectedContainerHost(), new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); + super(targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + new UsernamePasswordCredentials("squid", "nopassword".toCharArray()), + null); } } @Nested - @DisplayName("Async client: HTTP/1.1, plain, direct connection") - class AsyncDirectHttp1 extends HttpAsyncClientHttp1CompatibilityTest { + @DisplayName("Classic client: HTTP/1.1, TLS, password, connection via SPNEGO protected proxy (tunnel)") + class ClassicViaSpnegoProtectedProxyHttpTls extends HttpClientCompatibilityTest { + + public ClassicViaSpnegoProtectedProxyHttpTls() throws Exception { + super(targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + SpnegoTestUtil.createCredentials(spnegoSubject), + null); + } + + } + + @Nested + @DisplayName("Async client: HTTP/1.1, plain, password, direct connection") + class AsyncDirectHttp1Pw extends HttpAsyncClientHttp1CompatibilityTest { + + public AsyncDirectHttp1Pw() throws Exception { + super(targetContainerHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + null, + null); + } - public AsyncDirectHttp1() throws Exception { - super(targetContainerHost(), null, null); + } + + @Nested + @DisplayName("Async client: HTTP/1.1, plain, SPNEGO, direct connection") + class AsyncDirectHttp1Spnego extends HttpAsyncClientHttp1CompatibilityTest { + + public AsyncDirectHttp1Spnego() throws Exception { + super(targetContainerHost(), + SpnegoTestUtil.createCredentials(spnegoSubject), + null, + null); } } @Nested - @DisplayName("Async client: HTTP/1.1, plain, connection via proxy") + @DisplayName("Async client: HTTP/1.1, plain, password, connection via proxy") class AsyncViaProxyHttp1 extends HttpAsyncClientHttp1CompatibilityTest { public AsyncViaProxyHttp1() throws Exception { - super(targetInternalHost(), proxyContainerHost(), null); + super(targetInternalHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyContainerHost(), + null); } } @Nested - @DisplayName("Async client: HTTP/1.1, plain, connection via password protected proxy") - class AsyncViaPwProtectedProxyHttp1 extends HttpAsyncClientHttp1CompatibilityTest { + @DisplayName("Async client: HTTP/1.1, plain, password, connection via password protected proxy") + class AsyncViaPwProtectedProxyHttp1Pw extends HttpAsyncClientHttp1CompatibilityTest { + + public AsyncViaPwProtectedProxyHttp1Pw() throws Exception { + super(targetInternalHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); + } - public AsyncViaPwProtectedProxyHttp1() throws Exception { - super(targetInternalHost(), proxyPwProtectedContainerHost(), new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); + } + + @Nested + @DisplayName("Async client: HTTP/1.1, plain, passwsord, connection via SPNEGO protected proxy") + class AsyncViaPwProtectedProxyHttp1Spnego extends HttpAsyncClientHttp1CompatibilityTest { + + public AsyncViaPwProtectedProxyHttp1Spnego() throws Exception { + super(targetInternalHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + SpnegoTestUtil.createCredentials(spnegoSubject)); } } @Nested - @DisplayName("Async client: HTTP/1.1, TLS, direct connection") - class AsyncDirectHttp1Tls extends HttpAsyncClientHttp1CompatibilityTest { + @DisplayName("Async client: HTTP/1.1, TLS, password, direct connection") + class AsyncDirectHttp1TlsPw extends HttpAsyncClientHttp1CompatibilityTest { + + public AsyncDirectHttp1TlsPw() throws Exception { + super(targetContainerTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + null, + null); + } + + } - public AsyncDirectHttp1Tls() throws Exception { - super(targetContainerTlsHost(), null, null); + @Nested + @DisplayName("Async client: HTTP/1.1, TLS, SPNEGO, direct connection") + class AsyncDirectHttp1TlsSpnego extends HttpAsyncClientHttp1CompatibilityTest { + + public AsyncDirectHttp1TlsSpnego() throws Exception { + super(targetContainerTlsHost(), + SpnegoTestUtil.createCredentials(spnegoSubject), + null, + null); } } @Nested - @DisplayName("Async client: HTTP/1.1, TLS, connection via proxy (tunnel)") + @DisplayName("Async client: HTTP/1.1, TLS, password, connection via proxy (tunnel)") class AsyncViaProxyHttp1Tls extends HttpAsyncClientHttp1CompatibilityTest { public AsyncViaProxyHttp1Tls() throws Exception { - super(targetInternalTlsHost(), proxyContainerHost(), null); + super(targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyContainerHost(), + null); } } @Nested - @DisplayName("Async client: HTTP/1.1, TLS, connection via password protected proxy (tunnel)") - class AsyncViaPwProtectedProxyHttp1Tls extends HttpAsyncClientHttp1CompatibilityTest { - - public AsyncViaPwProtectedProxyHttp1Tls() throws Exception { - super(targetInternalTlsHost(), proxyPwProtectedContainerHost(), new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); + @DisplayName("Async client: HTTP/1.1, TLS, password, connection via password protected proxy (tunnel)") + class AsyncViaPwProtectedProxyHttp1TlsPw extends HttpAsyncClientHttp1CompatibilityTest { + + public AsyncViaPwProtectedProxyHttp1TlsPw() throws Exception { + super(targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); } } @Nested - @DisplayName("Async client: HTTP/2, plain, direct connection") - class AsyncDirectHttp2 extends HttpAsyncClientCompatibilityTest { + @DisplayName("Async client: HTTP/1.1, TLS, password, connection via SPNEGO protected proxy (tunnel)") + class AsyncViaPwProtectedProxyHttp1TlsSpnego extends HttpAsyncClientHttp1CompatibilityTest { + + public AsyncViaPwProtectedProxyHttp1TlsSpnego() throws Exception { + super(targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + SpnegoTestUtil.createCredentials(spnegoSubject)); + } + + } - public AsyncDirectHttp2() throws Exception { - super(HttpVersionPolicy.FORCE_HTTP_2, targetContainerHost(), null, null); + @Nested + @DisplayName("Async client: HTTP/2, plain, password, direct connection") + class AsyncDirectHttp2Pw extends HttpAsyncClientCompatibilityTest { + + public AsyncDirectHttp2Pw() throws Exception { + super(HttpVersionPolicy.FORCE_HTTP_2, targetContainerHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + null, + null); } } @Nested - @DisplayName("Async client: HTTP/2, TLS, direct connection") - class AsyncDirectHttp2Tls extends HttpAsyncClientCompatibilityTest { + @DisplayName("Async client: HTTP/2, plain, SPNEGO, direct connection") + class AsyncDirectHttp2Spnego extends HttpAsyncClientCompatibilityTest { + + public AsyncDirectHttp2Spnego() throws Exception { + super(HttpVersionPolicy.FORCE_HTTP_2, + targetContainerHost(), + SpnegoTestUtil.createCredentials(spnegoSubject), + null, + null); + } - public AsyncDirectHttp2Tls() throws Exception { - super(HttpVersionPolicy.FORCE_HTTP_2, targetContainerTlsHost(), null, null); + } + + @Nested + @DisplayName("Async client: HTTP/2, TLS, password, direct connection") + class AsyncDirectHttp2TlsPw extends HttpAsyncClientCompatibilityTest { + + public AsyncDirectHttp2TlsPw() throws Exception { + super(HttpVersionPolicy.FORCE_HTTP_2, + targetContainerTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + null, + null); } } @Nested - @DisplayName("Async client: HTTP/2, TLS, connection via proxy (tunnel)") + @DisplayName("Async client: HTTP/2, TLS, SPNEGO, direct connection") + class AsyncDirectHttp2TlsSpnego extends HttpAsyncClientCompatibilityTest { + + public AsyncDirectHttp2TlsSpnego() throws Exception { + super(HttpVersionPolicy.FORCE_HTTP_2, + targetContainerTlsHost(), + SpnegoTestUtil.createCredentials(spnegoSubject), + null, + null); + } + + } + @Nested + @DisplayName("Async client: HTTP/2, TLS, password, connection via proxy (tunnel)") class AsyncViaProxyHttp2Tls extends HttpAsyncClientCompatibilityTest { public AsyncViaProxyHttp2Tls() throws Exception { - super(HttpVersionPolicy.FORCE_HTTP_2, targetInternalTlsHost(), proxyContainerHost(), null); + super(HttpVersionPolicy.FORCE_HTTP_2, + targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyContainerHost(), null); } } @Nested - @DisplayName("Async client: HTTP/2, TLS, connection via password protected proxy (tunnel)") + @DisplayName("Async client: HTTP/2, TLS, password, connection via password protected proxy (tunnel)") class AsyncViaPwProtectedProxyHttp2Tls extends HttpAsyncClientCompatibilityTest { public AsyncViaPwProtectedProxyHttp2Tls() throws Exception { - super(HttpVersionPolicy.FORCE_HTTP_2, targetInternalTlsHost(), proxyPwProtectedContainerHost(), new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); + super(HttpVersionPolicy.FORCE_HTTP_2, + targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); } } @Nested - @DisplayName("Async client: protocol negotiate, TLS, connection via proxy (tunnel)") + @DisplayName("Async client: HTTP/2, TLS, password, connection via SPNEGO protected proxy (tunnel)") + class AsyncViaPwProtectedProxyHttp2TlsSpnego extends HttpAsyncClientCompatibilityTest { + + public AsyncViaPwProtectedProxyHttp2TlsSpnego() throws Exception { + super(HttpVersionPolicy.FORCE_HTTP_2, + targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + SpnegoTestUtil.createCredentials(spnegoSubject)); + } + + } + + @Nested + @DisplayName("Async client: protocol negotiate, TLS, password, connection via proxy (tunnel)") class AsyncViaProxyHttpNegotiateTls extends HttpAsyncClientCompatibilityTest { public AsyncViaProxyHttpNegotiateTls() throws Exception { - super(HttpVersionPolicy.NEGOTIATE, targetInternalTlsHost(), proxyContainerHost(), null); + super(HttpVersionPolicy.NEGOTIATE, + targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyContainerHost(), + null); } } @Nested - @DisplayName("Async client: protocol negotiate, TLS, connection via password protected proxy (tunnel)") + @DisplayName("Async client: protocol negotiate, TLS, password, connection via password protected proxy (tunnel)") class AsyncViaPwProtectedProxyHttpNegotiateTls extends HttpAsyncClientCompatibilityTest { public AsyncViaPwProtectedProxyHttpNegotiateTls() throws Exception { - super(HttpVersionPolicy.NEGOTIATE, targetInternalTlsHost(), proxyPwProtectedContainerHost(), new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); + super(HttpVersionPolicy.NEGOTIATE, + targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + new UsernamePasswordCredentials("squid", "nopassword".toCharArray())); + } + + } + + @Nested + @DisplayName("Async client: protocol negotiate, TLS, password, connection via SPNEGO protected proxy (tunnel)") + class AsyncViaPwProtectedProxyHttpNegotiateTlsSpnego extends HttpAsyncClientCompatibilityTest { + + public AsyncViaPwProtectedProxyHttpNegotiateTlsSpnego() throws Exception { + super(HttpVersionPolicy.NEGOTIATE, + targetInternalTlsHost(), + new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()), + proxyAuthenticatedContainerHost(), + SpnegoTestUtil.createCredentials(spnegoSubject)); } } @@ -330,7 +571,7 @@ public AsyncCachingHttp2Tls() throws Exception { class HttpClientProxy extends HttpClientProxyCompatibilityTest { public HttpClientProxy() throws Exception { - super(targetInternalTlsHost(), proxyPwProtectedContainerHost()); + super(targetInternalTlsHost(), proxyAuthenticatedContainerHost()); } } @@ -340,7 +581,7 @@ public HttpClientProxy() throws Exception { class AsyncClientProxy extends HttpAsyncClientProxyCompatibilityTest { public AsyncClientProxy() throws Exception { - super(targetInternalTlsHost(), proxyPwProtectedContainerHost()); + super(targetInternalTlsHost(), proxyAuthenticatedContainerHost()); } } diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ContainerImages.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ContainerImages.java index 0cb835b1f6..8de58e9ae0 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ContainerImages.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ContainerImages.java @@ -27,6 +27,7 @@ package org.apache.hc.client5.testing.compatibility; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.util.Random; import org.apache.hc.client5.http.utils.ByteArrayBuilder; @@ -45,6 +46,8 @@ public final class ContainerImages { public final static String WEB_SERVER = "test-httpd"; public final static int HTTP_PORT = 8080; public final static int HTTPS_PORT = 8443; + public final static String KDC_SERVER = "test-kdc"; + public final static int KDC_PORT = 88; public final static String PROXY = "test-proxy"; public final static int PROXY_PORT = 8888; public final static int PROXY_PW_PROTECTED_PORT = 8889; @@ -62,12 +65,13 @@ static byte[] randomData(final int max) { return builder.toByteArray(); } - public static GenericContainer apacheHttpD(final Network network) { + public static GenericContainer apacheHttpD(final Network network, final Path keytabsHostPath, final GenericContainer dependsOn) { return new GenericContainer<>(new ImageFromDockerfile() .withFileFromClasspath("server-cert.pem", "docker/server-cert.pem") .withFileFromClasspath("server-key.pem", "docker/server-key.pem") .withFileFromClasspath("httpd.conf", "docker/httpd/httpd.conf") .withFileFromClasspath("httpd-ssl.conf", "docker/httpd/httpd-ssl.conf") + .withFileFromClasspath("krb5.conf", "docker/kdc/krb5.conf") .withFileFromTransferable("111", Transferable.of(randomData(10240))) .withFileFromTransferable("222", Transferable.of(randomData(10240))) .withFileFromTransferable("333", Transferable.of(randomData(10240))) @@ -78,6 +82,7 @@ public static GenericContainer apacheHttpD(final Network network) { .env("var_dir", "/var/httpd") .env("www_dir", "${var_dir}/www") .env("private_dir", "${www_dir}/private") + .env("private_spnego_dir", "${www_dir}/private_spnego") .run("mkdir ${httpd_home}/ssl") .copy("server-cert.pem", "${httpd_home}/ssl/") .copy("server-key.pem", "${httpd_home}/ssl/") @@ -87,33 +92,76 @@ public static GenericContainer apacheHttpD(final Network network) { .copy("222", "${www_dir}/") .copy("333", "${www_dir}/") .run("mkdir -p ${private_dir}") + .run("mkdir -p ${private_spnego_dir}") //# user: testuser; pwd: nopassword - .run("echo \"testuser:{SHA}0Ybo2sSKJNARW1aNCrLJ6Lguats=\" > ${private_dir}/.htpasswd") - .run("echo \"testuser:Restricted Files:73deccd22e07066db8c405e5364335f5\" > ${private_dir}/.htpasswd_digest") - .run("echo \"Big Secret\" > ${private_dir}/big-secret.txt") + .run("echo \"testuser:{SHA}0Ybo2sSKJNARW1aNCrLJ6Lguats=\" > ${private_dir}/.htpasswd;" + + "echo \"testuser:Restricted Files:73deccd22e07066db8c405e5364335f5\" > ${private_dir}/.htpasswd_digest;" + + "echo \"Big Secret\" > ${private_dir}/big-secret.txt;" + + "echo \"Big Secret\" > ${private_spnego_dir}/big-secret.txt") + .env("MOD_AUTH_GSSAPI_PREFIX", "/usr/local/mod_auth_gssapi") + .run("mkdir -p \"$MOD_AUTH_GSSAPI_PREFIX\"") + .workDir("$MOD_AUTH_GSSAPI_PREFIX") + .run("apt-get update; apt-get install -y krb5-user libkrb5-dev " + + " wget automake libtool pkg-config bison flex " + + " libapr1-dev libaprutil1-dev libssl-dev make;" + + " wget https://github.com/gssapi/mod_auth_gssapi/releases/download/v1.6.5/mod_auth_gssapi-1.6.5.tar.gz;" + + " mkdir src; cd src; tar xfvz ../mod_auth_gssapi-1.6.5.tar.gz") + .run("cd src/mod_auth_gssapi-1.6.5;" + + " autoreconf -fi; ./configure; make; make install") + .copy("krb5.conf", "/etc/krb5.conf") .build())) .withNetwork(network) .withNetworkAliases(WEB_SERVER) + .withFileSystemBind(keytabsHostPath.toString(), "/keytabs") + .dependsOn(dependsOn) .withLogConsumer(new Slf4jLogConsumer(LOG)) .withExposedPorts(HTTP_PORT, HTTPS_PORT); } - public static GenericContainer squid(final Network network) { + public static GenericContainer squid(final Network network, final Path keytabsHostPath, final GenericContainer dependsOn) { return new GenericContainer<>(new ImageFromDockerfile() .withFileFromClasspath("squid.conf", "docker/squid/squid.conf") + .withFileFromClasspath("krb5.conf", "docker/kdc/krb5.conf") .withDockerfileFromBuilder(builder -> builder .from("ubuntu/squid:5.2-22.04_beta") .env("conf_dir", "/etc/squid") .copy("squid.conf", "${conf_dir}/") + .copy("krb5.conf", "/etc/krb5.conf") //# user: squid; pwd: nopassword .run("echo \"squid:\\$apr1\\$.5saX63T\\$cMSoCJPqEfUw9br6zBdSO0\" > ${conf_dir}/htpasswd") .build())) .withNetwork(network) .withNetworkAliases(PROXY) + .dependsOn(dependsOn) + .withFileSystemBind(keytabsHostPath.toString(), "/keytabs") .withLogConsumer(new Slf4jLogConsumer(LOG)) .withExposedPorts(PROXY_PORT, PROXY_PW_PROTECTED_PORT); } + // This image builds on Ubuntu 24.04 and uses the included KDC + public static GenericContainer KDC(final Network network, final Path keytabsHostPath) { + return new GenericContainer<>(new ImageFromDockerfile() + .withFileFromClasspath("krb5.conf", "docker/kdc/krb5.conf") + .withFileFromClasspath("start.sh", "docker/kdc/start.sh") + .withDockerfileFromBuilder(builder -> + builder + .from("ubuntu:noble") + .workDir("/workdir") + .volume("/keytabs") + .expose(KDC_PORT) + .copy("krb5.conf", "/etc/krb5.conf") + .copy("start.sh", ".") + .run("mkdir /var/log/kerberos && apt-get update" + + " && apt-get -y install krb5-kdc krb5-admin-server") + .cmd("/bin/sh", "start.sh") + .build())) + .withNetwork(network) + .withNetworkAliases(KDC_SERVER) + .withLogConsumer(new Slf4jLogConsumer(LOG)) + .withExposedPorts(KDC_PORT) + .withFileSystemBind(keytabsHostPath.toString(), "/keytabs"); + } + } diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/async/HttpAsyncClientCompatibilityTest.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/async/HttpAsyncClientCompatibilityTest.java index e6019d9952..49ff8a45f2 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/async/HttpAsyncClientCompatibilityTest.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/async/HttpAsyncClientCompatibilityTest.java @@ -38,10 +38,13 @@ import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.Credentials; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.auth.gss.GssCredentials; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.client5.testing.Result; +import org.apache.hc.client5.testing.compatibility.spnego.SpnegoAuthenticationStrategy; +import org.apache.hc.client5.testing.compatibility.spnego.SpnegoTestUtil; import org.apache.hc.client5.testing.extension.async.HttpAsyncClientResource; import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.HttpHost; @@ -60,23 +63,46 @@ public abstract class HttpAsyncClientCompatibilityTest { static final Timeout LONG_TIMEOUT = Timeout.ofSeconds(30); private final HttpVersionPolicy versionPolicy; - private final HttpHost target; + protected final HttpHost target; @RegisterExtension private final HttpAsyncClientResource clientResource; private final BasicCredentialsProvider credentialsProvider; + protected final Credentials targetCreds; + protected String secretPath = "/private/big-secret.txt"; public HttpAsyncClientCompatibilityTest( final HttpVersionPolicy versionPolicy, final HttpHost target, + final Credentials targetCreds, final HttpHost proxy, final Credentials proxyCreds) throws Exception { this.versionPolicy = versionPolicy; this.target = target; - this.clientResource = new HttpAsyncClientResource(versionPolicy); - this.clientResource.configure(builder -> builder.setProxy(proxy)); + this.targetCreds = targetCreds; this.credentialsProvider = new BasicCredentialsProvider(); - if (proxy != null && proxyCreds != null) { - this.credentialsProvider.setCredentials(new AuthScope(proxy), proxyCreds); + this.clientResource = new HttpAsyncClientResource(versionPolicy); + if (targetCreds != null) { + //this.setCredentials(new AuthScope(target), targetCreds); + if (targetCreds instanceof GssCredentials) { + secretPath = "/private_spnego/big-secret.txt"; + this.clientResource.configure(builder -> builder + .setTargetAuthenticationStrategy(new SpnegoAuthenticationStrategy()) + .setDefaultAuthSchemeRegistry(SpnegoTestUtil.getDefaultSpnegoSchemeRegistry())); + } + } + if (proxy != null) { + this.clientResource.configure(builder -> builder.setProxy(proxy)); + if (proxyCreds != null) { + this.setCredentials(new AuthScope(proxy), proxyCreds); + if (proxyCreds instanceof GssCredentials) { + // We disable Mutual Auth, because Squid does not support it. + // There is no way to set separate scheme registry for target/proxy, + // but that's not a problem as SPNEGO cannot be proxied anyway. + this.clientResource.configure(builder -> + builder.setProxyAuthenticationStrategy(new SpnegoAuthenticationStrategy()) + .setDefaultAuthSchemeRegistry(SpnegoTestUtil.getLegacySpnegoSchemeRegistry())); + } + } } } @@ -90,7 +116,7 @@ HttpClientContext context() { .build(); } - void addCredentials(final AuthScope authScope, final Credentials credentials) { + void setCredentials(final AuthScope authScope, final Credentials credentials) { credentialsProvider.setCredentials(authScope, credentials); } @@ -176,15 +202,15 @@ public void cancelled() { @Test void test_auth_failure_wrong_auth_scope() throws Exception { - addCredentials( + setCredentials( new AuthScope("http", "otherhost", -1, "Restricted Files", null), - new UsernamePasswordCredentials("testuser", "nopassword".toCharArray())); + targetCreds); final CloseableHttpAsyncClient client = client(); final HttpClientContext context = context(); final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get() .setHttpHost(target) - .setPath("/private/big-secret.txt") + .setPath(secretPath) .build(); final Future future = client.execute(httpGetSecret, context, null); final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit()); @@ -194,7 +220,7 @@ void test_auth_failure_wrong_auth_scope() throws Exception { @Test void test_auth_failure_wrong_auth_credentials() throws Exception { - addCredentials( + setCredentials( new AuthScope(target), new UsernamePasswordCredentials("testuser", "wrong password".toCharArray())); final CloseableHttpAsyncClient client = client(); @@ -202,7 +228,7 @@ void test_auth_failure_wrong_auth_credentials() throws Exception { final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get() .setHttpHost(target) - .setPath("/private/big-secret.txt") + .setPath(secretPath) .build(); final Future future = client.execute(httpGetSecret, context, null); final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit()); @@ -212,15 +238,15 @@ void test_auth_failure_wrong_auth_credentials() throws Exception { @Test void test_auth_success() throws Exception { - addCredentials( + setCredentials( new AuthScope(target), - new UsernamePasswordCredentials("testuser", "nopassword".toCharArray())); + targetCreds); final CloseableHttpAsyncClient client = client(); final HttpClientContext context = context(); final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get() .setHttpHost(target) - .setPath("/private/big-secret.txt") + .setPath(secretPath) .build(); final Future future = client.execute(httpGetSecret, context, null); final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit()); diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/async/HttpAsyncClientHttp1CompatibilityTest.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/async/HttpAsyncClientHttp1CompatibilityTest.java index 901e655c4b..503d915eb5 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/async/HttpAsyncClientHttp1CompatibilityTest.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/async/HttpAsyncClientHttp1CompatibilityTest.java @@ -33,7 +33,6 @@ import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.Credentials; -import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.core5.http.HeaderElements; @@ -46,27 +45,26 @@ public abstract class HttpAsyncClientHttp1CompatibilityTest extends HttpAsyncClientCompatibilityTest { - private final HttpHost target; public HttpAsyncClientHttp1CompatibilityTest( final HttpHost target, + final Credentials targetCreds, final HttpHost proxy, final Credentials proxyCreds) throws Exception { - super(HttpVersionPolicy.FORCE_HTTP_1, target, proxy, proxyCreds); - this.target = target; + super(HttpVersionPolicy.FORCE_HTTP_1, target, targetCreds, proxy, proxyCreds); } @Test void test_auth_success_no_keep_alive() throws Exception { - addCredentials( + setCredentials( new AuthScope(target), - new UsernamePasswordCredentials("testuser", "nopassword".toCharArray())); + targetCreds); final CloseableHttpAsyncClient client = client(); final HttpClientContext context = context(); final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get() .setHttpHost(target) - .setPath("/private/big-secret.txt") + .setPath(secretPath) .addHeader(HttpHeaders.CONNECTION, HeaderElements.CLOSE) .build(); final Future future = client.execute(httpGetSecret, context, null); diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/KeytabConfiguration.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/KeytabConfiguration.java new file mode 100644 index 0000000000..9206b8de57 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/KeytabConfiguration.java @@ -0,0 +1,73 @@ +/* + * ==================================================================== + * 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.testing.compatibility.spnego; + +import java.nio.file.Path; +import java.util.HashMap; + +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; + +public class KeytabConfiguration extends Configuration { + private static final String IBM_KRB5_LOGIN_MODULE = + "com.ibm.security.auth.module.Krb5LoginModule"; + private static final String SUN_KRB5_LOGIN_MODULE = + "com.sun.security.auth.module.Krb5LoginModule"; + + private static final String JAVA_VENDOR_NAME = System.getProperty("java.vendor"); + private static final boolean IS_IBM_JAVA = JAVA_VENDOR_NAME.contains("IBM"); + + private final String principal; + private final Path keytabFilePath; + + public KeytabConfiguration(final String principal, final Path keyTabFilePath) { + this.principal = principal; + this.keytabFilePath = keyTabFilePath; + } + + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(final String name) { + final HashMap options = new HashMap<>(); + + if (IS_IBM_JAVA) { + options.put("principal", principal); + options.put("useKeyTab", "true"); + options.put("useKeytab", "file://" + keytabFilePath.normalize().toString()); + return new AppConfigurationEntry[] { new AppConfigurationEntry( + IBM_KRB5_LOGIN_MODULE, AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, + options) }; + } else { + options.put("principal", principal); + options.put("doNotPrompt", "true"); + options.put("useKeyTab", "true"); + options.put("keyTab", keytabFilePath.normalize().toString()); + return new AppConfigurationEntry[] { new AppConfigurationEntry( + SUN_KRB5_LOGIN_MODULE, AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, + options) }; + } + } +} \ No newline at end of file diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoAuthenticationStrategy.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoAuthenticationStrategy.java new file mode 100644 index 0000000000..4c48f3ea9e --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoAuthenticationStrategy.java @@ -0,0 +1,50 @@ +/* + * ==================================================================== + * 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.testing.compatibility.spnego; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy; + +public class SpnegoAuthenticationStrategy extends DefaultAuthenticationStrategy { + + private static final List SPNEGO_SCHEME_PRIORITY = + Collections.unmodifiableList( + Arrays.asList( + StandardAuthScheme.BEARER, + StandardAuthScheme.DIGEST, + StandardAuthScheme.BASIC, + StandardAuthScheme.SPNEGO)); + + @Override + protected final List getSchemePriority() { + return SPNEGO_SCHEME_PRIORITY; + } +} \ No newline at end of file diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoTestUtil.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoTestUtil.java new file mode 100644 index 0000000000..492a9f10f0 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoTestUtil.java @@ -0,0 +1,156 @@ +/* + * ==================================================================== + * 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.testing.compatibility.spnego; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; + +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.auth.gss.GssCredentials; +import org.apache.hc.client5.http.impl.auth.BasicSchemeFactory; +import org.apache.hc.client5.http.impl.auth.BearerSchemeFactory; +import org.apache.hc.client5.http.impl.auth.DigestSchemeFactory; +import org.apache.hc.client5.http.impl.auth.gss.SpnegoSchemeFactory; +import org.apache.hc.client5.testing.compatibility.ContainerImages; +import org.apache.hc.client5.testing.util.SecurityUtils; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSManager; + + +public class SpnegoTestUtil { + + public static GssCredentials createCredentials(final Subject subject) { + return SecurityUtils.callAs(subject, new Callable() { + @Override + public GssCredentials call() throws Exception { + return new GssCredentials( + GSSManager.getInstance().createCredential(GSSCredential.INITIATE_ONLY)); + } + }); + } + + public static Path createKeytabDir() { + try { + return Files.createTempDirectory("keytabs", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("r-xr-xr-x"))); + } catch (final IOException e) { + return Paths.get("/tmp/keytabs"); + } + } + + public static Registry getDefaultSpnegoSchemeRegistry() { + return RegistryBuilder.create() + .register(StandardAuthScheme.BEARER, BearerSchemeFactory.INSTANCE) + .register(StandardAuthScheme.BASIC, BasicSchemeFactory.INSTANCE) + .register(StandardAuthScheme.DIGEST, DigestSchemeFactory.INSTANCE) + .register(StandardAuthScheme.SPNEGO, SpnegoSchemeFactory.DEFAULT) + // register other schemes as needed + .build(); + } + + //Squid does not support mutual auth + public static Registry getLegacySpnegoSchemeRegistry() { + return RegistryBuilder.create() + .register(StandardAuthScheme.BEARER, BearerSchemeFactory.INSTANCE) + .register(StandardAuthScheme.BASIC, BasicSchemeFactory.INSTANCE) + .register(StandardAuthScheme.DIGEST, DigestSchemeFactory.INSTANCE) + .register(StandardAuthScheme.SPNEGO, SpnegoSchemeFactory.LEGACY) + // register other schemes as needed + .build(); + } + + public static Subject loginFromKeytab(final String principal, final Path keytabFilePath) { + final Configuration kerberosConfig = new KeytabConfiguration(principal, keytabFilePath); + final Subject subject = new Subject(); + + final LoginContext lc; + try { + lc = new LoginContext("SPNEGOTest", subject, new CallbackHandler() { + @Override + public void handle(final Callback[] callbacks) + throws IOException, UnsupportedCallbackException { + throw new UnsupportedCallbackException(callbacks[0], + "Only keytab supported"); + } + }, kerberosConfig); + lc.login(); + return subject; + } catch (final LoginException e) { + throw new RuntimeException(e); + } + } + + /** + * Updates the krb5.conf file with the specified host and port, + * writes it to a tmp file, + * and sets the java.security.krb5.conf system property to point to it. + * + * @param KdcHostPort + * @return Path to the updated krb5.conf file + * @throws IOException + */ + public static Path prepareKrb5Conf(final String KdcHostPort) throws IOException { + // Copy krb5.conf to filesystem + final InputStream krb5 = SpnegoTestUtil.class.getResourceAsStream( + "/docker/kdc/krb5.conf"); + // replace KDC address + final String krb5In; + try (final BufferedReader reader = new BufferedReader( + new InputStreamReader(krb5, StandardCharsets.UTF_8))) { + krb5In = reader.lines() + .collect(Collectors.joining("\n")); + } + final String krb5Out = krb5In.replaceAll(ContainerImages.KDC_SERVER, KdcHostPort); + final Path tmpKrb5 = Files.createTempDirectory("test_krb_config_dir") + .resolve("krb5.conf"); + Files.write(tmpKrb5, krb5Out.getBytes(StandardCharsets.UTF_8)); + // Set the copied krb5.conf for java + System.setProperty("java.security.krb5.conf", tmpKrb5.toString()); + return tmpKrb5; + } + +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/UseJaasCredentials.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/UseJaasCredentials.java new file mode 100644 index 0000000000..272d370d38 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/UseJaasCredentials.java @@ -0,0 +1,45 @@ +/* + * ==================================================================== + * 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.testing.compatibility.spnego; + +import java.security.Principal; + +import org.apache.hc.client5.http.auth.Credentials; + +public class UseJaasCredentials implements Credentials { + + @Override + public char[] getPassword() { + return null; + } + + @Override + public Principal getUserPrincipal() { + return null; + } + +} \ No newline at end of file diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/sync/HttpClientCompatibilityTest.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/sync/HttpClientCompatibilityTest.java index 6db3469524..fca601db07 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/sync/HttpClientCompatibilityTest.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/sync/HttpClientCompatibilityTest.java @@ -26,17 +26,26 @@ */ package org.apache.hc.client5.testing.compatibility.sync; + +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import javax.security.auth.Subject; + import org.apache.hc.client5.http.ContextBuilder; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.Credentials; import org.apache.hc.client5.http.auth.CredentialsStore; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.auth.gss.GssCredentials; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpOptions; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.testing.compatibility.spnego.SpnegoAuthenticationStrategy; +import org.apache.hc.client5.testing.compatibility.spnego.SpnegoTestUtil; import org.apache.hc.client5.testing.extension.sync.HttpClientResource; +import org.apache.hc.client5.testing.util.SecurityUtils; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.HttpHeaders; @@ -54,14 +63,38 @@ public abstract class HttpClientCompatibilityTest { @RegisterExtension private final HttpClientResource clientResource; private final CredentialsStore credentialsProvider; + private final Credentials targetCreds; + private Subject callAs; + private String secretPath = "/private/big-secret.txt"; - public HttpClientCompatibilityTest(final HttpHost target, final HttpHost proxy, final Credentials proxyCreds) throws Exception { + public HttpClientCompatibilityTest(final HttpHost target, final Credentials targetCreds, final HttpHost proxy, final Credentials proxyCreds, final Subject callAs) throws Exception { this.target = target; - this.clientResource = new HttpClientResource(); - this.clientResource.configure(builder -> builder.setProxy(proxy)); + this.targetCreds = targetCreds; + this.callAs = callAs; this.credentialsProvider = new BasicCredentialsProvider(); - if (proxy != null && proxyCreds != null) { - this.addCredentials(new AuthScope(proxy), proxyCreds); + this.clientResource = new HttpClientResource(); + if (targetCreds != null || callAs != null) { + //this.setCredentials(new AuthScope(target), targetCreds); + if (targetCreds instanceof GssCredentials || callAs != null) { + secretPath = "/private_spnego/big-secret.txt"; + this.clientResource.configure(builder -> builder + .setTargetAuthenticationStrategy(new SpnegoAuthenticationStrategy()) + .setDefaultAuthSchemeRegistry(SpnegoTestUtil.getDefaultSpnegoSchemeRegistry())); + } + } + if (proxy != null) { + this.clientResource.configure(builder -> builder.setProxy(proxy)); + if (proxyCreds != null) { + this.setCredentials(new AuthScope(proxy), proxyCreds); + if (proxyCreds instanceof GssCredentials) { + // We disable Mutual Auth, because Squid does not support it. + // There is no way to set separate scheme registry for target/proxy, + // but that's not a problem as SPNEGO cannot be proxied anyway. + this.clientResource.configure(builder -> + builder.setProxyAuthenticationStrategy(new SpnegoAuthenticationStrategy()) + .setDefaultAuthSchemeRegistry(SpnegoTestUtil.getLegacySpnegoSchemeRegistry())); + } + } } } @@ -75,7 +108,7 @@ HttpClientContext context() { .build(); } - void addCredentials(final AuthScope authScope, final Credentials credentials) { + void setCredentials(final AuthScope authScope, final Credentials credentials) { credentialsProvider.setCredentials(authScope, credentials); } @@ -123,14 +156,14 @@ void test_get_connection_close() throws Exception { @Test void test_wrong_target_auth_scope() throws Exception { - addCredentials( + setCredentials( new AuthScope("http", "otherhost", -1, "Restricted Files", null), new UsernamePasswordCredentials("testuser", "nopassword".toCharArray())); final CloseableHttpClient client = client(); final HttpClientContext context = context(); - final ClassicHttpRequest request = new HttpGet("/private/big-secret.txt"); + final ClassicHttpRequest request = new HttpGet(secretPath); try (ClassicHttpResponse response = client.executeOpen(target, request, context)) { Assertions.assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getCode()); EntityUtils.consume(response.getEntity()); @@ -139,14 +172,14 @@ void test_wrong_target_auth_scope() throws Exception { @Test void test_wrong_target_credentials() throws Exception { - addCredentials( + setCredentials( new AuthScope(target), new UsernamePasswordCredentials("testuser", "wrong password".toCharArray())); final CloseableHttpClient client = client(); final HttpClientContext context = context(); - final ClassicHttpRequest request = new HttpGet("/private/big-secret.txt"); + final ClassicHttpRequest request = new HttpGet(secretPath); try (ClassicHttpResponse response = client.executeOpen(target, request, context)) { Assertions.assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getCode()); EntityUtils.consume(response.getEntity()); @@ -155,13 +188,25 @@ void test_wrong_target_credentials() throws Exception { @Test void test_correct_target_credentials() throws Exception { - addCredentials( - new AuthScope(target), - new UsernamePasswordCredentials("testuser", "nopassword".toCharArray())); + setCredentials( + new AuthScope(target), targetCreds); + test_correct_target_credentials_int(); + } + + @Test + void test_correct_target_credentials_call_as() throws Exception { + assumeFalse(callAs == null); + SecurityUtils.callAs(callAs, () -> { + test_correct_target_credentials_int(); + return 0; + }); + } + + private void test_correct_target_credentials_int() throws Exception { final CloseableHttpClient client = client(); final HttpClientContext context = context(); - final ClassicHttpRequest request = new HttpGet("/private/big-secret.txt"); + final ClassicHttpRequest request = new HttpGet(secretPath); try (ClassicHttpResponse response = client.executeOpen(target, request, context)) { Assertions.assertEquals(HttpStatus.SC_OK, response.getCode()); EntityUtils.consume(response.getEntity()); @@ -170,13 +215,25 @@ void test_correct_target_credentials() throws Exception { @Test void test_correct_target_credentials_no_keep_alive() throws Exception { - addCredentials( - new AuthScope(target), - new UsernamePasswordCredentials("testuser", "nopassword".toCharArray())); + setCredentials( + new AuthScope(target), targetCreds); + test_correct_target_credentials_no_keep_alive_int(); + } + + @Test + void test_correct_target_credentials_no_keep_alive_call_as() throws Exception { + assumeFalse(callAs == null); + SecurityUtils.callAs(callAs, () -> { + test_correct_target_credentials_int(); + return 0; + }); + } + + private void test_correct_target_credentials_no_keep_alive_int() throws Exception { final CloseableHttpClient client = client(); final HttpClientContext context = context(); - final ClassicHttpRequest request = ClassicRequestBuilder.get("/private/big-secret.txt") + final ClassicHttpRequest request = ClassicRequestBuilder.get(secretPath) .addHeader(HttpHeaders.CONNECTION, "close") .build(); try (ClassicHttpResponse response = client.executeOpen(target, request, context)) { @@ -184,5 +241,4 @@ void test_correct_target_credentials_no_keep_alive() throws Exception { EntityUtils.consume(response.getEntity()); } } - } diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestSpnegoScheme.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestSpnegoScheme.java new file mode 100644 index 0000000000..70325b5bfb --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestSpnegoScheme.java @@ -0,0 +1,525 @@ +/* + * ==================================================================== + * 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.testing.sync; + + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.client5.http.AuthenticationStrategy; +import org.apache.hc.client5.http.ClientProtocolException; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.AuthenticationException; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.auth.gss.GssConfig; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy; +import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder; +import org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.utils.Base64; +import org.apache.hc.client5.testing.extension.sync.ClientProtocolLevel; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.io.HttpRequestHandler; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Timeout; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.AdditionalMatchers; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +/** + * Tests for {@link org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme}. + */ +public class TestSpnegoScheme extends AbstractIntegrationTestBase { + + protected TestSpnegoScheme() { + super(URIScheme.HTTP, ClientProtocolLevel.STANDARD); + } + + public static final Timeout TIMEOUT = Timeout.ofMinutes(1); + + private static final String GOOD_TOKEN = "GOOD_TOKEN"; + private static final byte[] GOOD_TOKEN_BYTES = GOOD_TOKEN.getBytes(StandardCharsets.UTF_8); + private static final byte[] GOOD_TOKEN_B64_BYTES = Base64.encodeBase64(GOOD_TOKEN_BYTES); + private static final String GOOD_TOKEN_B64 = new String(GOOD_TOKEN_B64_BYTES); + + private static final String NO_TOKEN = ""; + private static final byte[] NO_TOKEN_BYTES = NO_TOKEN.getBytes(StandardCharsets.UTF_8); + + private static final String GOOD_MUTUAL_AUTH_TOKEN = "GOOD_MUTUAL_AUTH_TOKEN"; + private static final byte[] GOOD_MUTUAL_AUTH_TOKEN_BYTES = GOOD_MUTUAL_AUTH_TOKEN.getBytes(StandardCharsets.UTF_8); + private static final byte[] GOOD_MUTUAL_AUTH_TOKEN_B64_BYTES = Base64.encodeBase64(GOOD_MUTUAL_AUTH_TOKEN_BYTES); + + private static final String BAD_MUTUAL_AUTH_TOKEN = "BAD_MUTUAL_AUTH_TOKEN"; + private static final byte[] BAD_MUTUAL_AUTH_TOKEN_BYTES = BAD_MUTUAL_AUTH_TOKEN.getBytes(StandardCharsets.UTF_8); + private static final byte[] BAD_MUTUAL_AUTH_TOKEN_B64_BYTES = Base64.encodeBase64(BAD_MUTUAL_AUTH_TOKEN_BYTES); + + static GssConfig MUTUAL_KERBEROS_CONFIG = GssConfig.DEFAULT; + + private static class SpnegoAuthenticationStrategy extends DefaultAuthenticationStrategy { + + private static final List SPNEGO_SCHEME_PRIORITY = + Collections.unmodifiableList( + Arrays.asList(StandardAuthScheme.SPNEGO, + StandardAuthScheme.BEARER, + StandardAuthScheme.DIGEST, + StandardAuthScheme.BASIC)); + + @Override + protected final List getSchemePriority() { + return SPNEGO_SCHEME_PRIORITY; + } + } + + final AuthenticationStrategy spnegoAuthenticationStrategy = new SpnegoAuthenticationStrategy(); + + final CredentialsProvider jaasCredentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(null, null, -1, null, null), new UseJaasCredentials()) + .build(); + + /** + * This service will continue to ask for authentication. + */ + private static class PleaseNegotiateService implements HttpRequestHandler { + + @Override + public void handle( + final ClassicHttpRequest request, + final ClassicHttpResponse response, + final HttpContext context) throws HttpException, IOException { + response.setCode(HttpStatus.SC_UNAUTHORIZED); + response.addHeader(new BasicHeader("WWW-Authenticate", StandardAuthScheme.SPNEGO + " blablabla")); + response.addHeader(new BasicHeader("Connection", "Keep-Alive")); + response.setEntity(new StringEntity("auth required ")); + } + } + + /** + * This service implements a normal mutualAuth flow + */ + private static class SpnegoService implements HttpRequestHandler { + + int callCount = 1; + final boolean sendMutualToken; + final byte[] encodedMutualAuthToken; + + SpnegoService (final boolean sendMutualToken, final byte[] encodedMutualAuthToken) { + this.sendMutualToken = sendMutualToken; + this.encodedMutualAuthToken = encodedMutualAuthToken; + } + + @Override + public void handle( + final ClassicHttpRequest request, + final ClassicHttpResponse response, + final HttpContext context) throws HttpException, IOException { + if (callCount == 1) { + callCount++; + // Send the empty challenge + response.setCode(HttpStatus.SC_UNAUTHORIZED); + response.addHeader(new BasicHeader("WWW-Authenticate", StandardAuthScheme.SPNEGO)); + response.addHeader(new BasicHeader("Connection", "Keep-Alive")); + response.setEntity(new StringEntity("auth required ")); + } else if (callCount == 2) { + callCount++; + if (request.getHeader("Authorization").getValue().contains(GOOD_TOKEN_B64)) { + response.setCode(HttpStatus.SC_OK); + if (sendMutualToken) { + response.addHeader(new BasicHeader("WWW-Authenticate", StandardAuthScheme.SPNEGO + " " + new String(encodedMutualAuthToken))); + } + response.addHeader(new BasicHeader("Connection", "Keep-Alive")); + response.setEntity(new StringEntity("auth successful ")); + } else { + response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } + } + } + } + + /** + * NegotatieScheme with a custom GSSManager that does not require any Jaas or + * Kerberos configuration. + * + */ + private static class NegotiateSchemeWithMockGssManager extends SpnegoScheme { + + final GSSManager manager = Mockito.mock(GSSManager.class); + final GSSName name = Mockito.mock(GSSName.class); + final GSSContext context = Mockito.mock(GSSContext.class); + + NegotiateSchemeWithMockGssManager() throws Exception { + super(GssConfig.DEFAULT, SystemDefaultDnsResolver.INSTANCE); + Mockito.when(context.initSecContext( + ArgumentMatchers.any(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenReturn("12345678".getBytes()); + Mockito.when(manager.createName( + ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(name); + Mockito.when(manager.createContext( + ArgumentMatchers.any(), ArgumentMatchers.any(), + ArgumentMatchers.any(), ArgumentMatchers.anyInt())) + .thenReturn(context); + } + + @Override + protected GSSManager getManager() { + return manager; + } + + } + + private static class MutualNegotiateSchemeWithMockGssManager extends SpnegoScheme { + + final GSSManager manager = Mockito.mock(GSSManager.class); + final GSSName name = Mockito.mock(GSSName.class); + final GSSContext context = Mockito.mock(GSSContext.class); + + MutualNegotiateSchemeWithMockGssManager(final boolean established, final boolean mutual) throws Exception { + super(MUTUAL_KERBEROS_CONFIG, SystemDefaultDnsResolver.INSTANCE); + // Initial empty WWW-Authenticate response header + Mockito.when(context.initSecContext( + AdditionalMatchers.aryEq(NO_TOKEN_BYTES), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenReturn(GOOD_TOKEN_BYTES); + // Valid mutual token + Mockito.when(context.initSecContext( + AdditionalMatchers.aryEq(GOOD_MUTUAL_AUTH_TOKEN_BYTES), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenReturn(NO_TOKEN_BYTES); + // Invalid mutual token + Mockito.when(context.initSecContext( + AdditionalMatchers.aryEq(BAD_MUTUAL_AUTH_TOKEN_BYTES), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenThrow(new GSSException(GSSException.DEFECTIVE_CREDENTIAL)); + // It's hard to mock state, so instead we specify the complete and mutualAuth states + // in the constructor + Mockito.when(context.isEstablished()).thenReturn(established); + Mockito.when(context.getMutualAuthState()).thenReturn(mutual); + Mockito.when(manager.createName( + ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(name); + Mockito.when(manager.createContext( + ArgumentMatchers.any(), ArgumentMatchers.any(), + ArgumentMatchers.any(), ArgumentMatchers.anyInt())) + .thenReturn(context); + } + + @Override + protected GSSManager getManager() { + return manager; + } + + } + + private static class UseJaasCredentials implements Credentials { + + @Override + public char[] getPassword() { + return null; + } + + @Override + public Principal getUserPrincipal() { + return null; + } + + } + + private static class TestAuthSchemeFactory implements AuthSchemeFactory { + + AuthScheme scheme; + + TestAuthSchemeFactory(final AuthScheme scheme) throws Exception { + this.scheme = scheme; + } + + @Override + public AuthScheme create(final HttpContext context) { + return scheme; + } + + } + + + /** + * Tests that the client will stop connecting to the server if + * the server still keep asking for a valid ticket. + */ + @Test + void testDontTryToAuthenticateEndlessly() throws Exception { + configureServer(t -> { + t.register("*", new PleaseNegotiateService()); + }); + + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(new NegotiateSchemeWithMockGssManager()); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + t.setDefaultCredentialsProvider(jaasCredentialsProvider); + }); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getCode()); + return null; + }); + Assertions.fail(); + } catch (final IllegalStateException e) { + // Expected + } + } + + /** + * Test the success case for mutual auth + */ + @Test + void testMutualSuccess() throws Exception { + configureServer(t -> { + t.register("*", new SpnegoService(true, GOOD_MUTUAL_AUTH_TOKEN_B64_BYTES)); + }); + final HttpHost target = startServer(); + + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(true, true); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + t.setDefaultCredentialsProvider(jaasCredentialsProvider); + }); + + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + client().execute(target, httpget, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.assertEquals(HttpStatus.SC_OK, response.getCode()); + return null; + }); + + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).getMutualAuthState(); + } + + /** + * No mutual auth response token sent by server. + */ + @Test + void testMutualFailureNoToken() throws Exception { + configureServer(t -> { + t.register("*", new SpnegoService(false, null)); + }); + + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(false, false); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + }); + + final HttpClientContext context = new HttpClientContext(); + context.setCredentialsProvider(jaasCredentialsProvider); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, context, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.fail(); + return null; + }); + Assertions.fail(); + } catch (final Exception e) { + Assertions.assertTrue(e instanceof ClientProtocolException); + Assertions.assertTrue(e.getCause() instanceof AuthenticationException); + } + + Mockito.verify(mockAuthScheme.context, Mockito.never()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.never()).getMutualAuthState(); + } + + /** + * Server sends a "valid" token, but we mock the established status to false + */ + @Test + void testMutualFailureEstablishedStatusFalse() throws Exception { + configureServer(t -> { + t.register("*", new SpnegoService(true, GOOD_MUTUAL_AUTH_TOKEN_B64_BYTES)); + }); + + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(false, false); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + }); + + final HttpClientContext context = new HttpClientContext(); + context.setCredentialsProvider(jaasCredentialsProvider); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, context, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.fail(); + return null; + }); + Assertions.fail(); + } catch (final Exception e) { + Assertions.assertTrue(e instanceof ClientProtocolException); + Assertions.assertTrue(e.getCause() instanceof AuthenticationException); + } + + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.never()).getMutualAuthState(); + } + + /** + * Server sends a "valid" token, but we mock the mutual auth status to false + */ + @Test + void testMutualFailureMutualStatusFalse() throws Exception { + configureServer(t -> { + t.register("*", new SpnegoService(true, GOOD_MUTUAL_AUTH_TOKEN_B64_BYTES)); + }); + + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(true, false); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + }); + + final HttpClientContext context = new HttpClientContext(); + context.setCredentialsProvider(jaasCredentialsProvider); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, context, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.fail(); + return null; + }); + Assertions.fail(); + } catch (final Exception e) { + Assertions.assertTrue(e instanceof ClientProtocolException); + Assertions.assertTrue(e.getCause() instanceof AuthenticationException); + } + + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).getMutualAuthState(); + } + + /** + * Server sends a "bad" token, and GSS throws an exception. + */ + @Test + void testMutualFailureBadToken() throws Exception { + configureServer(t -> { + t.register("*", new SpnegoService(true, BAD_MUTUAL_AUTH_TOKEN_B64_BYTES)); + }); + + // We except that the initSecContent throws an exception, so the status is irrelevant + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(true, true); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + }); + + final HttpClientContext context = new HttpClientContext(); + context.setCredentialsProvider(jaasCredentialsProvider); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, context, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.fail(); + return null; + }); + Assertions.fail(); + } catch (final Exception e) { + Assertions.assertTrue(e instanceof ClientProtocolException); + Assertions.assertTrue(e.getCause() instanceof AuthenticationException); + } + + Mockito.verify(mockAuthScheme.context, Mockito.never()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.never()).getMutualAuthState(); + } +} diff --git a/httpclient5-testing/src/test/resources/docker/httpd/httpd.conf b/httpclient5-testing/src/test/resources/docker/httpd/httpd.conf index f9931ea71b..cbb11f761a 100644 --- a/httpclient5-testing/src/test/resources/docker/httpd/httpd.conf +++ b/httpclient5-testing/src/test/resources/docker/httpd/httpd.conf @@ -77,6 +77,7 @@ Listen 8080 # Example: # LoadModule foo_module modules/mod_foo.so # +LoadModule auth_gssapi_module modules/mod_auth_gssapi.so LoadModule mpm_event_module modules/mod_mpm_event.so #LoadModule mpm_prefork_module modules/mod_mpm_prefork.so #LoadModule mpm_worker_module modules/mod_mpm_worker.so @@ -340,7 +341,7 @@ DocumentRoot "/var/httpd/www" # logged here. If you *do* define an error logfile for a # container, that host's errors will be logged there and not here. # -ErrorLog /proc/self/fd/2 +ErrorLog /proc/1/fd/2 # # LogLevel: Control the number of messages logged to the error_log. @@ -369,7 +370,7 @@ LogLevel warn # define per- access logfiles, transactions will be # logged therein and *not* in this file. # - CustomLog /proc/self/fd/1 common + CustomLog /proc/1/fd/1 common # # If you prefer a logfile with access, agent, and referer information @@ -595,3 +596,15 @@ SSLRandomSeed connect builtin Require valid-user + + + + + AuthType GSSAPI + AuthName "GSSAPI Single Sign On Login" + GssapiCredStore keytab:/keytabs/HTTP.keytab + GssapiAcceptorName HTTP + Require valid-user + + + diff --git a/httpclient5-testing/src/test/resources/docker/kdc/krb5.conf b/httpclient5-testing/src/test/resources/docker/kdc/krb5.conf new file mode 100644 index 0000000000..a9ce6acc50 --- /dev/null +++ b/httpclient5-testing/src/test/resources/docker/kdc/krb5.conf @@ -0,0 +1,35 @@ +# 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. +# ========================================================================== +# +[libdefaults] + default_realm = EXAMPLE.ORG + forwardable = true + udp_preference_limit = 1 + + +[realms] + EXAMPLE.ORG = { + kdc = test-kdc + } + +[domain_realm] + .example.org = EXAMPLE.ORG + example.org = EXAMPLE.ORG + +[logging] + kdc = FILE:/var/log/kerberos/krb5kdc.log + admin_server = FILE:/var/log/kerberos/kadmin.log + default = FILE:/var/log/kerberos/krb5lib.log diff --git a/httpclient5-testing/src/test/resources/docker/kdc/start.sh b/httpclient5-testing/src/test/resources/docker/kdc/start.sh new file mode 100644 index 0000000000..3156d67ff8 --- /dev/null +++ b/httpclient5-testing/src/test/resources/docker/kdc/start.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# 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. +# ========================================================================== +# +# The default image has no init +kdb5_util -P unsafe create -s +echo Kerberos DB created +krb5kdc +echo KDC started +useradd testclient +echo testclient:testclient | chpasswd +kadmin.local addprinc -pw HTTP HTTP/localhost@EXAMPLE.ORG +kadmin.local addprinc -pw testclient testclient@EXAMPLE.ORG +kadmin.local addprinc -pw testpwclient testpwclient@EXAMPLE.ORG +rm -f /keytabs/testclient.keytab +rm -f /keytabs/HTTP.keytab +kadmin.local ktadd -k /keytabs/testclient.keytab testclient@EXAMPLE.ORG +kadmin.local ktadd -k /keytabs/HTTP.keytab HTTP/localhost@EXAMPLE.ORG +chmod 666 /keytabs/testclient.keytab +chmod 666 /keytabs/HTTP.keytab +echo keytabs written +sleep 3600 + diff --git a/httpclient5-testing/src/test/resources/docker/squid/squid.conf b/httpclient5-testing/src/test/resources/docker/squid/squid.conf index 0f476df6c3..f1fdb56b67 100644 --- a/httpclient5-testing/src/test/resources/docker/squid/squid.conf +++ b/httpclient5-testing/src/test/resources/docker/squid/squid.conf @@ -17,12 +17,18 @@ http_port 8888 http_port 8889 +debug_options ALL,1 + coredump_dir /var/spool/squid auth_param basic program /usr/lib/squid/basic_ncsa_auth /etc/squid/htpasswd auth_param basic children 5 auth_param basic realm test-proxy +auth_param negotiate program /usr/lib/squid/negotiate_kerberos_auth -k /keytabs/HTTP.keytab -s HTTP/localhost -d -i +auth_param negotiate children 5 +auth_param negotiate keep_alive on + acl localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN) acl localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN) acl localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN) @@ -71,4 +77,4 @@ refresh_pattern \/(Packages|Sources)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims refresh_pattern \/Release(|\.gpg)$ 0 0% 0 refresh-ims refresh_pattern \/InRelease$ 0 0% 0 refresh-ims refresh_pattern \/(Translation-.*)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims -refresh_pattern . 0 20% 4320 \ No newline at end of file +refresh_pattern . 0 20% 4320 diff --git a/httpclient5-testing/src/test/resources/log4j2-debug.xml.template b/httpclient5-testing/src/test/resources/log4j2-debug.xml.template index 3386294619..8d540913a7 100644 --- a/httpclient5-testing/src/test/resources/log4j2-debug.xml.template +++ b/httpclient5-testing/src/test/resources/log4j2-debug.xml.template @@ -25,6 +25,8 @@ + + diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosConfig.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosConfig.java index 508eeb9b0e..6b405bbe5d 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosConfig.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosConfig.java @@ -35,9 +35,10 @@ * * @since 4.6 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. - * + * @deprecated Do not use. The old GGS based experimental authentication schemes are no longer + * supported. + * @see org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme + * @see org.apache.hc.client5.http.auth.gss.GssConfig */ @Deprecated @Contract(threading = ThreadingBehavior.IMMUTABLE) diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosCredentials.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosCredentials.java index 92bab8d4f3..beb5fe7ed1 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosCredentials.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosCredentials.java @@ -26,9 +26,7 @@ */ package org.apache.hc.client5.http.auth; -import java.io.Serializable; -import java.security.Principal; - +import org.apache.hc.client5.http.auth.gss.GssCredentials; import org.apache.hc.core5.annotation.Contract; import org.apache.hc.core5.annotation.ThreadingBehavior; import org.ietf.jgss.GSSCredential; @@ -38,42 +36,23 @@ * * @since 4.4 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * The original KerberosCredentials class has been renamed to + * org.apache.hc.client5.http.auth.gss.GssCredentials. * - * @see UsernamePasswordCredentials - * @see BearerToken + * @deprecated Do not use. The old GGS based experimental authentication schemes are no longer + * supported. + * Use org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme, or consider using Basic or Bearer + * authentication with TLS instead. + * @see org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme + * @see org.apache.hc.client5.http.auth.gss.GssConfig + * @see org.apache.hc.client5.http.auth.gss.GssCredentials */ @Deprecated @Contract(threading = ThreadingBehavior.IMMUTABLE) -public class KerberosCredentials implements Credentials, Serializable { - - private static final long serialVersionUID = 487421613855550713L; - - /** GSSCredential */ - private final GSSCredential gssCredential; +public class KerberosCredentials extends GssCredentials { - /** - * Constructor with GSSCredential argument - * - * @param gssCredential - */ public KerberosCredentials(final GSSCredential gssCredential) { - this.gssCredential = gssCredential; - } - - public GSSCredential getGSSCredential() { - return gssCredential; - } - - @Override - public Principal getUserPrincipal() { - return null; - } - - @Override - public char[] getPassword() { - return null; + super(gssCredential); } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/StandardAuthScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/StandardAuthScheme.java index 4d7d34b7ea..7ccb6e9c88 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/StandardAuthScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/StandardAuthScheme.java @@ -67,16 +67,15 @@ private StandardAuthScheme() { /** * SPNEGO authentication scheme as defined in RFC 4559 and RFC 4178. * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * Use {@link org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme} instead of the old + * deprecated {@link org.apache.hc.client5.http.impl.auth.SPNegoScheme} */ - @Deprecated public static final String SPNEGO = "Negotiate"; /** * Kerberos authentication scheme as defined in RFC 4120. * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer + * @deprecated Do not use. The old GGS based experimental authentication schemes are no longer * supported. Consider using Basic or Bearer authentication with TLS instead. */ @Deprecated diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/gss/GssConfig.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/gss/GssConfig.java new file mode 100644 index 0000000000..614589de59 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/gss/GssConfig.java @@ -0,0 +1,204 @@ +/* + * ==================================================================== + * 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.auth.gss; + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; + +/** + * Immutable class encapsulating GSS configuration options for the new mutual auth capable + * {@link SpnegoScheme}. + * + * Unlike the deprecated {@link KerberosConfig}, this class uses explicit defaults, and + * primitive booleans. + * + * Compared to {@link KerberosConfig} stripPort has been changed to addPort, and the default is now + * false (same effect). The default for useCanonicalHostname has been changed to false from true. + * + * @since 5.6 + * + */ +@Contract(threading = ThreadingBehavior.IMMUTABLE) +public class GssConfig implements Cloneable { + + + public static final GssConfig DEFAULT = new Builder().build(); + public static final GssConfig LEGACY = + new Builder().setIgnoreIncompleteSecurityContext(true).setRequireMutualAuth(false).build(); + + private final boolean addPort; + private final boolean useCanonicalHostname; + private final boolean requestMutualAuth; + private final boolean requireMutualAuth; + private final boolean requestDelegCreds; + private final boolean ignoreIncompleteSecurityContext; + + /** + * Intended for CDI compatibility + */ + protected GssConfig() { + this(false, false, true, true, false, false); + } + + GssConfig( + final boolean addPort, + final boolean useCanonicalHostname, + final boolean requestMutualAuth, + final boolean requireMutualAuth, + final boolean requestDelegCreds, + final boolean ignoreIncompleteSecurityContext) { + super(); + this.addPort = addPort; + this.useCanonicalHostname = useCanonicalHostname; + this.requestMutualAuth = requestMutualAuth; + this.requireMutualAuth = requireMutualAuth; + this.requestDelegCreds = requestDelegCreds; + this.ignoreIncompleteSecurityContext = ignoreIncompleteSecurityContext; + } + + public boolean isAddPort() { + return addPort; + } + + public boolean isUseCanonicalHostname() { + return useCanonicalHostname; + } + + public boolean isRequestDelegCreds() { + return requestDelegCreds; + } + + public boolean isRequestMutualAuth() { + return requestMutualAuth; + } + + public boolean isRequireMutualAuth() { + return requireMutualAuth; + } + + public boolean isIgnoreIncompleteSecurityContext() { + return ignoreIncompleteSecurityContext; + } + + @Override + protected GssConfig clone() throws CloneNotSupportedException { + return (GssConfig) super.clone(); + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(); + builder.append("["); + builder.append("addPort=").append(addPort); + builder.append(", useCanonicalHostname=").append(useCanonicalHostname); + builder.append(", requestDelegCreds=").append(requestDelegCreds); + builder.append(", requestMutualAuth=").append(requestMutualAuth); + builder.append(", requireMutualAuth=").append(requireMutualAuth); + builder.append(", ignoreIncompleteSecurityContext=").append(ignoreIncompleteSecurityContext); + builder.append("]"); + return builder.toString(); + } + + public static GssConfig.Builder custom() { + return new Builder(); + } + + public static GssConfig.Builder copy(final GssConfig config) { + return new Builder() + .setAddPort(config.isAddPort()) + .setUseCanonicalHostname(config.isUseCanonicalHostname()) + .setRequestDelegCreds(config.isRequestDelegCreds()) + .setRequireMutualAuth(config.isRequireMutualAuth()) + .setRequestMutualAuth(config.isRequestMutualAuth()) + .setIgnoreIncompleteSecurityContext(config.isIgnoreIncompleteSecurityContext()); + } + + public static class Builder { + + private boolean addPort = false; + private boolean useCanonicalHostname = false; + private boolean requestMutualAuth = true; + private boolean requireMutualAuth = true; + private boolean requestDelegCreds = false; + private boolean ignoreIncompleteSecurityContext = false; + + + Builder() { + super(); + } + + public Builder setAddPort(final boolean addPort) { + this.addPort = addPort; + return this; + } + + public Builder setUseCanonicalHostname(final boolean useCanonicalHostname) { + this.useCanonicalHostname = useCanonicalHostname; + return this; + } + + public Builder setRequestMutualAuth(final boolean requestMutualAuth) { + this.requestMutualAuth = requestMutualAuth; + return this; + } + + public Builder setRequireMutualAuth(final boolean requireMutualAuth) { + this.requireMutualAuth = requireMutualAuth; + return this; + } + + public Builder setRequestDelegCreds(final boolean requuestDelegCreds) { + this.requestDelegCreds = requuestDelegCreds; + return this; + } + + public Builder setIgnoreIncompleteSecurityContext(final boolean ignoreIncompleteSecurityContext) { + this.ignoreIncompleteSecurityContext = ignoreIncompleteSecurityContext; + return this; + } + + public GssConfig build() { + if (requireMutualAuth && ignoreIncompleteSecurityContext) { + throw new IllegalArgumentException("If requireMutualAuth is set then ignoreIncompleteSecurityContext must not be set"); + } + if (requireMutualAuth && !requestMutualAuth) { + throw new IllegalArgumentException("If requireMutualAuth is set then requestMutualAuth must also be set"); + } + return new GssConfig( + addPort, + useCanonicalHostname, + requestMutualAuth, + requireMutualAuth, + requestDelegCreds, + ignoreIncompleteSecurityContext + ); + } + + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/gss/GssCredentials.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/gss/GssCredentials.java new file mode 100644 index 0000000000..90b5752a43 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/gss/GssCredentials.java @@ -0,0 +1,75 @@ +/* + * ==================================================================== + * 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.auth.gss; + +import java.io.Serializable; +import java.security.Principal; + +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.ietf.jgss.GSSCredential; + +/** + * Kerberos specific {@link Credentials} representation based on {@link GSSCredential}. + * + * @since 5.6 + * + */ +@Contract(threading = ThreadingBehavior.IMMUTABLE) +public class GssCredentials implements Credentials, Serializable { + + private static final long serialVersionUID = 487421613855550713L; + + /** GSSCredential */ + private final GSSCredential gssCredential; + + /** + * Constructor with GSSCredential argument + * + * @param gssCredential + */ + public GssCredentials(final GSSCredential gssCredential) { + this.gssCredential = gssCredential; + } + + public GSSCredential getGSSCredential() { + return gssCredential; + } + + @Override + public Principal getUserPrincipal() { + // TODO obtain from gssCredential + return null; + } + + @Override + public char[] getPassword() { + return null; + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/GGSSchemeBase.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/GGSSchemeBase.java index 773746b612..700a961ffa 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/GGSSchemeBase.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/GGSSchemeBase.java @@ -61,7 +61,11 @@ * @since 4.2 * * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * supported. Use org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme, or consider using Basic or + * Bearer authentication with TLS instead. + * @see org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme + * @see BasicScheme + * @see BearerScheme */ @Deprecated public abstract class GGSSchemeBase implements AuthScheme { @@ -182,8 +186,8 @@ public boolean isResponseReady( final Credentials credentials = credentialsProvider.getCredentials( new AuthScope(host, null, getName()), context); - if (credentials instanceof org.apache.hc.client5.http.auth.KerberosCredentials) { - this.gssCredential = ((org.apache.hc.client5.http.auth.KerberosCredentials) credentials).getGSSCredential(); + if (credentials instanceof org.apache.hc.client5.http.auth.gss.GssCredentials) { + this.gssCredential = ((org.apache.hc.client5.http.auth.gss.GssCredentials) credentials).getGSSCredential(); } else { this.gssCredential = null; } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosScheme.java index 656f29633a..deb8316179 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosScheme.java @@ -41,9 +41,11 @@ * * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. - * + * @deprecated Do not use. The old GGS based experimental authentication schemes are no longer + * supported. Use org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme, or consider using Basic or + * Bearer authentication with TLS + * instead. + * @see org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme * @see BasicScheme * @see BearerScheme */ diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosSchemeFactory.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosSchemeFactory.java index 25930f0997..161f12629c 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosSchemeFactory.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosSchemeFactory.java @@ -45,9 +45,10 @@ * * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. - * + * @deprecated Do not use. The old GGS based experimental authentication schemes are no longer + * supported. Use org.apache.hc.client5.http.impl.auth.gss.SpnegoSchemeFactory, or consider using + * Basic or Bearer authentication with TLS instead. + * @see org.apache.hc.client5.http.impl.auth.gss.SpnegoSchemeFactory * @see BasicSchemeFactory * @see BearerSchemeFactory */ diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoScheme.java index 7971ff935d..2516588dc3 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoScheme.java @@ -42,9 +42,10 @@ * * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. - * + * @deprecated Do not use. The old GGS based experimental authentication schemes are no longer + * supported. Use org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme, or consider using Basic or + * Bearer authentication with TLS instead. + * @see org.apache.hc.client5.http.impl.auth.gss.SpnegoScheme * @see BasicScheme * @see BearerScheme */ diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoSchemeFactory.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoSchemeFactory.java index 14d8528c5e..d45d9dfe1a 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoSchemeFactory.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoSchemeFactory.java @@ -45,9 +45,10 @@ * * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. - * + * @deprecated Do not use. The old GGS based experimental authentication schemes are no longer + * supported. Use org.apache.hc.client5.http.impl.auth.gss.SpnegoSchemeFactory, or consider using + * Basic or Bearer authentication with TLS instead. + * @see org.apache.hc.client5.http.impl.auth.gss.SpnegoSchemeFactory * @see BasicSchemeFactory * @see BearerSchemeFactory */ diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/GssSchemeBase.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/GssSchemeBase.java new file mode 100644 index 0000000000..91fdfa091e --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/GssSchemeBase.java @@ -0,0 +1,414 @@ +/* + * ==================================================================== + * 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.impl.auth.gss; + +import java.net.UnknownHostException; +import java.security.Principal; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.auth.AuthChallenge; +import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.AuthenticationException; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.InvalidCredentialsException; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.auth.gss.GssConfig; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.utils.Base64; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Args; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Common behaviour for the new mutual authentication capable {@code GSS} based authentication + * schemes. + * + * This class is derived from the old {@link org.apache.hc.client5.http.impl.auth.GGSSchemeBase} + * class, which was deprecated in 5.3. + * + * @since 5.6 + * + * @see GGSSchemeBase + */ +public abstract class GssSchemeBase implements AuthScheme { + + enum State { + UNINITIATED, + TOKEN_READY, + TOKEN_SENT, + SUCCEEDED, + FAILED, + } + + private static final Logger LOG = LoggerFactory.getLogger(GssSchemeBase.class); + private static final String PEER_SERVICE_NAME = "HTTP"; + + // The GSS spec does not specify how long the conversation can be. This should be plenty. + // Realistically, we get one initial token, then one maybe one more for mutual authentication. + // TODO In the future this might need to be configurable with the upcoming IAKerb support + private static final int MAX_GSS_CHALLENGES = 3; + private final GssConfig config; + private final DnsResolver dnsResolver; + private final boolean requireMutualAuth; + private final boolean ignoreIncompleteSecurityContext; + private int challengesLeft = MAX_GSS_CHALLENGES; + + /** Authentication process state */ + private State state; + private GSSCredential gssCredential; + private GSSContext gssContext; + private String challenge; + private byte[] queuedToken = new byte[0]; + + GssSchemeBase(final GssConfig config, final DnsResolver dnsResolver) { + super(); + this.config = config != null ? config : GssConfig.DEFAULT; + this.dnsResolver = dnsResolver != null ? dnsResolver : SystemDefaultDnsResolver.INSTANCE; + this.requireMutualAuth = config.isRequireMutualAuth(); + this.ignoreIncompleteSecurityContext = config.isIgnoreIncompleteSecurityContext(); + this.state = State.UNINITIATED; + } + + private void dispose() { + // remove sensitive information from memory + // cleaning up the credential is the caller's job + try { + if (gssContext != null) { + gssContext.dispose(); + } + } catch (final Exception e) { + if (LOG.isWarnEnabled()) { + LOG.warn("Exception caught while calling gssContext.dispose()", e); + } + } + } + + GssSchemeBase(final GssConfig config) { + this(config, SystemDefaultDnsResolver.INSTANCE); + } + + GssSchemeBase() { + this(GssConfig.DEFAULT, SystemDefaultDnsResolver.INSTANCE); + } + + @Override + public String getRealm() { + return null; + } + + // Required by AuthScheme for backwards compatibility + @Override + public void processChallenge(final AuthChallenge authChallenge, + final HttpContext context ) { + // If this gets called, then AuthScheme was changed in an incompatible way + throw new UnsupportedOperationException(); + } + + // The AuthScheme API maps awkwardly to GSSAPI, where proccessChallange and generateAuthResponse + // map to the same single method call. Hence the generated token is only stored in this method. + @Override + public void processChallenge( + final HttpHost host, + final boolean challenged, + final AuthChallenge authChallenge, + final HttpContext context + ) throws AuthenticationException { + + if (challengesLeft-- <= 0 ) { + if (LOG.isWarnEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.warn("{} GSS error: too many challenges received. Infinite loop ?", exchangeId); + } + state = State.FAILED; + return; + } + + final byte[] challengeToken = (authChallenge == null) ? null : Base64.decodeBase64(authChallenge.getValue()); + + final String gssHostname; + String hostname = host.getHostName(); + if (config.isUseCanonicalHostname()) { + try { + hostname = dnsResolver.resolveCanonicalHostname(host.getHostName()); + } catch (final UnknownHostException ignore) { + if (LOG.isWarnEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.warn("{} Could not canonicalize hostname {}, using as is.", exchangeId, host.getHostName()); + } + } + } + if (config.isAddPort()) { + gssHostname = hostname + ":" + host.getPort(); + } else { + gssHostname = hostname; + } + + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} GSS init {}", exchangeId, gssHostname); + } + try { + switch (state) { + case UNINITIATED: + setGssCredential(HttpClientContext.cast(context).getCredentialsProvider(), host, context); + if (challengeToken == null) { + queuedToken = generateToken(challengeToken, PEER_SERVICE_NAME, gssHostname); + state = State.TOKEN_READY; + } else { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} Internal GSS error: token received when none was sent yet: {}", exchangeId, challengeToken); + } + state = State.FAILED; + } + break; + case TOKEN_SENT: + if (challengeToken == null) { + if (!challenged && ignoreIncompleteSecurityContext) { + // Got a Non 401/407 code without a challenge. Old non RFC compliant server. + if (LOG.isWarnEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.warn("{} GSS Context is not established, but continuing because GssConfig.ignoreIncompleteSecurityContext is true.", exchangeId); + } + state = State.SUCCEEDED; + break; + } else { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} Did not receive required challenge.", + exchangeId); + } + state = State.FAILED; + throw new AuthenticationException( + "Did not receive required challenge."); + } + } + queuedToken = generateToken(challengeToken, PEER_SERVICE_NAME, gssHostname); + if (challenged) { + state = State.TOKEN_READY; + } else if (!gssContext.isEstablished()) { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} GSSContext is not established.", exchangeId); + } + state = State.FAILED; + // TODO should we have specific exception(s) for these ? + throw new AuthenticationException( + "GSSContext is not established."); + } else if (!gssContext.getMutualAuthState()) { + if (requireMutualAuth) { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} requireMutualAuth is true but GSSContext mutualAuthState is false", + exchangeId); + } + state = State.FAILED; + throw new AuthenticationException( + "requireMutualAuth is true but GSSContext mutualAuthState is false"); + } else { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} GSSContext MutualAuthState is false, but continuing because GssConfig.requireMutualAuth is false.", + exchangeId); + } + state = State.SUCCEEDED; + } + } else { + state = State.SUCCEEDED; + } + break; + default: + final State prevState = state; + state = State.FAILED; + throw new IllegalStateException("Illegal state: " + prevState); + } + } catch (final GSSException gsse) { + state = State.FAILED; + if (gsse.getMajor() == GSSException.DEFECTIVE_CREDENTIAL + || gsse.getMajor() == GSSException.CREDENTIALS_EXPIRED) { + throw new InvalidCredentialsException(gsse.getMessage(), gsse); + } + if (gsse.getMajor() == GSSException.NO_CRED) { + throw new InvalidCredentialsException(gsse.getMessage(), gsse); + } + if (gsse.getMajor() == GSSException.DEFECTIVE_TOKEN + || gsse.getMajor() == GSSException.DUPLICATE_TOKEN + || gsse.getMajor() == GSSException.OLD_TOKEN) { + throw new AuthenticationException(gsse.getMessage(), gsse); + } + // other error + throw new AuthenticationException(gsse.getMessage(), gsse); + } finally { + if ((state == State.FAILED || state == State.SUCCEEDED) && gssContext != null) { + dispose(); + } + } + } + + protected GSSManager getManager() { + return GSSManager.getInstance(); + } + + /** + * @since 4.4 + */ + protected byte[] generateGSSToken( + final byte[] input, final Oid oid, final String gssServiceName, final String gssHostname) throws GSSException { + final GSSManager manager = getManager(); + final GSSName peerName = manager.createName(gssServiceName + "@" + gssHostname, GSSName.NT_HOSTBASED_SERVICE); + + if (gssContext == null) { + gssContext = createGSSContext(manager, oid, peerName, gssCredential); + } + if (input != null) { + return gssContext.initSecContext(input, 0, input.length); + } + return gssContext.initSecContext(new byte[] {}, 0, 0); + } + + /** + * @since 5.0 + */ + protected GSSContext createGSSContext( + final GSSManager manager, + final Oid oid, + final GSSName peerName, + final GSSCredential gssCredential) throws GSSException { + final GSSContext gssContext = manager.createContext(peerName.canonicalize(oid), oid, gssCredential, + GSSContext.DEFAULT_LIFETIME); + gssContext.requestMutualAuth(config.isRequestMutualAuth()); + gssContext.requestCredDeleg(config.isRequestDelegCreds()); + return gssContext; + } + + /** + * @since 4.4 + */ + protected abstract byte[] generateToken(byte[] input, String gssServiceName, String gssHostname) throws GSSException; + + @Override + public boolean isChallengeComplete() { + // For the mutual authentication response, this is should technically return true. + // However, the HttpAuthenticator immediately fails the authentication + // process if we return true, so we only return true here if the authentication has failed. + return this.state == State.FAILED; + } + + @Override + public boolean isChallengeExpected() { + return state == State.TOKEN_SENT; + } + + @Override + public boolean isResponseReady( + final HttpHost host, + final CredentialsProvider credentialsProvider, + final HttpContext context) throws AuthenticationException { + + Args.notNull(host, "Auth host"); + Args.notNull(credentialsProvider, "CredentialsProvider"); + + return true; + } + + protected void setGssCredential(final CredentialsProvider credentialsProvider, + final HttpHost host, + final HttpContext context) { + final Credentials credentials = + credentialsProvider.getCredentials(new AuthScope(host, null, getName()), context); + if (credentials instanceof org.apache.hc.client5.http.auth.gss.GssCredentials) { + this.gssCredential = + ((org.apache.hc.client5.http.auth.gss.GssCredentials) credentials) + .getGSSCredential(); + } else { + this.gssCredential = null; + } + } + + @Override + public Principal getPrincipal() { + return null; + } + + // Format the queued token and update the state. + // All token processing is done in processChallenge() + @Override + public String generateAuthResponse( + final HttpHost host, + final HttpRequest request, + final HttpContext context) throws AuthenticationException { + Args.notNull(host, "HTTP host"); + Args.notNull(request, "HTTP request"); + switch (state) { + case UNINITIATED: + throw new AuthenticationException(getName() + " authentication has not been initiated"); + case FAILED: + throw new AuthenticationException(getName() + " authentication has failed"); + case SUCCEEDED: + return null; + case TOKEN_READY: + state = State.TOKEN_SENT; + final Base64 codec = new Base64(0); + final String tokenstr = new String(codec.encode(queuedToken)); + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} Sending GSS response '{}' back to the auth server", exchangeId, tokenstr); + } + return StandardAuthScheme.SPNEGO + " " + tokenstr; + default: + throw new IllegalStateException("Illegal state: " + state); + } + } + + @Override + public String toString() { + return getName() + "{" + this.state + " " + challenge + '}'; + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/SpnegoScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/SpnegoScheme.java new file mode 100644 index 0000000000..c552c8ab8a --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/SpnegoScheme.java @@ -0,0 +1,121 @@ +/* + * ==================================================================== + * 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.impl.auth.gss; + +import org.apache.hc.client5.http.AuthenticationStrategy; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.impl.auth.SPNegoScheme; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.Oid; + +/** + * SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) authentication + * scheme. + *

+ * This is the new mutual authentication capable Scheme which replaces the old deprecated + * {@link SPNegoScheme} + *

+ * + *

+ * Note that this scheme is not enabled by default. To use it, you need create a custom + * {@link AuthenticationStrategy} and a custom + * {@link org.apache.hc.client5.http.auth.AuthSchemeFactory} + * {@link org.apache.hc.core5.http.config.Registry}, + * and set them on the HttpClientBuilder. + *

+ * + *
+ * {@code
+ * private static class SpnegoAuthenticationStrategy extends DefaultAuthenticationStrategy {
+ *   private static final List SPNEGO_SCHEME_PRIORITY =
+ *       Collections.unmodifiableList(
+ *           Arrays.asList(StandardAuthScheme.SPNEGO
+ *           // Add other Schemes as needed
+ *           );
+ *
+ *   protected final List getSchemePriority() {
+ *     return SPNEGO_SCHEME_PRIORITY;
+ *   }
+ * }
+ *
+ * AuthenticationStrategy spnegoStrategy = new SpnegoAuthenticationStrategy();
+ *
+ * AuthSchemeFactory spnegoFactory = new SpnegoSchemeFactory();
+ * Registry mutualSchemeRegistry = RegistryBuilder.create()
+ *     .register(StandardAuthScheme.SPNEGO, spnegoFactory)
+ *     //register other schemes as needed
+ *     .build();
+ *
+ * CloseableHttpClient mutualClient = HttpClientBuilder.create()
+ *    .setTargetAuthenticationStrategy(spnegoStrategy);
+ *    .setDefaultAuthSchemeRegistry(spnegoSchemeRegistry);
+ *    .build();
+ * }
+ * 
+ * + * @since 5.6 + */ +public class SpnegoScheme extends GssSchemeBase { + + private static final String SPNEGO_OID_STRING = "1.3.6.1.5.5.2"; + private static final Oid SPNEGO_OID; + static { + try { + SPNEGO_OID = new Oid(SPNEGO_OID_STRING); + } catch (final GSSException e) { + throw new IllegalStateException("Failed to create OID for SPNEGO mechanism", e); + } + } + + /** + * @since 5.0 + */ + public SpnegoScheme(final org.apache.hc.client5.http.auth.gss.GssConfig config, final DnsResolver dnsResolver) { + super(config, dnsResolver); + } + + public SpnegoScheme() { + super(); + } + + @Override + public String getName() { + return StandardAuthScheme.SPNEGO; + } + + @Override + protected byte[] generateToken(final byte[] input, final String gssServiceName, final String gssHostname) throws GSSException { + return generateGSSToken(input, SPNEGO_OID, gssServiceName, gssHostname); + } + + @Override + public boolean isConnectionBased() { + return false; + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/SpnegoSchemeFactory.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/SpnegoSchemeFactory.java new file mode 100644 index 0000000000..b36ac93229 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/gss/SpnegoSchemeFactory.java @@ -0,0 +1,79 @@ +/* + * ==================================================================== + * 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.impl.auth.gss; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Experimental; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * {@link AuthSchemeFactory} implementation that creates and initialises + * {@link SpnegoScheme} instances. + *

+ * This replaces the old deprecated {@link org.apache.hc.client5.http.impl.auth.SPNegoSchemeFactory} + *

+ * + * @since 5.6 + * + * @see SPNegoSchemeFactory + */ +@Contract(threading = ThreadingBehavior.STATELESS) +@Experimental +public class SpnegoSchemeFactory implements AuthSchemeFactory { + + /** + * Singleton instance for the default configuration. + */ + public static final SpnegoSchemeFactory DEFAULT = new SpnegoSchemeFactory(org.apache.hc.client5.http.auth.gss.GssConfig.DEFAULT, + SystemDefaultDnsResolver.INSTANCE); + + public static final SpnegoSchemeFactory LEGACY = new SpnegoSchemeFactory(org.apache.hc.client5.http.auth.gss.GssConfig.LEGACY, + SystemDefaultDnsResolver.INSTANCE); + + private final org.apache.hc.client5.http.auth.gss.GssConfig config; + private final DnsResolver dnsResolver; + + /** + * @since 5.6 + */ + public SpnegoSchemeFactory(final org.apache.hc.client5.http.auth.gss.GssConfig config, final DnsResolver dnsResolver) { + super(); + this.config = config; + this.dnsResolver = dnsResolver; + } + + @Override + public AuthScheme create(final HttpContext context) { + return new SpnegoScheme(this.config, this.dnsResolver); + } + +} diff --git a/pom.xml b/pom.xml index cc21e55cc1..d1ac126717 100644 --- a/pom.xml +++ b/pom.xml @@ -77,7 +77,7 @@ 1.21.3 2.10.1 5.3 - javax.net.ssl.SSLEngine,javax.net.ssl.SSLParameters,java.nio.ByteBuffer,java.nio.CharBuffer,jdk.net.ExtendedSocketOptions,jdk.net.Sockets + javax.net.ssl.SSLEngine,javax.net.ssl.SSLParameters,java.nio.ByteBuffer,java.nio.CharBuffer,jdk.net.ExtendedSocketOptions,jdk.net.Sockets,java.lang.invoke.MethodHandle 1.27.1 1.5.7-4 1.15.2 @@ -336,6 +336,8 @@ com.github.siom79.japicmp japicmp-maven-plugin + + true ${project.groupId} @@ -503,6 +505,8 @@ + + true ${project.groupId}