From 2cb7ec9432e0b2f76b947016dbca8067d7a18108 Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Tue, 26 Jun 2018 18:54:06 +0200 Subject: [PATCH] [KEYCLOAK-7703] HierarchicalPathBasedKeycloakConfigResolver for more fine/coarse grained Keycloak configuration in Karaf --- ...chicalPathBasedKeycloakConfigResolver.java | 89 ++++++++++ .../osgi/PathBasedKeycloakConfigResolver.java | 140 ++++++++++++--- ...alPathBasedKeycloakConfigResolverTest.java | 162 ++++++++++++++++++ 3 files changed, 365 insertions(+), 26 deletions(-) create mode 100644 adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolver.java create mode 100644 adapters/oidc/osgi-adapter/src/test/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolverTest.java diff --git a/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolver.java b/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolver.java new file mode 100644 index 0000000000..06d01215d9 --- /dev/null +++ b/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolver.java @@ -0,0 +1,89 @@ +/* + * 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. + */ +package org.keycloak.adapters.osgi; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.adapters.KeycloakConfigResolver; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.OIDCHttpFacade; + +/** + * This {@link KeycloakConfigResolver} tries to resolve most specific configuration for given URI path. If not found, + * parent path is checked up to top-level path. + */ +public class HierarchicalPathBasedKeycloakConfigResolver extends PathBasedKeycloakConfigResolver { + + protected static final Logger log = Logger.getLogger(HierarchicalPathBasedKeycloakConfigResolver.class); + + public HierarchicalPathBasedKeycloakConfigResolver() { + prepopulateCache(); + } + + @Override + public KeycloakDeployment resolve(OIDCHttpFacade.Request request) { + // we cached all available deployments initially and now we'll try to check them from + // most specific to most general + URI uri = URI.create(request.getURI()); + String path = uri.getPath(); + if (path != null) { + while (path.startsWith("/")) { + path = path.substring(1); + } + String[] segments = path.split("/"); + List paths = collectPaths(segments); + for (String pathFragment: paths) { + KeycloakDeployment cachedDeployment = super.getCachedDeployment(pathFragment); + if (cachedDeployment != null) { + return cachedDeployment; + } + } + } + + throw new IllegalStateException("Can't find Keycloak configuration related to URI path " + uri); + } + + /** + *

For segments like "a, b, c, d", returns:

+ * @param segments + * @return + */ + private List collectPaths(String[] segments) { + List result = new ArrayList<>(segments.length + 1); + for (int idx = segments.length; idx >= 0; idx--) { + StringBuilder sb = null; + for (int i = 0; i < idx; i++) { + if (sb == null) { + sb = new StringBuilder(); + } + sb.append("-").append(segments[i]); + } + result.add(sb == null ? "" : sb.toString().substring(1)); + } + return result; + } + +} diff --git a/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/PathBasedKeycloakConfigResolver.java b/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/PathBasedKeycloakConfigResolver.java index e280f419c1..a2eaba9271 100644 --- a/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/PathBasedKeycloakConfigResolver.java +++ b/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/PathBasedKeycloakConfigResolver.java @@ -16,12 +16,15 @@ */ package org.keycloak.adapters.osgi; +import org.jboss.logging.Logger; import org.keycloak.adapters.KeycloakConfigResolver; import org.keycloak.adapters.KeycloakDeployment; import org.keycloak.adapters.KeycloakDeploymentBuilder; import org.keycloak.adapters.OIDCHttpFacade; +import org.keycloak.adapters.spi.HttpFacade; import java.io.File; +import java.io.FileFilter; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.InputStream; @@ -31,10 +34,117 @@ import java.util.concurrent.ConcurrentHashMap; public class PathBasedKeycloakConfigResolver implements KeycloakConfigResolver { + protected static final Logger log = Logger.getLogger(PathBasedKeycloakConfigResolver.class); + private final Map cache = new ConcurrentHashMap(); + private File keycloakConfigLocation = null; + + public PathBasedKeycloakConfigResolver() { + String location = null; + String keycloakConfig = (String) System.getProperties().get("keycloak.config"); + if (keycloakConfig != null && !"".equals(keycloakConfig.trim())) { + location = keycloakConfig; + } else { + String karafEtc = (String) System.getProperties().get("karaf.etc"); + if (karafEtc != null && !"".equals(karafEtc.trim())) { + location = karafEtc; + } + } + if (location != null) { + File loc = new File(location); + if (loc.isDirectory()) { + keycloakConfigLocation = loc; + } + } + } + @Override public KeycloakDeployment resolve(OIDCHttpFacade.Request request) { + String webContext = getDeploymentKeyForURI(request); + + return getOrCreateDeployment(webContext); + } + + /** + * {@code pathFragment} is a key for {@link KeycloakDeployment deployments}. The key is used to construct + * a path relative to {@code keycloak.config} or {@code karaf.etc} system properties. + * For given key, {@code -keycloak.json} file is checked. + * @param pathFragment + * @return + */ + protected synchronized KeycloakDeployment getOrCreateDeployment(String pathFragment) { + KeycloakDeployment deployment = getCachedDeployment(pathFragment); + if (null == deployment) { + // not found on the simple cache, try to load it from the file system + if (keycloakConfigLocation == null) { + throw new IllegalStateException("Neither \"keycloak.config\" nor \"karaf.etc\" java properties are set." + + " Please set one of them."); + } + + File configuration = new File(keycloakConfigLocation, pathFragment + ("".equals(pathFragment) ? "" : "-") + + "keycloak.json"); + if (!cacheConfiguration(pathFragment, configuration)) { + throw new IllegalStateException("Not able to read the file " + configuration); + } + } + + return deployment; + } + + protected synchronized KeycloakDeployment getCachedDeployment(String pathFragment) { + return cache.get(pathFragment); + } + + /** + * If there's a need, we can pre populate the cache of deployments. + */ + protected void prepopulateCache() { + if (keycloakConfigLocation == null || !keycloakConfigLocation.isDirectory()) { + log.warn("Can't cache Keycloak configurations. No configuration storage is accessible." + + " Please set either \"keycloak.config\" or \"karaf.etc\" system properties"); + return; + } + + File[] configs = keycloakConfigLocation.listFiles(new FileFilter() { + @Override + public boolean accept(File pathname) { + return pathname.isFile() && pathname.getName().endsWith("keycloak.json"); + } + }); + if (configs != null) { + for (File config: configs) { + String pathFragment = null; + if ("keycloak.json".equals(config.getName())) { + pathFragment = ""; + } else if (config.getName().endsWith("-keycloak.json")) { + pathFragment = config.getName() + .substring(0, config.getName().length() - "-keycloak.json".length()); + } + cacheConfiguration(pathFragment, config); + } + } + } + + private boolean cacheConfiguration(String key, File config) { + try { + InputStream is = new FileInputStream(config); + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(is); + cache.put(key, deployment); + return true; + } catch (FileNotFoundException | RuntimeException e) { + log.warn("Can't cache " + config + ": " + e.getMessage(), e); + return false; + } + } + + /** + * Finds a context path from given {@link HttpFacade.Request}. For default context, first path segment + * is returned. + * @param request + * @return + */ + private String getDeploymentKeyForURI(HttpFacade.Request request) { String uri = request.getURI(); String relativePath = request.getRelativePath(); String webContext = null; @@ -48,7 +158,9 @@ public class PathBasedKeycloakConfigResolver implements KeycloakConfigResolver { } else { URI parsedURI = URI.create(uri); String path = parsedURI.getPath(); - path = path.substring(0, path.indexOf(relativePath)); + if (path.contains(relativePath)) { + path = path.substring(0, path.indexOf(relativePath)); + } while (path.startsWith("/")) { path = path.substring(1); } @@ -65,31 +177,7 @@ public class PathBasedKeycloakConfigResolver implements KeycloakConfigResolver { } } - KeycloakDeployment deployment = cache.get(webContext); - if (null == deployment) { - // not found on the simple cache, try to load it from the file system - String keycloakConfig = (String) System.getProperties().get("keycloak.config"); - if(keycloakConfig == null || "".equals(keycloakConfig.trim())){ - String karafEtc = (String) System.getProperties().get("karaf.etc"); - if(karafEtc == null || "".equals(karafEtc.trim())){ - throw new IllegalStateException("Neither \"keycloak.config\" nor \"karaf.etc\" java properties are set. Please set one of them."); - } - keycloakConfig = karafEtc; - } - - String absolutePath = keycloakConfig + File.separator + webContext + ("".equals(webContext) ? "" : "-") - + "keycloak.json"; - InputStream is = null; - try { - is = new FileInputStream(absolutePath); - } catch (FileNotFoundException e){ - throw new IllegalStateException("Not able to find the file " + absolutePath); - } - deployment = KeycloakDeploymentBuilder.build(is); - cache.put(webContext, deployment); - } - - return deployment; + return webContext; } } diff --git a/adapters/oidc/osgi-adapter/src/test/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolverTest.java b/adapters/oidc/osgi-adapter/src/test/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolverTest.java new file mode 100644 index 0000000000..aecbfc90b3 --- /dev/null +++ b/adapters/oidc/osgi-adapter/src/test/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolverTest.java @@ -0,0 +1,162 @@ +/* + * Copyright 2018 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. + */ +package org.keycloak.adapters.osgi; + +import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.OIDCHttpFacade; +import org.keycloak.adapters.spi.AuthenticationError; +import org.keycloak.adapters.spi.HttpFacade; +import org.keycloak.adapters.spi.LogoutError; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +public class HierarchicalPathBasedKeycloakConfigResolverTest { + + @Test + public void genericAndSpecificConfigurations() throws Exception { + HierarchicalPathBasedKeycloakConfigResolver resolver = new HierarchicalPathBasedKeycloakConfigResolver(); + populate(resolver, true); + + assertThat(resolver.resolve(new MockRequest("http://localhost/a/b/c/d/e?a=b")).getRealm(), equalTo("a-b-c-d-e")); + assertThat(resolver.resolve(new MockRequest("http://localhost/a/b/c/d/x?a=b")).getRealm(), equalTo("a-b-c-d")); + assertThat(resolver.resolve(new MockRequest("http://localhost/a/b/c/x/x?a=b")).getRealm(), equalTo("a-b-c")); + assertThat(resolver.resolve(new MockRequest("http://localhost/a/b/x/x/x?a=b")).getRealm(), equalTo("a-b")); + assertThat(resolver.resolve(new MockRequest("http://localhost/a/x/x/x/x?a=b")).getRealm(), equalTo("a")); + assertThat(resolver.resolve(new MockRequest("http://localhost/x/x/x/x/x?a=b")).getRealm(), equalTo("")); + + populate(resolver, false); + try { + resolver.resolve(new MockRequest("http://localhost/x/x/x/x/x?a=b")); + fail("Expected java.lang.IllegalStateException: Can't find Keycloak configuration ..."); + } catch (IllegalStateException expected) { + } + } + + @SuppressWarnings("unchecked") + private PathBasedKeycloakConfigResolver populate(PathBasedKeycloakConfigResolver resolver, boolean fallback) + throws Exception { + Field f = PathBasedKeycloakConfigResolver.class.getDeclaredField("cache"); + f.setAccessible(true); + Map cache = (Map) f.get(resolver); + cache.clear(); + cache.put("a-b-c-d-e", newKeycloakDeployment("a-b-c-d-e")); + cache.put("a-b-c-d", newKeycloakDeployment("a-b-c-d")); + cache.put("a-b-c", newKeycloakDeployment("a-b-c")); + cache.put("a-b", newKeycloakDeployment("a-b")); + cache.put("a", newKeycloakDeployment("a")); + if (fallback) { + cache.put("", newKeycloakDeployment("")); + } + + return resolver; + } + + private KeycloakDeployment newKeycloakDeployment(String realm) { + KeycloakDeployment deployment = new KeycloakDeployment(); + deployment.setRealm(realm); + + return deployment; + } + + private class MockRequest implements OIDCHttpFacade.Request { + + private String uri; + + public MockRequest(String uri) { + this.uri = uri; + } + + @Override + public String getMethod() { + return null; + } + + @Override + public String getURI() { + return this.uri; + } + + @Override + public String getRelativePath() { + return null; + } + + @Override + public boolean isSecure() { + return false; + } + + @Override + public String getFirstParam(String param) { + return null; + } + + @Override + public String getQueryParamValue(String param) { + return null; + } + + @Override + public HttpFacade.Cookie getCookie(String cookieName) { + return null; + } + + @Override + public String getHeader(String name) { + return null; + } + + @Override + public List getHeaders(String name) { + return null; + } + + @Override + public InputStream getInputStream() { + return null; + } + + @Override + public InputStream getInputStream(boolean buffered) { + return null; + } + + @Override + public String getRemoteAddr() { + return null; + } + + @Override + public void setError(AuthenticationError error) { + + } + + @Override + public void setError(LogoutError error) { + + } + } + +}