From d211b53cde0cce1a3b7e67c330ffe448cacf6bbc Mon Sep 17 00:00:00 2001 From: Konrad Windszus Date: Wed, 28 Feb 2024 12:52:27 +0100 Subject: [PATCH] OSGi support for Java HTTP Client This closes #3276 --- ...positoryInitializer-acs-commons-all.config | 7 + ...rMapperImpl.amended-acs-commons-all.config | 3 +- .../http/OsgiManagedJavaHttpClient.java | 65 +++++ .../commons/http/impl/AemKeyStoreFactory.java | 174 ++++++++++++++ .../commons/http/impl/KeyManagerUtils.java | 92 +++++++ .../impl/OsgiManagedJavaHttpClientImpl.java | 227 ++++++++++++++++++ .../http/impl/ProxyAuthenticatorImpl.java | 53 ++++ .../http/impl/ProxyConfigurationSelector.java | 207 ++++++++++++++++ .../commons/http/impl/ProxySelectorImpl.java | 72 ++++++ .../commons/http/impl/TrustManagerUtils.java | 111 +++++++++ .../adobe/acs/commons/http/package-info.java | 4 +- pom.xml | 2 +- 12 files changed, 1013 insertions(+), 4 deletions(-) create mode 100644 bundle/src/main/java/com/adobe/acs/commons/http/OsgiManagedJavaHttpClient.java create mode 100644 bundle/src/main/java/com/adobe/acs/commons/http/impl/AemKeyStoreFactory.java create mode 100644 bundle/src/main/java/com/adobe/acs/commons/http/impl/KeyManagerUtils.java create mode 100644 bundle/src/main/java/com/adobe/acs/commons/http/impl/OsgiManagedJavaHttpClientImpl.java create mode 100644 bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxyAuthenticatorImpl.java create mode 100644 bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxyConfigurationSelector.java create mode 100644 bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxySelectorImpl.java create mode 100644 bundle/src/main/java/com/adobe/acs/commons/http/impl/TrustManagerUtils.java diff --git a/all/src/main/content/jcr_root/apps/acs-commons/config/org.apache.sling.jcr.repoinit.RepositoryInitializer-acs-commons-all.config b/all/src/main/content/jcr_root/apps/acs-commons/config/org.apache.sling.jcr.repoinit.RepositoryInitializer-acs-commons-all.config index 1dec5e6dc5..af911c254a 100644 --- a/all/src/main/content/jcr_root/apps/acs-commons/config/org.apache.sling.jcr.repoinit.RepositoryInitializer-acs-commons-all.config +++ b/all/src/main/content/jcr_root/apps/acs-commons/config/org.apache.sling.jcr.repoinit.RepositoryInitializer-acs-commons-all.config @@ -46,6 +46,13 @@ set ACL for anonymous allow jcr:read on /conf restriction(rep:glob,/*/settings/redirects) end +# user to read keystores of users as well as global truststore +create service user acs-commons-osgi-key-store-factory with path system/acs-commons +set ACL for acs-commons-osgi-key-store-factory + allow jcr:read on /home/users + allow jcr:read on /etc/truststore +end + create service user acs-commons-automatic-package-replicator-service with path system/acs-commons create path /etc/acs-commons/automatic-package-replication(sling:OrderedFolder) set ACL for acs-commons-automatic-package-replicator-service diff --git a/all/src/main/content/jcr_root/apps/acs-commons/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-acs-commons-all.config b/all/src/main/content/jcr_root/apps/acs-commons/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-acs-commons-all.config index a91dfa7eb5..ace20e1b1a 100644 --- a/all/src/main/content/jcr_root/apps/acs-commons/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-acs-commons-all.config +++ b/all/src/main/content/jcr_root/apps/acs-commons/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-acs-commons-all.config @@ -15,5 +15,6 @@ user.mapping=[ \ "com.adobe.acs.acs-aem-commons-bundle:workflowpackagemanager-service\=[acs-commons-workflowpackagemanager-service]", \ "com.adobe.acs.acs-aem-commons-bundle:redirect-manager\=[acs-commons-manage-redirects-service]", \ "com.adobe.acs.acs-aem-commons-bundle:marketo-conf\=[acs-commons-marketo-conf-service]", \ - "com.adobe.acs.acs-aem-commons-bundle:package-garbage-collection\=[acs-commons-package-garbage-collection-service]" \ + "com.adobe.acs.acs-aem-commons-bundle:package-garbage-collection\=[acs-commons-package-garbage-collection-service]", \ + "com.adobe.acs.acs-aem-commons-bundle:key-store-factory\=[acs-commons-osgi-key-store-factory]" ] diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/OsgiManagedJavaHttpClient.java b/bundle/src/main/java/com/adobe/acs/commons/http/OsgiManagedJavaHttpClient.java new file mode 100644 index 0000000000..2c99b84d6c --- /dev/null +++ b/bundle/src/main/java/com/adobe/acs/commons/http/OsgiManagedJavaHttpClient.java @@ -0,0 +1,65 @@ +/* + * ACS AEM Commons + * + * Copyright (C) 2024 Konrad Windszus + * + * Licensed 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. + */ +package com.adobe.acs.commons.http; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.util.function.Consumer; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Encapsulates a single Java {@link HttpClient}. Its lifetime and basic configuration is managed via OSGi (Config Admin and Declarative + * Services). + * @since 2.2.0 (Bundle Version 6.5.0) + * @see HttpClientFactory HttpClientFactory, for a similar service for the Apache Http Client + */ +public interface OsgiManagedJavaHttpClient { + + /** Returns the configured HTTP client. + * + * @return the HTTP client + */ + @NotNull HttpClient getClient(); + + /** + * Similar to {@link #getClient()} but customizes the underlying {@link HttpClient.Builder} which is used to create the singleton HTTP + * client + * + * @param builderCustomizer a {@link Consumer} taking the {@link HttpClient.Builder} initialized with the configured basic options. + * + * @throws IllegalStateException in case {@link #getClient()} has been called already + */ + @NotNull HttpClient getClient(@Nullable Consumer builderCustomizer); + + /** Creates a new configured HTTP request. + * + * @param uri the URI to target + * @return the new request + */ + @NotNull HttpRequest createRequest(@NotNull URI uri); + + /** Creates a new configured HTTP request. + * + * @param uri the URI to target + * @return the new request + */ + @NotNull HttpRequest createRequest(@NotNull URI uri, @Nullable Consumer builderCustomizer); +} diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/impl/AemKeyStoreFactory.java b/bundle/src/main/java/com/adobe/acs/commons/http/impl/AemKeyStoreFactory.java new file mode 100644 index 0000000000..16e7574854 --- /dev/null +++ b/bundle/src/main/java/com/adobe/acs/commons/http/impl/AemKeyStoreFactory.java @@ -0,0 +1,174 @@ +/* + * ACS AEM Commons + * + * Copyright (C) 2024 Konrad Windszus + * + * Licensed 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. + */ +package com.adobe.acs.commons.http.impl; + +import java.io.IOException; +import java.security.KeyStore; +import java.util.Map; + +import javax.jcr.Node; +import javax.jcr.Property; +import javax.jcr.RepositoryException; +import javax.net.ssl.X509TrustManager; + +import org.apache.jackrabbit.api.security.user.Authorizable; +import org.apache.jackrabbit.api.security.user.User; +import org.apache.jackrabbit.api.security.user.UserManager; +import org.apache.sling.api.SlingIOException; +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.resource.ResourceUtil; +import org.apache.sling.serviceusermapping.ServiceUserMapped; +import org.jetbrains.annotations.NotNull; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +import com.adobe.granite.crypto.CryptoException; +import com.adobe.granite.crypto.CryptoSupport; +import com.adobe.granite.keystore.KeyStoreService; + +@Component(service=AemKeyStoreFactory.class) +public class AemKeyStoreFactory { + + private static final String SUB_SERVICE_NAME = "key-store-factory"; + + private static final Map SERVICE_USER = Map.of(ResourceResolverFactory.SUBSERVICE, + SUB_SERVICE_NAME); + + /** Defer starting the service until service user mapping is available. */ + @Reference(target = "(|(" + ServiceUserMapped.SUBSERVICENAME + "=" + SUB_SERVICE_NAME + ")(!(" + + ServiceUserMapped.SUBSERVICENAME + "=*)))") + private ServiceUserMapped serviceUserMapped; + + private final ResourceResolverFactory resolverFactory; + private final KeyStoreService keyStoreService; + private final CryptoSupport cryptoSupport; + + @Activate() + public AemKeyStoreFactory(@Reference ResourceResolverFactory resolverFactory, + @Reference KeyStoreService keyStoreService, + @Reference CryptoSupport cryptoSupport) { + this.resolverFactory = resolverFactory; + this.keyStoreService = keyStoreService; + this.cryptoSupport = cryptoSupport; + } + + /** @return the global AEM trust store + * @throws LoginException + * @see Call + * internal APIs having private certificates */ + public @NotNull X509TrustManager getTrustManager() throws LoginException { + try (final var serviceResolver = getKeyStoreResourceResolver()) { + return (X509TrustManager) keyStoreService.getTrustManager(serviceResolver); + } + } + + /** @return the global AEM trust store + * @throws LoginException + * @see Call + * internal APIs having private certificates */ + public @NotNull KeyStore getTrustStore() throws LoginException { + try (final var serviceResolver = getKeyStoreResourceResolver()) { + var aemTrustStore = keyStoreService.getTrustStore(serviceResolver); + return aemTrustStore; + } + } + + public @NotNull KeyStore getKeyStore(@NotNull final String userId) throws LoginException { + try (final var serviceResolver = getKeyStoreResourceResolver()) { + // using the password set for the user Id's keystore to decrypt the entry + return keyStoreService.getKeyStore(serviceResolver, userId); + } + } + + public @NotNull char[] getKeyStorePassword(@NotNull final String userId) throws LoginException { + try (final var serviceResolver = getKeyStoreResourceResolver()) { + User user = retrieveUser(serviceResolver, userId); + String path = getKeyStorePathForUser(user, "store.p12"); + return extractStorePassword(serviceResolver, path, cryptoSupport); + } + } + + private @NotNull ResourceResolver getKeyStoreResourceResolver() throws LoginException { + return this.resolverFactory.getServiceResourceResolver(SERVICE_USER); + } + + // the following methods are extracted from com.adobe.granite.keystore.internal.KeyStoreServiceImpl, because there is no public method + // for retrieving the keystore's password + private static User retrieveUser(ResourceResolver resolver, String userId) + throws IllegalArgumentException, SlingIOException { + UserManager userManager = (UserManager) resolver.adaptTo(UserManager.class); + if (userManager != null) { + Authorizable authorizable; + try { + authorizable = userManager.getAuthorizable(userId); + } catch (RepositoryException var6) { + throw new SlingIOException(new IOException(var6)); + } + + if (authorizable != null && !authorizable.isGroup()) { + User user = (User) authorizable; + return user; + } else { + throw new IllegalArgumentException("The provided userId does not identify an existing user."); + } + } else { + throw new IllegalArgumentException("Cannot obtain a UserManager for the given resource resolver."); + } + } + + private static String getKeyStorePathForUser(User user, String keyStoreFileName) throws SlingIOException { + String userHome; + try { + userHome = user.getPath(); + } catch (RepositoryException var4) { + throw new SlingIOException(new IOException(var4)); + } + return userHome + "/" + "keystore" + "/" + keyStoreFileName; + } + + private static char[] extractStorePassword(ResourceResolver resolver, String storePath, CryptoSupport cryptoSupport) + throws SecurityException { + Resource storeResource = resolver.getResource(storePath); + if (storeResource != null) { + Node storeParentNode = (Node) storeResource.getParent().adaptTo(Node.class); + + try { + Property passwordProperty = storeParentNode.getProperty("keystorePassword"); + if (passwordProperty != null) { + return cryptoSupport.unprotect(passwordProperty.getString()).toCharArray(); + } else { + throw new SecurityException( + "Missing 'keystorePassword' property on " + ResourceUtil.getParent(storePath)); + } + } catch (RepositoryException var6) { + throw new SecurityException(var6); + } catch (CryptoException var7) { + throw new SecurityException(var7); + } + } else { + return null; + } + } + +} diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/impl/KeyManagerUtils.java b/bundle/src/main/java/com/adobe/acs/commons/http/impl/KeyManagerUtils.java new file mode 100644 index 0000000000..3dabd3b7d3 --- /dev/null +++ b/bundle/src/main/java/com/adobe/acs/commons/http/impl/KeyManagerUtils.java @@ -0,0 +1,92 @@ +/* + * ACS AEM Commons + * + * Copyright (C) 2024 Konrad Windszus + * + * Licensed 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. + */ +package com.adobe.acs.commons.http.impl; + +import java.net.Socket; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.X509KeyManager; + +import org.jetbrains.annotations.NotNull; + +public class KeyManagerUtils { + + private KeyManagerUtils() { + // no supposed to be instantiated + } + + static @NotNull X509KeyManager createSingleClientSideCertificateKeyManager(@NotNull KeyStore keyStore, @NotNull char[] password, @NotNull String clientCertAlias) throws UnrecoverableKeyException, NoSuchAlgorithmException, KeyStoreException { + return new FixClientAliasX509KeyManagerWrapper(clientCertAlias, createKeyManager(keyStore, password)); + } + + private static @NotNull X509KeyManager createKeyManager(@NotNull KeyStore keyStore, @NotNull char[] password) + throws NoSuchAlgorithmException, KeyStoreException, UnrecoverableKeyException { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, password); + return (X509KeyManager) Arrays.stream(kmf.getKeyManagers()).filter(X509KeyManager.class::isInstance).findFirst().orElseThrow(() -> new IllegalStateException("The KeyManagerFactory does not expose a X509KeyManager")); + } + + private static final class FixClientAliasX509KeyManagerWrapper implements X509KeyManager { + private final String clientAlias; + private final X509KeyManager delegate; + + FixClientAliasX509KeyManagerWrapper(String clientAlias, X509KeyManager delegate) { + this.clientAlias = clientAlias; + this.delegate = delegate; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return clientAlias; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + @Override + public String[] getClientAliases(String s, Principal[] principals) { + return delegate.getClientAliases(s, principals); + } + + @Override + public String[] getServerAliases(String s, Principal[] principals) { + return delegate.getServerAliases(s, principals); + } + + @Override + public String chooseServerAlias(String s, Principal[] principals, Socket socket) { + return delegate.chooseServerAlias(s, principals, socket); + } + + @Override + public PrivateKey getPrivateKey(String s) { + return delegate.getPrivateKey(s); + } + } + +} diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/impl/OsgiManagedJavaHttpClientImpl.java b/bundle/src/main/java/com/adobe/acs/commons/http/impl/OsgiManagedJavaHttpClientImpl.java new file mode 100644 index 0000000000..c87cd77c4c --- /dev/null +++ b/bundle/src/main/java/com/adobe/acs/commons/http/impl/OsgiManagedJavaHttpClientImpl.java @@ -0,0 +1,227 @@ +/* + * ACS AEM Commons + * + * Copyright (C) 2024 Konrad Windszus + * + * Licensed 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. + */ +package com.adobe.acs.commons.http.impl; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.Authenticator; +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.PasswordAuthentication; +import java.net.ProxySelector; +import java.net.URI; +import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.time.Duration; +import java.util.function.Consumer; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; + +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.AttributeType; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.adobe.acs.commons.http.OsgiManagedJavaHttpClient; + +@Component +@Designate(factory = true, ocd = OsgiManagedJavaHttpClientImpl.Config.class) +public class OsgiManagedJavaHttpClientImpl implements OsgiManagedJavaHttpClient { + + @ObjectClassDefinition( + name="ACS AEM Commons - OSGi Managed Java Http Clients", + description = "Allows to manage multiple Java clients with basic settings" + ) + @interface Config { + @AttributeDefinition(name = "Client Name", description = "Unique name of this client (used to reference a particular config in code)") + String name(); + + @AttributeDefinition(name = "Base URL", description = "The absolute base URL including protocol and optionally the port. Considered for relative URLs.") + String baseUrl(); + + @AttributeDefinition(name = "Disable certificate path check", description = "If selected it will disable certificate path check for the TLS connection, i.e. the certificate doesn't need to be issued by a trusted CA (only relevant for protocol HTTPS).") + boolean disableCertificatePathCheck() default false; + + @AttributeDefinition(name = "Use trust store from AEM", description = "If selected it will use the global trust store from AEM in addition to the standard JRE one, otherwise only the default one from the JRE is used (only relevant if disableCertificatePathCheck is not set to true).") + boolean useAemTrustStore() default false; + + @AttributeDefinition(name = "User name", description = "User name for requests (using basic authentication)", required=false) + String userName(); + + @AttributeDefinition(name = "Password", description = "Password for requests (using basic authentication)", type = AttributeType.PASSWORD, required=false) + String password() default ""; + + @AttributeDefinition(name = "User ID and Alias For Client Certificate", description = "User ID and alias to use for client certificate authentication. The certificate is extracted from the given user's keystore leveraging the given alias. Must have the format :") + String userIdAndAliasForClientCertificate(); + + @AttributeDefinition(name = "Request Timeout", description = "Request timeout in milliseconds") + int requestTimeout() default 30000; + + @AttributeDefinition(name = "Connect Timeout", description = "Connect timeout in milliseconds") + int connectTimeout() default 30000; + + // Internal Name hint for web console. + String webconsole_configurationFactory_nameHint() default "Name: {name}"; + } + + private static final Logger LOG = LoggerFactory.getLogger(OsgiManagedJavaHttpClientImpl.class); + + private final Config config; + private final HttpClient.Builder builder; + private final URI baseUrl; + private HttpClient client; + + @Activate + public OsgiManagedJavaHttpClientImpl(Config config, @Reference ProxySelector proxySelector, + @Reference Authenticator proxyAuthenticator, @Reference AemKeyStoreFactory aemKeyStoreFactory) throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException, FileNotFoundException, CertificateException, IOException, org.apache.sling.api.resource.LoginException, UnrecoverableKeyException { + this.config = config; + this.builder = HttpClient.newBuilder(); + this.builder.proxy(proxySelector); + this.builder.authenticator(new Authenticator() { + @Override + public PasswordAuthentication requestPasswordAuthenticationInstance(String host, InetAddress addr, int port, String protocol, + String prompt, String scheme, URL url, RequestorType reqType) { + if (reqType == RequestorType.PROXY) { + return proxyAuthenticator.requestPasswordAuthenticationInstance(host, addr, port, protocol, prompt, scheme, url, reqType); + } else { + return super.requestPasswordAuthenticationInstance(host, addr, port, protocol, prompt, scheme, url, reqType); + } + } + + @Override + protected PasswordAuthentication getPasswordAuthentication() { + + // basic authentication credentials + if (config.userName() != null && !config.userName().isEmpty()) { + int expectedPort = baseUrl.getPort(); + if (expectedPort <= 0) { + try { + expectedPort = baseUrl.toURL().getDefaultPort(); + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Given baseUrl " + baseUrl + " is no valid URL", e); + } + } + if (getRequestingHost().equalsIgnoreCase(baseUrl.getHost()) && getRequestingPort() == expectedPort) { + return new PasswordAuthentication(config.userName(), config.password().toCharArray()); + } else { + LOG.warn("Don't use credentials from configuration as request targets another host {} and/or port {}", getRequestingHost(), getRequestingPort()); + } + } + return null; + } + }); + final SSLContext sslContext = SSLContext.getInstance("TLS"); + // configure trust manager + final TrustManager trustManager; + if (config.disableCertificatePathCheck()) { + trustManager = TrustManagerUtils.createAlwaysTrusted(); + } else if (config.useAemTrustStore()) { + trustManager = TrustManagerUtils.createComposition(aemKeyStoreFactory.getTrustManager()); + } else { + trustManager = null; + } + final KeyManager keyManager; + // configure key manager (for client cert authentication) + if (config.userIdAndAliasForClientCertificate() != null && !config.userIdAndAliasForClientCertificate().isBlank()) { + String[] parts = config.userIdAndAliasForClientCertificate().split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Configuration parameter 'userIdAndAliasForClientCertificate' must have one separator ':' but is " + config.userIdAndAliasForClientCertificate()); + } + String userId = parts[0]; + String alias = parts[1]; + KeyStore keyStore = aemKeyStoreFactory.getKeyStore(userId); + + keyManager = KeyManagerUtils.createSingleClientSideCertificateKeyManager(keyStore, aemKeyStoreFactory.getKeyStorePassword(userId), alias); + } else { + keyManager = null; + } + TrustManager[] tms = trustManager != null ? new TrustManager[]{ trustManager } : null; + KeyManager[] kms = keyManager != null ? new KeyManager[]{ keyManager } : null; + sslContext.init(kms, tms, null); + this.builder.sslContext(sslContext); + this.builder.connectTimeout(Duration.ofMillis(config.connectTimeout())); + baseUrl = URI.create(config.baseUrl()); + } + + @Deactivate() + public void deactivate() { + if (client != null) { + try { + // call httpClient.close() with JRE21 + Method method = HttpClient.class.getMethod("close"); + method.invoke(client); + } catch (NoSuchMethodException e) { + LOG.debug("Couldn't found close() method on HTTP Client, probably JRE < 21", e); + } catch (SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + LOG.warn("Could not close HTTP client", e); + } + } + } + + @Override + public HttpClient getClient() { + return getClient(null); + } + + @Override + public HttpClient getClient(Consumer builderCustomizer) { + if (builderCustomizer != null) { + if (client == null) { + builderCustomizer.accept(builder); + client = builder.build(); + } else { + throw new IllegalStateException("The underlying HTTP client has already been created through a call of getClient(...) and can no longer be customized"); + } + } + return client; + } + + @Override + public HttpRequest createRequest(URI uri) { + return createRequest(uri, null); + } + + @Override + public HttpRequest createRequest(URI uri, Consumer requestBuilderCustomizer) { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(baseUrl.resolve(uri)) // combine with base + .timeout(Duration.ofMillis(config.requestTimeout())); + if (requestBuilderCustomizer != null) { + requestBuilderCustomizer.accept(requestBuilder); + } + return requestBuilder.build(); + } + +} diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxyAuthenticatorImpl.java b/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxyAuthenticatorImpl.java new file mode 100644 index 0000000000..63ed158b32 --- /dev/null +++ b/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxyAuthenticatorImpl.java @@ -0,0 +1,53 @@ +/* + * ACS AEM Commons + * + * Copyright (C) 2024 Konrad Windszus + * + * Licensed 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. + */ +package com.adobe.acs.commons.http.impl; + +import java.net.Authenticator; +import java.net.PasswordAuthentication; +import java.util.Optional; + +import org.apache.http.osgi.services.ProxyConfiguration; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * This service implements an {@link Authenticator} for proxies configured + * via existing {@link ProxyConfiguration} services from Apache HTTP Client, instead of providing a dedicated configuration. + */ +@Component(service = Authenticator.class, + property = {"source=Apache Http Components Proxy Configuration"}) +public class ProxyAuthenticatorImpl extends Authenticator { + + private final ProxyConfigurationSelector proxyConfigurationSelector; + + @Activate() + public ProxyAuthenticatorImpl(@Reference ProxyConfigurationSelector proxyConfigurationSelector) { + this.proxyConfigurationSelector = proxyConfigurationSelector; + } + + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if (getRequestorType() != RequestorType.PROXY) { + Optional configuration = proxyConfigurationSelector.findConfigurationForProxy(getRequestingHost(), getRequestingPort()); + return configuration.map(c-> new PasswordAuthentication(c.getUsername(), c.getPassword().toCharArray())).orElse(null); + } + return null; + } + +} diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxyConfigurationSelector.java b/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxyConfigurationSelector.java new file mode 100644 index 0000000000..905b7d84ba --- /dev/null +++ b/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxyConfigurationSelector.java @@ -0,0 +1,207 @@ +/* + * ACS AEM Commons + * + * Copyright (C) 2024 Konrad Windszus + * + * Licensed 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. + */ +package com.adobe.acs.commons.http.impl; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.http.osgi.services.ProxyConfiguration; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.FieldOption; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.osgi.service.component.annotations.ReferencePolicyOption; + +/** + * Encapsulates all {@link ProxyConfiguration}s registered implicitly via OSGi configuration admin. + * Implements logic similar to org.apache.http.osgi.impl.OSGiHttpRoutePlanner. + * to find a matching config. + */ +@Component(service = ProxyConfigurationSelector.class) +public class ProxyConfigurationSelector { + + @Reference(fieldOption = FieldOption.REPLACE, policyOption = ReferencePolicyOption.GREEDY, policy = ReferencePolicy.DYNAMIC) + volatile List configurations; + + List findApplicableConfiguration(URI uri) { + // similar logic as in https://github.com/apache/httpcomponents-client/blob/54900db4653d7f207477e6ee40135b88e9bcf832/httpclient-osgi/src/main/java/org/apache/http/osgi/impl/OSGiHttpRoutePlanner.java#L67 + // using first config, except if there is a config which excludes the host, then don't use any config at all + List configs = new LinkedList<>(); + for (final ProxyConfiguration proxyConfiguration : this.configurations) { + if (proxyConfiguration.isEnabled()) { + for (final String exception : proxyConfiguration.getProxyExceptions()) { + if (createMatcher(exception).matches(uri.getHost())) { + return Collections.emptyList(); + } + } + configs.add(proxyConfiguration); + } + } + return configs; + } + + Optional findConfigurationForProxy(String proxyHost, int proxyPort) { + return configurations.stream().filter(c -> proxyHost.equals(c.getHostname()) && proxyPort == c.getPort()).findFirst(); + } + + private static final String DOT = "."; + + /** + * The IP mask pattern against which hosts are matched. + */ + public static final Pattern IP_MASK_PATTERN = Pattern.compile("^([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])$"); + + private static HostMatcher createMatcher(final String name) { + final NetworkAddress na = NetworkAddress.parse(name); + if (na != null) { + return new IPAddressMatcher(na); + } + + if (name.startsWith(DOT)) { + return new DomainNameMatcher(name); + } + + return new HostNameMatcher(name); + } + + private interface HostMatcher { + + boolean matches(String host); + + } + + private static class HostNameMatcher implements HostMatcher { + + private final String hostName; + + HostNameMatcher(final String hostName) { + this.hostName = hostName; + } + + @Override + public boolean matches(final String host) { + return hostName.equalsIgnoreCase(host); + } + } + + private static class DomainNameMatcher implements HostMatcher { + + private final String domainName; + + DomainNameMatcher(final String domainName) { + this.domainName = domainName.toLowerCase(Locale.ROOT); + } + + @Override + public boolean matches(final String host) { + return host.toLowerCase(Locale.ROOT).endsWith(domainName); + } + } + + private static class IPAddressMatcher implements HostMatcher { + + private final NetworkAddress address; + + IPAddressMatcher(final NetworkAddress address) { + this.address = address; + } + + @Override + public boolean matches(final String host) { + final NetworkAddress hostAddress = NetworkAddress.parse(host); + return hostAddress != null && address.address == (hostAddress.address & address.mask); + } + + } + + public static class NetworkAddress { + + final int address; + + final int mask; + + static NetworkAddress parse(final String adrSpec) { + + if (null != adrSpec) { + final Matcher nameMatcher = IP_MASK_PATTERN.matcher(adrSpec); + if (nameMatcher.matches()) { + try { + final int i1 = toInt(nameMatcher.group(1), 255); + final int i2 = toInt(nameMatcher.group(2), 255); + final int i3 = toInt(nameMatcher.group(3), 255); + final int i4 = toInt(nameMatcher.group(4), 255); + final int ip = i1 << 24 | i2 << 16 | i3 << 8 | i4; + + int mask = toInt(nameMatcher.group(4), 32); + mask = (mask == 32) ? -1 : -1 - (-1 >>> mask); + + return new NetworkAddress(ip, mask); + } catch (final NumberFormatException nfe) { + // not expected after the pattern match ! + } + } + } + + return null; + } + + private static int toInt(final String value, final int max) { + if (value == null || value.isEmpty()) { + return max; + } + + int number = Integer.parseInt(value); + if (number > max) { + number = max; + } + return number; + } + + public InetAddress getInetAddress() { + byte[] byteAddress = new byte[] { + (byte)(address >>> 24), + (byte)(address >>> 16), + (byte)(address >>> 8), + (byte)address + }; + try { + return InetAddress.getByAddress(byteAddress); + } catch (UnknownHostException e) { + // this should never happen + throw new IllegalArgumentException("Wrong length of address", e); + } + } + + NetworkAddress(final int address, final int mask) { + this.address = address; + this.mask = mask; + } + + } +} diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxySelectorImpl.java b/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxySelectorImpl.java new file mode 100644 index 0000000000..04e5ac5e3f --- /dev/null +++ b/bundle/src/main/java/com/adobe/acs/commons/http/impl/ProxySelectorImpl.java @@ -0,0 +1,72 @@ +/* + * ACS AEM Commons + * + * Copyright (C) 2024 Konrad Windszus + * + * Licensed 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. + */ +package com.adobe.acs.commons.http.impl; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Proxy.Type; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.http.osgi.services.ProxyConfiguration; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +import com.adobe.acs.commons.http.impl.ProxyConfigurationSelector.NetworkAddress; + +/** + * This service is configured via existing {@link ProxyConfiguration} services from Apache HTTP Client, instead of providing a dedicated configuration. + * + */ +@Component(service = ProxySelector.class, + property = {"source=Apache Http Components Proxy Configuration"}) +public class ProxySelectorImpl extends ProxySelector { + + private final ProxyConfigurationSelector proxyConfigurationSelector; + + @Activate + public ProxySelectorImpl(@Reference ProxyConfigurationSelector proxyConfigurationSelector) { + this.proxyConfigurationSelector = proxyConfigurationSelector; + } + + @Override + public List select(URI uri) { + return proxyConfigurationSelector.findApplicableConfiguration(uri).stream().map(c -> new Proxy(Type.HTTP, getInetSocketAddress(c))).collect(Collectors.toList()); + } + + private InetSocketAddress getInetSocketAddress(ProxyConfiguration configuration) { + // TODO: Support IPv6, https://issues.apache.org/jira/browse/HTTPCLIENT-2320 + final NetworkAddress na =ProxyConfigurationSelector.NetworkAddress.parse(configuration.getHostname()); + if (na != null) { + return new InetSocketAddress(na.getInetAddress(), configuration.getPort()); + } else { + return new InetSocketAddress(configuration.getHostname(), configuration.getPort()); + } + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + // not considered for now + } + +} diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/impl/TrustManagerUtils.java b/bundle/src/main/java/com/adobe/acs/commons/http/impl/TrustManagerUtils.java new file mode 100644 index 0000000000..9fc8659d74 --- /dev/null +++ b/bundle/src/main/java/com/adobe/acs/commons/http/impl/TrustManagerUtils.java @@ -0,0 +1,111 @@ +/* + * ACS AEM Commons + * + * Copyright (C) 2024 Konrad Windszus + * + * Licensed 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. + */ +package com.adobe.acs.commons.http.impl; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import org.jetbrains.annotations.NotNull; + +public class TrustManagerUtils { + + private TrustManagerUtils() { + // no supposed to be instantiated + } + + /** + * + * @return a trust manager which accepts all certificates. + */ + static @NotNull X509TrustManager createAlwaysTrusted() { + return new X509TrustManager() { + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[]{}; + } + + @Override + public void checkClientTrusted(X509Certificate[] certs, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] certs, String authType) { + } + }; + } + + /** + * Creates a trust manager which combines the JRE default trust manager with the given one (the latter being checked first). + * @return the composite trust manager + * @throws KeyStoreException in case the JRE trust store cannot be read + * @throws NoSuchAlgorithmException in case no JRE trust manager can be found with the default algorithm + */ + static @NotNull X509TrustManager createComposition(@NotNull X509TrustManager trustManager) throws NoSuchAlgorithmException, KeyStoreException { + X509TrustManager jreTrustManager = getJreTrustManager(); + return createCompositeTrustManager(jreTrustManager, trustManager); + } + + private static @NotNull X509TrustManager getJreTrustManager() throws NoSuchAlgorithmException, KeyStoreException { + return findDefaultTrustManager(null); + } + + private static @NotNull X509TrustManager findDefaultTrustManager(KeyStore keyStore) + throws NoSuchAlgorithmException, KeyStoreException { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keyStore); // If keyStore is null, tmf will be initialized with the default trust store + return (X509TrustManager) Arrays.stream(tmf.getTrustManagers()).filter(X509TrustManager.class::isInstance).findFirst().orElseThrow(() -> new IllegalStateException("The TrustManagerFactory does not expose a X509TrustManager")); + } + + private static @NotNull X509TrustManager createCompositeTrustManager(@NotNull X509TrustManager jreTrustManager, + X509TrustManager customTrustManager) { + return new X509TrustManager() { + @Override + public X509Certificate[] getAcceptedIssuers() { + // If you're planning to use client-cert auth, + // merge results from "defaultTm" and "myTm". + return jreTrustManager.getAcceptedIssuers(); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + try { + customTrustManager.checkServerTrusted(chain, authType); + } catch (CertificateException e) { + // This will throw another CertificateException if this fails too. + jreTrustManager.checkServerTrusted(chain, authType); + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + // If you're planning to use client-cert auth, + // do the same as checking the server. + jreTrustManager.checkClientTrusted(chain, authType); + } + + }; + } + +} diff --git a/bundle/src/main/java/com/adobe/acs/commons/http/package-info.java b/bundle/src/main/java/com/adobe/acs/commons/http/package-info.java index e1fee75664..36792567c7 100644 --- a/bundle/src/main/java/com/adobe/acs/commons/http/package-info.java +++ b/bundle/src/main/java/com/adobe/acs/commons/http/package-info.java @@ -16,7 +16,7 @@ * limitations under the License. */ /** - * Http Injectors. + * HTTP related services. */ -@org.osgi.annotation.versioning.Version("2.1.0") +@org.osgi.annotation.versioning.Version("2.2.0") package com.adobe.acs.commons.http; \ No newline at end of file diff --git a/pom.xml b/pom.xml index 87e068fbc7..705401d53c 100644 --- a/pom.xml +++ b/pom.xml @@ -91,7 +91,7 @@ 0.8.7 5.10.0 - 8 + 11 1708638825