diff --git a/repository/src/main/config/alfresco-global.properties b/repository/src/main/config/alfresco-global.properties index ad180f0..bc21536 100644 --- a/repository/src/main/config/alfresco-global.properties +++ b/repository/src/main/config/alfresco-global.properties @@ -6,7 +6,7 @@ cache.${moduleId}.ssoToSessionCache.maxIdleSeconds=0 cache.${moduleId}.ssoToSessionCache.cluster.type=fully-distributed cache.${moduleId}.ssoToSessionCache.backup-count=1 cache.${moduleId}.ssoToSessionCache.eviction-policy=LRU -cache.${moduleId}.ssoToSessionCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.ssoToSessionCache.merge-policy=com.hazelcast.spi.merge.PutIfAbsentMergePolicy cache.${moduleId}.ssoToSessionCache.readBackupData=false # explicitly not clearable - should be cleared via Keycloak back-channel action cache.${moduleId}.ssoToSessionCache.clearable=false @@ -19,7 +19,7 @@ cache.${moduleId}.sessionToSsoCache.maxIdleSeconds=0 cache.${moduleId}.sessionToSsoCache.cluster.type=fully-distributed cache.${moduleId}.sessionToSsoCache.backup-count=1 cache.${moduleId}.sessionToSsoCache.eviction-policy=LRU -cache.${moduleId}.sessionToSsoCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.sessionToSsoCache.merge-policy=com.hazelcast.spi.merge.PutIfAbsentMergePolicy cache.${moduleId}.sessionToSsoCache.readBackupData=false # explicitly not clearable - should be cleared via Keycloak back-channel action cache.${moduleId}.sessionToSsoCache.clearable=false @@ -32,7 +32,7 @@ cache.${moduleId}.principalToSessionCache.maxIdleSeconds=0 cache.${moduleId}.principalToSessionCache.cluster.type=fully-distributed cache.${moduleId}.principalToSessionCache.backup-count=1 cache.${moduleId}.principalToSessionCache.eviction-policy=LRU -cache.${moduleId}.principalToSessionCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.principalToSessionCache.merge-policy=com.hazelcast.spi.merge.PutIfAbsentMergePolicy cache.${moduleId}.principalToSessionCache.readBackupData=false # explicitly not clearable - should be cleared via Keycloak back-channel action cache.${moduleId}.principalToSessionCache.clearable=false @@ -45,7 +45,7 @@ cache.${moduleId}.sessionToPrincipalCache.maxIdleSeconds=0 cache.${moduleId}.sessionToPrincipalCache.cluster.type=fully-distributed cache.${moduleId}.sessionToPrincipalCache.backup-count=1 cache.${moduleId}.sessionToPrincipalCache.eviction-policy=LRU -cache.${moduleId}.sessionToPrincipalCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.sessionToPrincipalCache.merge-policy=com.hazelcast.spi.merge.PutIfAbsentMergePolicy cache.${moduleId}.sessionToPrincipalCache.readBackupData=false # explicitly not clearable - should be cleared via Keycloak back-channel action cache.${moduleId}.sessionToPrincipalCache.clearable=false @@ -58,7 +58,7 @@ cache.${moduleId}.ticketTokenCache.maxIdleSeconds=0 cache.${moduleId}.ticketTokenCache.cluster.type=fully-distributed cache.${moduleId}.ticketTokenCache.backup-count=1 cache.${moduleId}.ticketTokenCache.eviction-policy=LRU -cache.${moduleId}.ticketTokenCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.ticketTokenCache.merge-policy=com.hazelcast.spi.merge.PutIfAbsentMergePolicy cache.${moduleId}.ticketTokenCache.readBackupData=false # dangerous to be cleared, as roles / claims can no longer be mapped # would always be better to just invalidate the tickets themselves diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenClient.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenClient.java index a06cc17..f3e0b75 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenClient.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenClient.java @@ -4,21 +4,20 @@ import com.fasterxml.jackson.core.JsonParseException; import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; +import java.util.LinkedList; import java.util.List; -import java.util.Map; import java.util.function.Consumer; import org.alfresco.util.ParameterCheck; +import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.NameValuePair; import org.apache.http.client.HttpClient; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; +import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicNameValuePair; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; @@ -38,6 +37,7 @@ import org.keycloak.util.JsonSerialization; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import de.acosix.alfresco.keycloak.repo.util.NameValueMapAdapter; import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder; /** @@ -284,20 +284,20 @@ public class AccessTokenClient final HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()) .path(ServiceUrlConstants.TOKEN_PATH).build(this.deployment.getRealm())); - final List formParams = new ArrayList<>(); + final List formParams = new LinkedList<>(); postParamProvider.accept(formParams); - Map formMap = new HashMap<>(); - for (NameValuePair pair : formParams) - formMap.put(pair.getName(), pair.getValue()); + final List
headers = new LinkedList<>(); ClientCredentialsProviderUtils.setClientCredentials( this.deployment.getAdapterConfig(), this.deployment.getClientAuthenticator(), - Collections.emptyMap(), - formMap); + new NameValueMapAdapter<>(headers, BasicHeader.class), + new NameValueMapAdapter<>(formParams, BasicNameValuePair.class)); + for (Header header : headers) + post.addHeader(header); final UrlEncodedFormEntity form = new UrlEncodedFormEntity(formParams, "UTF-8"); post.setEntity(form); diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/NameValueMapAdapter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/NameValueMapAdapter.java new file mode 100644 index 0000000..cb13219 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/NameValueMapAdapter.java @@ -0,0 +1,151 @@ +package de.acosix.alfresco.keycloak.repo.util; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.Set; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.apache.http.NameValuePair; + +public class NameValueMapAdapter implements Map { + + private final List pairs; + private final Class type; + + public NameValueMapAdapter(List pairs, Class type) { + this.pairs = pairs; + this.type = type; + } + + @Override + public void clear() { + this.pairs.clear(); + } + + @Override + public boolean containsKey(Object key) { + for (NameValuePair pair : this.pairs) + if (pair.getName().equals(key)) + return true; + return false; + } + + @Override + public boolean containsValue(Object value) { + for (NameValuePair pair : this.pairs) + if (pair.getValue().equals(value)) + return true; + return false; + } + + @Override + public Set> entrySet() { + Set> set = new HashSet>(); + for (NameValuePair pair : this.pairs) { + set.add(new Entry() { + @Override + public String getKey() { + return pair.getName(); + } + + @Override + public String getValue() { + return pair.getValue(); + } + + @Override + public String setValue(String value) { + throw new UnsupportedOperationException(); + } + }); + } + + return set; + } + + @Override + public String get(Object key) { + for (NameValuePair pair : this.pairs) + if (pair.getName().equals(key)) + return pair.getValue(); + return null; + } + + @Override + public boolean isEmpty() { + return this.pairs.isEmpty(); + } + + @Override + public Set keySet() { + Set set = new HashSet<>(); + for (NameValuePair pair : this.pairs) + set.add(pair.getName()); + return set; + } + + @Override + public String put(String key, String value) { + ListIterator i = (ListIterator) this.pairs.listIterator(); + while (i.hasNext()) { + NameValuePair pair = i.next(); + if (pair.getName().equals(key)) { + i.remove(); + i.add(this.newNameValuePair(key, value)); + return pair.getValue(); + } + } + + i.add(this.newNameValuePair(key, value)); + return null; + } + + @Override + public void putAll(Map m) { + for (Entry e : m.entrySet()) + this.put(e.getKey(), e.getValue()); + } + + @Override + public String remove(Object key) { + ListIterator i = (ListIterator) this.pairs.listIterator(); + while (i.hasNext()) { + NameValuePair pair = i.next(); + if (pair.getName().equals(key)) { + i.remove(); + return pair.getValue(); + } + } + + return null; + } + + @Override + public int size() { + return this.pairs.size(); + } + + @Override + public Collection values() { + List list = new ArrayList<>(this.pairs.size()); + for (NameValuePair pair : this.pairs) + list.add(pair.getValue()); + return list; + } + + private T newNameValuePair(String key, String value) { + try { + Constructor constructor = this.type.getConstructor(String.class, String.class); + return constructor.newInstance(key, value); + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new AlfrescoRuntimeException(e.getMessage(), e); + } + } + +} \ No newline at end of file diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/util/HttpClientBuilder.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/util/HttpClientBuilder.java new file mode 100644 index 0000000..cfb9697 --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/util/HttpClientBuilder.java @@ -0,0 +1,496 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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. + */ +/** + * Copied from Keycloak source: https://raw.githubusercontent.com/keycloak/keycloak/24.0.6/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/HttpClientBuilder.java + * + * The original is not extensible enough to configure the HttpClient with a + * custom HttpRoutePlanner. This copy adds that capability. + */ + +package de.acosix.alfresco.keycloak.share.util; + +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthSchemeProvider; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.Credentials; +import org.apache.http.client.CookieStore; +import org.apache.http.client.HttpClient; +import org.apache.http.client.config.AuthSchemes; +import org.apache.http.client.config.CookieSpecs; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.config.ConnectionConfig; +import org.apache.http.config.Registry; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.config.SocketConfig; +import org.apache.http.conn.HttpClientConnectionManager; +import org.apache.http.conn.routing.HttpRoutePlanner; +import org.apache.http.conn.socket.ConnectionSocketFactory; +import org.apache.http.conn.socket.PlainConnectionSocketFactory; +import org.apache.http.conn.ssl.AllowAllHostnameVerifier; +import org.apache.http.conn.ssl.BrowserCompatHostnameVerifier; +import org.apache.http.conn.ssl.SSLSocketFactory; +import org.apache.http.conn.ssl.StrictHostnameVerifier; +import org.apache.http.conn.ssl.X509HostnameVerifier; +import org.apache.http.cookie.Cookie; +import org.apache.http.cookie.CookieSpecProvider; +import org.apache.http.impl.auth.SPNegoSchemeFactory; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CookieSpecRegistries; +import org.apache.http.impl.conn.BasicHttpClientConnectionManager; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.impl.cookie.DefaultCookieSpecProvider; +import org.keycloak.adapters.SniSSLSocketFactory; +import org.keycloak.common.util.EnvUtil; +import org.keycloak.common.util.KeystoreUtil; +import org.keycloak.representations.adapters.config.AdapterHttpClientConfig; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.net.URI; +import java.security.KeyStore; +import java.security.Principal; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Abstraction for creating HttpClients. Allows SSL configuration. +* +* @author Bill Burke +* @version $Revision: 1 $ +*/ +public class HttpClientBuilder { + + public static enum HostnameVerificationPolicy { + /** + * Hostname verification is not done on the server's certificate + */ + ANY, + /** + * Allows wildcards in subdomain names i.e. *.foo.com + */ + WILDCARD, + /** + * CN must match hostname connecting to + */ + STRICT + } + + + /** + * @author Bill Burke + * @version $Revision: 1 $ + */ + private static class PassthroughTrustManager implements X509TrustManager { + public void checkClientTrusted(X509Certificate[] chain, + String authType) throws CertificateException { + } + + public void checkServerTrusted(X509Certificate[] chain, + String authType) throws CertificateException { + } + + public X509Certificate[] getAcceptedIssuers() { + return null; + } + } + + protected KeyStore truststore; + protected KeyStore clientKeyStore; + protected String clientPrivateKeyPassword; + protected boolean disableTrustManager; + protected boolean disableCookieCache = true; + protected HostnameVerificationPolicy policy = HostnameVerificationPolicy.WILDCARD; + protected SSLContext sslContext; + protected int connectionPoolSize = 100; + protected int maxPooledPerRoute = 0; + protected long connectionTTL = -1; + protected TimeUnit connectionTTLUnit = TimeUnit.MILLISECONDS; + protected HostnameVerifier verifier = null; + protected long socketTimeout = -1; + protected TimeUnit socketTimeoutUnits = TimeUnit.MILLISECONDS; + protected long establishConnectionTimeout = -1; + protected TimeUnit establishConnectionTimeoutUnits = TimeUnit.MILLISECONDS; + protected HttpHost proxyHost; + private SPNegoSchemeFactory spNegoSchemeFactory; + private boolean useSpNego; + private HttpRoutePlanner routePlanner; + + /** + * Socket inactivity timeout + * + * @param timeout + * @param unit + * @return + */ + public HttpClientBuilder socketTimeout(long timeout, TimeUnit unit) { + this.socketTimeout = timeout; + this.socketTimeoutUnits = unit; + return this; + } + + /** + * When trying to make an initial socket connection, what is the timeout? + * + * @param timeout + * @param unit + * @return + */ + public HttpClientBuilder establishConnectionTimeout(long timeout, TimeUnit unit) { + this.establishConnectionTimeout = timeout; + this.establishConnectionTimeoutUnits = unit; + return this; + } + + public HttpClientBuilder connectionTTL(long ttl, TimeUnit unit) { + this.connectionTTL = ttl; + this.connectionTTLUnit = unit; + return this; + } + + public HttpClientBuilder maxPooledPerRoute(int maxPooledPerRoute) { + this.maxPooledPerRoute = maxPooledPerRoute; + return this; + } + + public HttpClientBuilder connectionPoolSize(int connectionPoolSize) { + this.connectionPoolSize = connectionPoolSize; + return this; + } + + /** + * Disable trust management and hostname verification. NOTE this is a security + * hole, so only set this option if you cannot or do not want to verify the identity of the + * host you are communicating with. + */ + public HttpClientBuilder disableTrustManager() { + this.disableTrustManager = true; + return this; + } + + public HttpClientBuilder disableCookieCache(boolean disable) { + this.disableCookieCache = disable; + return this; + } + + /** + * SSL policy used to verify hostnames + * + * @param policy + * @return + */ + public HttpClientBuilder hostnameVerification(HostnameVerificationPolicy policy) { + this.policy = policy; + return this; + } + + + public HttpClientBuilder sslContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + public HttpClientBuilder trustStore(KeyStore truststore) { + this.truststore = truststore; + return this; + } + + public HttpClientBuilder keyStore(KeyStore keyStore, String password) { + this.clientKeyStore = keyStore; + this.clientPrivateKeyPassword = password; + return this; + } + + public HttpClientBuilder keyStore(KeyStore keyStore, char[] password) { + this.clientKeyStore = keyStore; + this.clientPrivateKeyPassword = new String(password); + return this; + } + + public HttpClientBuilder routePlanner(HttpRoutePlanner routePlanner) { + this.routePlanner = routePlanner; + return this; + } + + + static class VerifierWrapper implements X509HostnameVerifier { + protected HostnameVerifier verifier; + + VerifierWrapper(HostnameVerifier verifier) { + this.verifier = verifier; + } + + @Override + public void verify(String host, SSLSocket ssl) throws IOException { + if (!verifier.verify(host, ssl.getSession())) throw new SSLException("Hostname verification failure"); + } + + @Override + public void verify(String host, X509Certificate cert) throws SSLException { + throw new SSLException("This verification path not implemented"); + } + + @Override + public void verify(String host, String[] cns, String[] subjectAlts) throws SSLException { + throw new SSLException("This verification path not implemented"); + } + + @Override + public boolean verify(String s, SSLSession sslSession) { + return verifier.verify(s, sslSession); + } + } + + public HttpClientBuilder spNegoSchemeFactory(SPNegoSchemeFactory spnegoSchemeFactory) { + this.spNegoSchemeFactory = spnegoSchemeFactory; + return this; + } + + public HttpClientBuilder useSPNego(boolean useSpnego) { + this.useSpNego = useSpnego; + return this; + } + + public HttpClient build() { + X509HostnameVerifier verifier = null; + if (this.verifier != null) verifier = new VerifierWrapper(this.verifier); + else { + switch (policy) { + case ANY: + verifier = new AllowAllHostnameVerifier(); + break; + case WILDCARD: + verifier = new BrowserCompatHostnameVerifier(); + break; + case STRICT: + verifier = new StrictHostnameVerifier(); + break; + } + } + try { + ConnectionSocketFactory sslsf; + SSLContext theContext = sslContext; + if (disableTrustManager) { + theContext = SSLContext.getInstance("SSL"); + theContext.init(null, new TrustManager[]{new PassthroughTrustManager()}, + new SecureRandom()); + verifier = new AllowAllHostnameVerifier(); + sslsf = new SniSSLSocketFactory(theContext, verifier); + } else if (theContext != null) { + sslsf = new SniSSLSocketFactory(theContext, verifier); + } else if (clientKeyStore != null || truststore != null) { + sslsf = new SniSSLSocketFactory(SSLSocketFactory.TLS, clientKeyStore, clientPrivateKeyPassword, truststore, null, verifier); + } else { + final SSLContext tlsContext = SSLContext.getInstance(SSLSocketFactory.TLS); + tlsContext.init(null, null, null); + sslsf = new SniSSLSocketFactory(tlsContext, verifier); + } + + RegistryBuilder sf = RegistryBuilder.create(); + + sf.register("http", PlainConnectionSocketFactory.getSocketFactory()); + sf.register("https", sslsf); + + HttpClientConnectionManager cm; + + if (connectionPoolSize > 0) { + PoolingHttpClientConnectionManager tcm = new PoolingHttpClientConnectionManager(sf.build(), null, null, null, connectionTTL, connectionTTLUnit); + tcm.setMaxTotal(connectionPoolSize); + if (maxPooledPerRoute == 0) maxPooledPerRoute = connectionPoolSize; + tcm.setDefaultMaxPerRoute(maxPooledPerRoute); + cm = tcm; + + } else { + cm = new BasicHttpClientConnectionManager(sf.build()); + } + + SocketConfig.Builder socketConfig = SocketConfig.copy(SocketConfig.DEFAULT); + ConnectionConfig.Builder connConfig = ConnectionConfig.copy(ConnectionConfig.DEFAULT); + RequestConfig.Builder requestConfig = RequestConfig.copy(RequestConfig.DEFAULT); + + if (proxyHost != null) { + requestConfig.setProxy(new HttpHost(proxyHost)); + } + + if (socketTimeout > -1) { + requestConfig.setSocketTimeout((int) socketTimeoutUnits.toMillis(socketTimeout)); + + } + if (establishConnectionTimeout > -1) { + requestConfig.setConnectTimeout((int) establishConnectionTimeoutUnits.toMillis(establishConnectionTimeout)); + } + + Registry cookieSpecs = CookieSpecRegistries.createDefaultBuilder() + .register(CookieSpecs.DEFAULT, new DefaultCookieSpecProvider()).build(); + + if (useSpNego) { + requestConfig.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.SPNEGO)); + } + + org.apache.http.impl.client.HttpClientBuilder clientBuilder = org.apache.http.impl.client.HttpClientBuilder.create() + .setDefaultSocketConfig(socketConfig.build()) + .setDefaultConnectionConfig(connConfig.build()) + .setDefaultRequestConfig(requestConfig.build()) + .setDefaultCookieSpecRegistry(cookieSpecs) + .setConnectionManager(cm); + + if (spNegoSchemeFactory != null) { + RegistryBuilder authSchemes = RegistryBuilder.create(); + + authSchemes.register(AuthSchemes.SPNEGO, spNegoSchemeFactory); + + clientBuilder.setDefaultAuthSchemeRegistry(authSchemes.build()); + } + + if (useSpNego) { + Credentials fake = new Credentials() { + + @Override + public String getPassword() { + return null; + } + + @Override + public Principal getUserPrincipal() { + return null; + } + + }; + + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials(AuthScope.ANY, fake); + clientBuilder.setDefaultCredentialsProvider(credentialsProvider); + } + + if (disableCookieCache) { + clientBuilder.setDefaultCookieStore(new CookieStore() { + @Override + public void addCookie(Cookie cookie) { + //To change body of implemented methods use File | Settings | File Templates. + } + + @Override + public List getCookies() { + return Collections.emptyList(); + } + + @Override + public boolean clearExpired(Date date) { + return false; //To change body of implemented methods use File | Settings | File Templates. + } + + @Override + public void clear() { + //To change body of implemented methods use File | Settings | File Templates. + } + }); + + } + + if (this.routePlanner != null) { + clientBuilder.setRoutePlanner(this.routePlanner); + } + + return clientBuilder.build(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public HttpClient build(AdapterHttpClientConfig adapterConfig) { + disableCookieCache(true); // disable cookie cache as we don't want sticky sessions for load balancing + + String truststorePath = adapterConfig.getTruststore(); + if (truststorePath != null) { + truststorePath = EnvUtil.replace(truststorePath); + String truststorePassword = adapterConfig.getTruststorePassword(); + try { + this.truststore = KeystoreUtil.loadKeyStore(truststorePath, truststorePassword); + } catch (Exception e) { + throw new RuntimeException("Failed to load truststore", e); + } + } + String clientKeystore = adapterConfig.getClientKeystore(); + if (clientKeystore != null) { + clientKeystore = EnvUtil.replace(clientKeystore); + String clientKeystorePassword = adapterConfig.getClientKeystorePassword(); + try { + KeyStore clientCertKeystore = KeystoreUtil.loadKeyStore(clientKeystore, clientKeystorePassword); + keyStore(clientCertKeystore, clientKeystorePassword); + } catch (Exception e) { + throw new RuntimeException("Failed to load keystore", e); + } + } + + HttpClientBuilder.HostnameVerificationPolicy policy = HttpClientBuilder.HostnameVerificationPolicy.WILDCARD; + if (adapterConfig.isAllowAnyHostname()) + policy = HttpClientBuilder.HostnameVerificationPolicy.ANY; + connectionPoolSize(adapterConfig.getConnectionPoolSize()); + hostnameVerification(policy); + if (adapterConfig.isDisableTrustManager()) { + disableTrustManager(); + } else { + trustStore(truststore); + } + + configureProxyForAuthServerIfProvided(adapterConfig); + + if (socketTimeout == -1 && adapterConfig.getSocketTimeout() > 0) { + socketTimeout(adapterConfig.getSocketTimeout(), TimeUnit.MILLISECONDS); + } + + if (establishConnectionTimeout == -1 && adapterConfig.getConnectionTimeout() > 0) { + establishConnectionTimeout(adapterConfig.getConnectionTimeout(), TimeUnit.MILLISECONDS); + } + + if (connectionTTL == -1 && adapterConfig.getConnectionTTL() > 0) { + connectionTTL(adapterConfig.getConnectionTTL(), TimeUnit.MILLISECONDS); + } + + return build(); + } + + /** + * Configures a the proxy to use for auth-server requests if provided. + *

+ * If the given {@link AdapterHttpClientConfig} contains the attribute {@code proxy-url} we use the + * given URL as a proxy server, otherwise the proxy configuration is ignored. + *

+ * + * @param adapterConfig + */ + private void configureProxyForAuthServerIfProvided(AdapterHttpClientConfig adapterConfig) { + + if (adapterConfig == null || adapterConfig.getProxyUrl() == null || adapterConfig.getProxyUrl().trim().isEmpty()) { + return; + } + + URI uri = URI.create(adapterConfig.getProxyUrl()); + this.proxyHost = new HttpHost(uri.getHost(), uri.getPort(), uri.getScheme()); + } +} \ No newline at end of file diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/util/NameValueMapAdapter.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/util/NameValueMapAdapter.java new file mode 100644 index 0000000..fcf494b --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/util/NameValueMapAdapter.java @@ -0,0 +1,151 @@ +package de.acosix.alfresco.keycloak.share.util; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.Set; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.apache.http.NameValuePair; + +public class NameValueMapAdapter implements Map { + + private final List pairs; + private final Class type; + + public NameValueMapAdapter(List pairs, Class type) { + this.pairs = pairs; + this.type = type; + } + + @Override + public void clear() { + this.pairs.clear(); + } + + @Override + public boolean containsKey(Object key) { + for (NameValuePair pair : this.pairs) + if (pair.getName().equals(key)) + return true; + return false; + } + + @Override + public boolean containsValue(Object value) { + for (NameValuePair pair : this.pairs) + if (pair.getValue().equals(value)) + return true; + return false; + } + + @Override + public Set> entrySet() { + Set> set = new HashSet>(); + for (NameValuePair pair : this.pairs) { + set.add(new Entry() { + @Override + public String getKey() { + return pair.getName(); + } + + @Override + public String getValue() { + return pair.getValue(); + } + + @Override + public String setValue(String value) { + throw new UnsupportedOperationException(); + } + }); + } + + return set; + } + + @Override + public String get(Object key) { + for (NameValuePair pair : this.pairs) + if (pair.getName().equals(key)) + return pair.getValue(); + return null; + } + + @Override + public boolean isEmpty() { + return this.pairs.isEmpty(); + } + + @Override + public Set keySet() { + Set set = new HashSet<>(); + for (NameValuePair pair : this.pairs) + set.add(pair.getName()); + return set; + } + + @Override + public String put(String key, String value) { + ListIterator i = (ListIterator) this.pairs.listIterator(); + while (i.hasNext()) { + NameValuePair pair = i.next(); + if (pair.getName().equals(key)) { + i.remove(); + i.add(this.newNameValuePair(key, value)); + return pair.getValue(); + } + } + + i.add(this.newNameValuePair(key, value)); + return null; + } + + @Override + public void putAll(Map m) { + for (Entry e : m.entrySet()) + this.put(e.getKey(), e.getValue()); + } + + @Override + public String remove(Object key) { + ListIterator i = (ListIterator) this.pairs.listIterator(); + while (i.hasNext()) { + NameValuePair pair = i.next(); + if (pair.getName().equals(key)) { + i.remove(); + return pair.getValue(); + } + } + + return null; + } + + @Override + public int size() { + return this.pairs.size(); + } + + @Override + public Collection values() { + List list = new ArrayList<>(this.pairs.size()); + for (NameValuePair pair : this.pairs) + list.add(pair.getValue()); + return list; + } + + private T newNameValuePair(String key, String value) { + try { + Constructor constructor = this.type.getConstructor(String.class, String.class); + return constructor.newInstance(key, value); + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new AlfrescoRuntimeException(e.getMessage(), e); + } + } + +} \ No newline at end of file diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java index 7dbde98..03e796e 100644 --- a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java @@ -23,13 +23,16 @@ import java.io.InputStream; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; +import java.util.LinkedList; import java.util.List; import java.util.Locale; -import java.util.Map; +import java.util.concurrent.Callable; import java.util.function.BiFunction; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,8 +53,11 @@ import org.alfresco.error.AlfrescoRuntimeException; import org.alfresco.util.EqualsHelper; import org.alfresco.util.PropertyCheck; import org.alfresco.web.site.servlet.SSOAuthenticationFilter; +import org.apache.http.Header; import org.apache.http.HttpEntity; +import org.apache.http.HttpException; import org.apache.http.HttpHost; +import org.apache.http.HttpRequest; import org.apache.http.HttpResponse; import org.apache.http.NameValuePair; import org.apache.http.client.HttpClient; @@ -60,8 +66,11 @@ import org.apache.http.client.methods.HttpPost; import org.apache.http.conn.params.ConnRoutePNames; import org.apache.http.conn.params.ConnRouteParams; import org.apache.http.conn.routing.HttpRoute; +import org.apache.http.conn.routing.HttpRoutePlanner; +import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicNameValuePair; import org.apache.http.params.HttpParams; +import org.apache.http.protocol.HttpContext; import org.apache.http.util.EntityUtils; import org.keycloak.KeycloakSecurityContext; import org.keycloak.OAuth2Constants; @@ -127,6 +136,8 @@ import de.acosix.alfresco.keycloak.share.config.KeycloakAdapterConfigElement; import de.acosix.alfresco.keycloak.share.config.KeycloakAuthenticationConfigElement; import de.acosix.alfresco.keycloak.share.config.KeycloakConfigConstants; import de.acosix.alfresco.keycloak.share.remote.AccessTokenAwareSlingshotAlfrescoConnector; +import de.acosix.alfresco.keycloak.share.util.HttpClientBuilder; +import de.acosix.alfresco.keycloak.share.util.NameValueMapAdapter; import de.acosix.alfresco.keycloak.share.util.RefreshableAccessTokenHolder; /** @@ -517,13 +528,25 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I { final ExtendedAdapterConfig adapterConfiguration = keycloakAdapterConfig.buildAdapterConfiguration(); this.keycloakDeployment = KeycloakDeploymentBuilder.build(adapterConfiguration); - final String forcedRouteUrl = adapterConfiguration.getForcedRouteUrl(); - if (forcedRouteUrl != null && !forcedRouteUrl.isEmpty()) - { - final HttpClient client = this.keycloakDeployment.getClient(); - this.configureForcedRouteIfNecessary(client, forcedRouteUrl); - this.keycloakDeployment.setClient(client); - } + + // we need to recreate the HttpClient to configure the forced route URL + this.keycloakDeployment.setClient(new Callable() { + private HttpClient client; + @Override + public HttpClient call() throws Exception { + if (client == null) { + synchronized (this) { + if (client == null) { + client = new HttpClientBuilder() + .routePlanner(createForcedRoutePlanner(adapterConfiguration)) + .build(adapterConfiguration); + } + } + } + return client; + } + }); + this.deploymentContext = new AdapterDeploymentContext(this.keycloakDeployment); } @@ -1728,7 +1751,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I final HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(this.keycloakDeployment.getAuthServerBaseUrl()) .path(ServiceUrlConstants.TOKEN_PATH).build(this.keycloakDeployment.getRealm())); - final List formParams = new ArrayList<>(); + final List formParams = new LinkedList<>(); formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)); formParams.add(new BasicNameValuePair(OAuth2Constants.AUDIENCE, alfrescoResourceName)); @@ -1751,16 +1774,16 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I "Either an active security context or access token should be present in the session, or previous validations have caught their non-existence and prevented this operation form being called"); } - Map formMap = new HashMap<>(); - for (NameValuePair formParam : formParams) - formMap.put(formParam.getName(), formParam.getValue()); + final List
headers = new LinkedList<>(); ClientCredentialsProviderUtils.setClientCredentials( this.keycloakDeployment.getAdapterConfig(), this.keycloakDeployment.getClientAuthenticator(), - Collections.emptyMap(), - formMap); + new NameValueMapAdapter<>(headers, BasicHeader.class), + new NameValueMapAdapter<>(formParams, BasicNameValuePair.class)); + for (Header header : headers) + post.addHeader(header); final UrlEncodedFormEntity form = new UrlEncodedFormEntity(formParams, "UTF-8"); post.setEntity(form); @@ -1877,4 +1900,47 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I } params.setParameter(ConnRoutePNames.FORCED_ROUTE, route); } + + protected HttpRoute createRoute(ExtendedAdapterConfig adapterConfig, HttpHost routeHost) throws UnknownHostException, MalformedURLException { + boolean secure = "https".equalsIgnoreCase(routeHost.getSchemeName()); + + if (adapterConfig.getProxyUrl() != null) { + // useful in parsing the URL for just what is needed for HttpHost + URL proxyUrl = new URL(adapterConfig.getProxyUrl()); + HttpHost proxyHost = new HttpHost(proxyUrl.getHost(), proxyUrl.getPort(), proxyUrl.getProtocol()); + return new HttpRoute(routeHost, InetAddress.getLocalHost(), proxyHost, secure); + } else { + return new HttpRoute(routeHost, InetAddress.getLocalHost(), secure); + } + } + + protected HttpRoute createForcedRoute(ExtendedAdapterConfig adapterConfig) throws UnknownHostException, MalformedURLException { + // useful in parsing the URL for just what is needed for HttpHost + URL forcedRouteUrl = new URL(adapterConfig.getForcedRouteUrl()); + HttpHost forcedRouteHost = new HttpHost(forcedRouteUrl.getHost(), forcedRouteUrl.getPort(), forcedRouteUrl.getProtocol()); + return this.createRoute(adapterConfig, forcedRouteHost); + } + + protected HttpRoutePlanner createForcedRoutePlanner(ExtendedAdapterConfig adapterConfig) throws MalformedURLException { + URL authServerUrl = new URL(adapterConfig.getAuthServerUrl()); + final HttpHost authServerHost = new HttpHost(authServerUrl.getHost(), authServerUrl.getPort(), authServerUrl.getProtocol()); + + return new HttpRoutePlanner() { + @Override + public HttpRoute determineRoute(HttpHost target, HttpRequest request, HttpContext context) throws HttpException { + try { + if (authServerHost.equals(target)) { + LOGGER.trace("Rerouting to forced route"); + HttpRoute route = createForcedRoute(adapterConfig); + LOGGER.trace("Rerouting to forced route: {}", route); + return route; + } else { + return createRoute(adapterConfig, target); + } + } catch (IOException ie) { + throw new HttpException(ie.getMessage(), ie); + } + } + }; + } }