From be6688d8701ed622afb0248dc28bdfa3e831a28e Mon Sep 17 00:00:00 2001 From: Istvan Toth Date: Thu, 13 Feb 2025 08:45:00 +0100 Subject: [PATCH] Add containerized apache httpd + mod_auth_gssapi tests Fix GSSCredential handling --- httpclient5-testing/pom.xml | 16 ++ .../client5/testing/util/SecurityUtils.java | 221 ++++++++++++++++++ .../ApacheHTTPDSquidCompatibilityIT.java | 50 +++- .../compatibility/ContainerImages.java | 54 ++++- .../HttpAsyncClientCompatibilityTest.java | 76 ++++++ ...HttpAsyncClientHttp1CompatibilityTest.java | 36 ++- .../spnego/KeytabConfiguration.java | 73 ++++++ .../spnego/SpnegoAuthenticationStrategy.java | 49 ++++ .../compatibility/spnego/SpnegoTestUtil.java | 136 +++++++++++ .../spnego/UseJaasCredentials.java | 45 ++++ .../sync/HttpClientCompatibilityTest.java | 58 +++++ .../test/resources/docker/httpd/httpd.conf | 17 +- .../src/test/resources/docker/httpd/start.sh | 3 + .../src/test/resources/docker/kdc/krb5.conf | 19 ++ .../src/test/resources/docker/kdc/start.sh | 20 ++ .../src/test/resources/log4j2.xml | 6 +- .../http/impl/auth/MutualGssSchemeBase.java | 20 +- pom.xml | 27 ++- 18 files changed, 905 insertions(+), 21 deletions(-) create mode 100644 httpclient5-testing/src/main/java/org/apache/hc/client5/testing/util/SecurityUtils.java create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/KeytabConfiguration.java create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoAuthenticationStrategy.java create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoTestUtil.java create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/UseJaasCredentials.java create mode 100644 httpclient5-testing/src/test/resources/docker/httpd/start.sh create mode 100644 httpclient5-testing/src/test/resources/docker/kdc/krb5.conf create mode 100644 httpclient5-testing/src/test/resources/docker/kdc/start.sh diff --git a/httpclient5-testing/pom.xml b/httpclient5-testing/pom.xml index 63377a25af..055240f3f9 100644 --- a/httpclient5-testing/pom.xml +++ b/httpclient5-testing/pom.xml @@ -52,6 +52,7 @@ org.apache.httpcomponents.client5 httpclient5 + 5.5-alpha1-SNAPSHOT org.slf4j @@ -107,6 +108,21 @@ junit-jupiter test + + org.apache.kerby + kerb-core + test + + + org.apache.kerby + kerb-client + test + + + org.apache.kerby + kerb-simplekdc + test + 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..b933f5ff28 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,11 +59,24 @@ 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); @Container static final GenericContainer SQUID = ContainerImages.squid(NETWORK); + 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)); } @@ -82,7 +105,20 @@ static HttpHost proxyPwProtectedContainerHost() { static void cleanup() { 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 @@ -90,7 +126,7 @@ static void cleanup() { class ClassicDirectHttp extends HttpClientCompatibilityTest { public ClassicDirectHttp() throws Exception { - super(targetContainerHost(), null, null); + super(targetContainerHost(), null, null, spnegoSubject); } } @@ -120,7 +156,7 @@ public ClassicViaPwProtectedProxyHttp() throws Exception { class ClassicDirectHttpTls extends HttpClientCompatibilityTest { public ClassicDirectHttpTls() throws Exception { - super(targetContainerTlsHost(), null, null); + super(targetContainerTlsHost(), null, null, spnegoSubject); } } @@ -150,7 +186,7 @@ public ClassicViaPwProtectedProxyHttpTls() throws Exception { class AsyncDirectHttp1 extends HttpAsyncClientHttp1CompatibilityTest { public AsyncDirectHttp1() throws Exception { - super(targetContainerHost(), null, null); + super(targetContainerHost(), null, null, ApacheHTTPDSquidCompatibilityIT.spnegoSubject); } } @@ -180,7 +216,7 @@ public AsyncViaPwProtectedProxyHttp1() throws Exception { class AsyncDirectHttp1Tls extends HttpAsyncClientHttp1CompatibilityTest { public AsyncDirectHttp1Tls() throws Exception { - super(targetContainerTlsHost(), null, null); + super(targetContainerTlsHost(), null, null, ApacheHTTPDSquidCompatibilityIT.spnegoSubject); } } @@ -210,7 +246,7 @@ public AsyncViaPwProtectedProxyHttp1Tls() throws Exception { class AsyncDirectHttp2 extends HttpAsyncClientCompatibilityTest { public AsyncDirectHttp2() throws Exception { - super(HttpVersionPolicy.FORCE_HTTP_2, targetContainerHost(), null, null); + super(HttpVersionPolicy.FORCE_HTTP_2, targetContainerHost(), null, null, ApacheHTTPDSquidCompatibilityIT.spnegoSubject); } } @@ -220,7 +256,7 @@ public AsyncDirectHttp2() throws Exception { class AsyncDirectHttp2Tls extends HttpAsyncClientCompatibilityTest { public AsyncDirectHttp2Tls() throws Exception { - super(HttpVersionPolicy.FORCE_HTTP_2, targetContainerTlsHost(), null, null); + super(HttpVersionPolicy.FORCE_HTTP_2, targetContainerTlsHost(), null, null, ApacheHTTPDSquidCompatibilityIT.spnegoSubject); } } 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..30633b05d6 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,14 @@ 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) { 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("start.sh", "docker/httpd/start.sh") + .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 +83,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/") @@ -86,14 +92,30 @@ public static GenericContainer apacheHttpD(final Network network) { .copy("111", "${www_dir}/") .copy("222", "${www_dir}/") .copy("333", "${www_dir}/") + .copy("start.sh", "/usr/local/bin/") .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") + .cmd("/bin/sh", "/usr/local/bin/start.sh") .build())) .withNetwork(network) .withNetworkAliases(WEB_SERVER) + .withFileSystemBind(keytabsHostPath.toString(), "/keytabs") .withLogConsumer(new Slf4jLogConsumer(LOG)) .withExposedPorts(HTTP_PORT, HTTPS_PORT); } @@ -116,4 +138,28 @@ public static GenericContainer squid(final Network network) { } + // 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..511b6c0e0f 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 @@ -26,11 +26,16 @@ */ package org.apache.hc.client5.testing.compatibility.async; +import static org.junit.Assume.assumeNotNull; + import java.util.Queue; +import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Future; +import javax.security.auth.Subject; + import org.apache.hc.client5.http.ContextBuilder; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; @@ -42,7 +47,11 @@ 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.compatibility.spnego.UseJaasCredentials; import org.apache.hc.client5.testing.extension.async.HttpAsyncClientResource; +import org.apache.hc.client5.testing.util.SecurityUtils; import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpStatus; @@ -51,6 +60,7 @@ import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.util.Timeout; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -63,27 +73,45 @@ public abstract class HttpAsyncClientCompatibilityTest { private final HttpHost target; @RegisterExtension private final HttpAsyncClientResource clientResource; + private final HttpAsyncClientResource spnegoClientResource; private final BasicCredentialsProvider credentialsProvider; + protected final Subject spnegoSubject; public HttpAsyncClientCompatibilityTest( final HttpVersionPolicy versionPolicy, final HttpHost target, final HttpHost proxy, final Credentials proxyCreds) throws Exception { + this(versionPolicy, target, proxy, proxyCreds, null); + } + + public HttpAsyncClientCompatibilityTest( + final HttpVersionPolicy versionPolicy, + final HttpHost target, + final HttpHost proxy, + final Credentials proxyCreds, + final Subject spnegoSubject) throws Exception { this.versionPolicy = versionPolicy; this.target = target; this.clientResource = new HttpAsyncClientResource(versionPolicy); + this.spnegoClientResource = new HttpAsyncClientResource(versionPolicy); this.clientResource.configure(builder -> builder.setProxy(proxy)); + this.spnegoClientResource.configure(builder -> builder.setProxy(proxy).setTargetAuthenticationStrategy(new SpnegoAuthenticationStrategy()).setDefaultAuthSchemeRegistry(SpnegoTestUtil.getSpnegoSchemeRegistry())); this.credentialsProvider = new BasicCredentialsProvider(); if (proxy != null && proxyCreds != null) { this.credentialsProvider.setCredentials(new AuthScope(proxy), proxyCreds); } + this.spnegoSubject = spnegoSubject; } CloseableHttpAsyncClient client() { return clientResource.client(); } + CloseableHttpAsyncClient spnegoClient() { + return spnegoClientResource.client(); + } + HttpClientContext context() { return ContextBuilder.create() .useCredentialsProvider(credentialsProvider) @@ -228,4 +256,52 @@ void test_auth_success() throws Exception { assertProtocolVersion(context); } + // This does not work. + // Looks like by the time the SPNEGO negotiations happens, we're in another thread, + // and Subject is no longer set. We could save the subject somewhere, or just document this. + @Disabled + @Test + void test_spnego_auth_success_implicit() throws Exception { + assumeNotNull(spnegoSubject); + addCredentials( + new AuthScope(target), + new UseJaasCredentials()); + final CloseableHttpAsyncClient client = spnegoClient(); + final HttpClientContext context = context(); + final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get() + .setHttpHost(target) + .setPath("/private_spnego/big-secret.txt") + .build(); + + final Future future = SecurityUtils.callAs(spnegoSubject, new Callable>() { + @Override + public Future call() throws Exception { + return client.execute(httpGetSecret, context, null); + } + }); + + final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit()); + Assertions.assertEquals(HttpStatus.SC_OK, response.getCode()); + assertProtocolVersion(context); + } + + @Test + void test_spnego_auth_success() throws Exception { + assumeNotNull(spnegoSubject); + addCredentials( + new AuthScope(target), + SpnegoTestUtil.createCredentials(spnegoSubject)); + final CloseableHttpAsyncClient client = spnegoClient(); + final HttpClientContext context = context(); + final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get() + .setHttpHost(target) + .setPath("/private_spnego/big-secret.txt") + .build(); + + final Future future = client.execute(httpGetSecret, context, null); + + final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit()); + Assertions.assertEquals(HttpStatus.SC_OK, response.getCode()); + assertProtocolVersion(context); + } } 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..9813c8aa84 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 @@ -26,8 +26,12 @@ */ package org.apache.hc.client5.testing.compatibility.async; +import static org.junit.Assume.assumeNotNull; + import java.util.concurrent.Future; +import javax.security.auth.Subject; + import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; @@ -36,6 +40,7 @@ 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.client5.testing.compatibility.spnego.SpnegoTestUtil; import org.apache.hc.core5.http.HeaderElements; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; @@ -52,7 +57,16 @@ public HttpAsyncClientHttp1CompatibilityTest( final HttpHost target, final HttpHost proxy, final Credentials proxyCreds) throws Exception { - super(HttpVersionPolicy.FORCE_HTTP_1, target, proxy, proxyCreds); + super(HttpVersionPolicy.FORCE_HTTP_1, target, proxy, proxyCreds, null); + this.target = target; + } + + public HttpAsyncClientHttp1CompatibilityTest( + final HttpHost target, + final HttpHost proxy, + final Credentials proxyCreds, + final Subject spnegoSubject) throws Exception { + super(HttpVersionPolicy.FORCE_HTTP_1, target, proxy, proxyCreds, spnegoSubject); this.target = target; } @@ -75,4 +89,24 @@ void test_auth_success_no_keep_alive() throws Exception { assertProtocolVersion(context); } + @Test + void test_spnego_auth_success_no_keep_alive() throws Exception { + assumeNotNull(spnegoSubject); + addCredentials( + new AuthScope(target), + SpnegoTestUtil.createCredentials(spnegoSubject)); + final CloseableHttpAsyncClient client = spnegoClient(); + final HttpClientContext context = context(); + final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get() + .setHttpHost(target) + .setPath("/private_spnego/big-secret.txt") + .addHeader(HttpHeaders.CONNECTION, HeaderElements.CLOSE) + .build(); + + final Future future = client.execute(httpGetSecret, context, null); + + final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit()); + Assertions.assertEquals(HttpStatus.SC_OK, response.getCode()); + assertProtocolVersion(context); + } } 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..b1c1efec77 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoAuthenticationStrategy.java @@ -0,0 +1,49 @@ +/* + * ==================================================================== + * 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.SPNEGO, + StandardAuthScheme.BEARER, + StandardAuthScheme.DIGEST, + StandardAuthScheme.BASIC)); + + @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..ed4188a568 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/spnego/SpnegoTestUtil.java @@ -0,0 +1,136 @@ +/* + * ==================================================================== + * 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.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.KerberosCredentials; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.impl.auth.MutualSpnegoSchemeFactory; +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 KerberosCredentials createCredentials(final Subject subject) { + return SecurityUtils.callAs(subject, new Callable() { + @Override + public KerberosCredentials call() throws Exception { + return new KerberosCredentials(GSSManager.getInstance().createCredential(GSSCredential.INITIATE_ONLY)); + } + }); + } + + public static Path createKeytabDir() { + try { + return Files.createTempDirectory("keytabs"); + } catch (final IOException e) { + return Paths.get("/tmp/keytabs"); + } + } + + public static Registry getSpnegoSchemeRegistry() { + return RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, MutualSpnegoSchemeFactory.DEFAULT) + // 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..c2ae1332e0 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,6 +26,11 @@ */ package org.apache.hc.client5.testing.compatibility.sync; + +import java.util.concurrent.Callable; + +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; @@ -36,7 +41,11 @@ 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.compatibility.spnego.UseJaasCredentials; 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; @@ -45,6 +54,7 @@ import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -53,13 +63,22 @@ public abstract class HttpClientCompatibilityTest { private final HttpHost target; @RegisterExtension private final HttpClientResource clientResource; + private final HttpClientResource spnegoClientResource; private final CredentialsStore credentialsProvider; + private final Subject spnegoSubject; public HttpClientCompatibilityTest(final HttpHost target, final HttpHost proxy, final Credentials proxyCreds) throws Exception { + this(target, proxy, proxyCreds, null); + } + + public HttpClientCompatibilityTest(final HttpHost target, final HttpHost proxy, final Credentials proxyCreds, final Subject spnegoSubject) throws Exception { this.target = target; this.clientResource = new HttpClientResource(); this.clientResource.configure(builder -> builder.setProxy(proxy)); + this.spnegoClientResource = new HttpClientResource(); + this.spnegoClientResource.configure(builder -> builder.setProxy(proxy).setTargetAuthenticationStrategy(new SpnegoAuthenticationStrategy()).setDefaultAuthSchemeRegistry(SpnegoTestUtil.getSpnegoSchemeRegistry())); this.credentialsProvider = new BasicCredentialsProvider(); + this.spnegoSubject = spnegoSubject; if (proxy != null && proxyCreds != null) { this.addCredentials(new AuthScope(proxy), proxyCreds); } @@ -69,6 +88,10 @@ CloseableHttpClient client() { return clientResource.client(); } + CloseableHttpClient spnegoClient() { + return spnegoClientResource.client(); + } + HttpClientContext context() { return ContextBuilder.create() .useCredentialsProvider(credentialsProvider) @@ -185,4 +208,39 @@ void test_correct_target_credentials_no_keep_alive() throws Exception { } } + @Test + void test_spnego_correct_target_credentials_implicit() throws Exception { + Assumptions.assumeFalse(spnegoSubject == null); + addCredentials(new AuthScope(target), new UseJaasCredentials()); + final CloseableHttpClient client = spnegoClient(); + final HttpClientContext context = context(); + + final ClassicHttpRequest request = new HttpGet("/private_spnego/big-secret.txt"); + try (ClassicHttpResponse response = + SecurityUtils.callAs(spnegoSubject, new Callable() { + @Override + public ClassicHttpResponse call() throws Exception { + return client.executeOpen(target, request, context); + } + });) { + Assertions.assertEquals(HttpStatus.SC_OK, response.getCode()); + EntityUtils.consume(response.getEntity()); + } + } + + @Test + void test_spnego_correct_target_credentials() throws Exception { + Assumptions.assumeFalse(spnegoSubject == null); + addCredentials( + new AuthScope(target), + SpnegoTestUtil.createCredentials(spnegoSubject)); + final CloseableHttpClient client = spnegoClient(); + final HttpClientContext context = context(); + + final ClassicHttpRequest request = new HttpGet("/private_spnego/big-secret.txt"); + try (ClassicHttpResponse response = client.executeOpen(target, request, context)) { + Assertions.assertEquals(HttpStatus.SC_OK, response.getCode()); + EntityUtils.consume(response.getEntity()); + } + } } 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/httpd/start.sh b/httpclient5-testing/src/test/resources/docker/httpd/start.sh new file mode 100644 index 0000000000..c4183eb52a --- /dev/null +++ b/httpclient5-testing/src/test/resources/docker/httpd/start.sh @@ -0,0 +1,3 @@ +#!/bin/sh +chmod 777 /keytabs +/usr/local/bin/httpd-foreground 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..7785e58ea1 --- /dev/null +++ b/httpclient5-testing/src/test/resources/docker/kdc/krb5.conf @@ -0,0 +1,19 @@ +[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..cb09f23020 --- /dev/null +++ b/httpclient5-testing/src/test/resources/docker/kdc/start.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# 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 /keytabs/testclient.keytab +rm /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/log4j2.xml b/httpclient5-testing/src/test/resources/log4j2.xml index dff8a53814..4c72fff550 100644 --- a/httpclient5-testing/src/test/resources/log4j2.xml +++ b/httpclient5-testing/src/test/resources/log4j2.xml @@ -15,15 +15,17 @@ See the License for the specific language governing permissions and limitations under the License. --> - + - + + + \ No newline at end of file diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualGssSchemeBase.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualGssSchemeBase.java index 33021f5197..6fa04715f3 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualGssSchemeBase.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualGssSchemeBase.java @@ -165,6 +165,7 @@ public void processChallenge( LOG.debug("{} GSS init {}", exchangeId, gssHostname); } try { + setGssCredential(HttpClientContext.cast(context).getCredentialsProvider(), host, context); queuedToken = generateToken(challengeToken, KERBEROS_SCHEME, gssHostname); switch (state) { case UNINITIATED: @@ -298,14 +299,25 @@ public boolean isResponseReady( Args.notNull(host, "Auth host"); Args.notNull(credentialsProvider, "CredentialsProvider"); - final Credentials credentials = credentialsProvider.getCredentials( - new AuthScope(host, null, getName()), context); + setGssCredential(credentialsProvider, host, context); + return true; + } + + protected void setGssCredential(final CredentialsProvider credentialsProvider, + final HttpHost host, + final HttpContext context) { + if (this.gssCredential != null) { + return; + } + 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(); + this.gssCredential = + ((org.apache.hc.client5.http.auth.KerberosCredentials) credentials) + .getGSSCredential(); } else { this.gssCredential = null; } - return true; } @Override diff --git a/pom.xml b/pom.xml index 9cc5cda643..23300ee6b5 100644 --- a/pom.xml +++ b/pom.xml @@ -76,7 +76,12 @@ 2.2.21 1.20.2 5.3 - javax.net.ssl.SSLEngine,javax.net.ssl.SSLParameters,java.nio.ByteBuffer,java.nio.CharBuffer + 9.4.56.v20240826 + 2.1.0 + javax.net.ssl.SSLEngine,javax.net.ssl.SSLParameters,java.nio.ByteBuffer,java.nio.CharBuffer,java.lang.invoke.MethodHandle + + ${project.build.directory} + @@ -192,6 +197,22 @@ junit-jupiter ${testcontainers.version} + + + org.apache.kerby + kerb-core + ${kerby.version} + + + org.apache.kerby + kerb-client + ${kerby.version} + + + org.apache.kerby + kerb-simplekdc + ${kerby.version} + @@ -270,6 +291,8 @@ com.github.siom79.japicmp japicmp-maven-plugin + + true ${project.groupId} @@ -408,6 +431,8 @@ + + true ${project.groupId}