diff --git a/authz/policy-enforcer/pom.xml b/authz/policy-enforcer/pom.xml index f1706319b6..5c00fa4468 100755 --- a/authz/policy-enforcer/pom.xml +++ b/authz/policy-enforcer/pom.xml @@ -30,35 +30,29 @@ Keycloak Authz: Policy Enforcer jar + + 6.0.0 + 2.0.0.Final + + - - org.jboss.logging - jboss-logging - org.keycloak keycloak-authz-client + + - com.fasterxml.jackson.core - jackson-core + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + true - com.fasterxml.jackson.core - jackson-databind - - - com.fasterxml.jackson.core - jackson-annotations - - - junit - junit - test - - - org.apache.httpcomponents - httpclient + org.wildfly.security + wildfly-elytron-http-oidc + ${wildfly-elytron.version} + true diff --git a/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/PolicyEnforcer.java b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/PolicyEnforcer.java index 3c25e8d217..eb7754759b 100644 --- a/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/PolicyEnforcer.java +++ b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/PolicyEnforcer.java @@ -82,7 +82,25 @@ public class PolicyEnforcer { protected PolicyEnforcer(Builder builder) { enforcerConfig = builder.getEnforcerConfig(); - authzClient = AuthzClient.create(builder.authzClientConfig); + Configuration authzClientConfig = builder.authzClientConfig; + + if (authzClientConfig.getRealm() == null) { + authzClientConfig.setRealm(enforcerConfig.getRealm()); + } + + if (authzClientConfig.getAuthServerUrl() == null) { + authzClientConfig.setAuthServerUrl(enforcerConfig.getAuthServerUrl()); + } + + if (authzClientConfig.getCredentials() == null || authzClientConfig.getCredentials().isEmpty()) { + authzClientConfig.setCredentials(enforcerConfig.getCredentials()); + } + + if (authzClientConfig.getResource() == null) { + authzClientConfig.setResource(enforcerConfig.getResource()); + } + + authzClient = AuthzClient.create(authzClientConfig); httpClient = authzClient.getConfiguration().getHttpClient(); pathMatcher = new PathConfigMatcher(builder.getEnforcerConfig(), authzClient); paths = pathMatcher.getPathConfig(); diff --git a/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/PolicyEnforcerFilter.java b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/PolicyEnforcerFilter.java new file mode 100644 index 0000000000..9ca6164490 --- /dev/null +++ b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/PolicyEnforcerFilter.java @@ -0,0 +1,110 @@ +package org.keycloak.adapters.authorization.integration.elytron; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletContextAttributeListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.jboss.logging.Logger; +import org.keycloak.AuthorizationContext; +import org.keycloak.adapters.authorization.PolicyEnforcer; +import org.keycloak.adapters.authorization.TokenPrincipal; +import org.keycloak.adapters.authorization.spi.ConfigurationResolver; +import org.keycloak.adapters.authorization.spi.HttpRequest; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; +import org.wildfly.security.http.oidc.OidcClientConfiguration; +import org.wildfly.security.http.oidc.OidcPrincipal; +import org.wildfly.security.http.oidc.RefreshableOidcSecurityContext; + +/** + * A {@link Filter} acting as a policy enforcer. This filter does not enforce access for anonymous subjects.

+ * + * For authenticated subjects, this filter delegates the access decision to the {@link PolicyEnforcer} and decide if + * the request should continue.

+ * + * If access is not granted, this filter aborts the request and relies on the {@link PolicyEnforcer} to properly + * respond to client. + * + * @author Pedro Igor + */ +public class PolicyEnforcerFilter implements Filter, ServletContextAttributeListener { + + private final Logger logger = Logger.getLogger(getClass()); + private final Map policyEnforcer; + private final ConfigurationResolver configResolver; + + public PolicyEnforcerFilter(ConfigurationResolver configResolver) { + this.configResolver = configResolver; + this.policyEnforcer = Collections.synchronizedMap(new HashMap<>()); + } + + @Override + public void init(FilterConfig filterConfig) { + // no-init + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpSession session = request.getSession(false); + + if (session == null) { + logger.debug("Anonymous request, continuing the filter chain"); + filterChain.doFilter(servletRequest, servletResponse); + return; + } + + RefreshableOidcSecurityContext securityContext = (RefreshableOidcSecurityContext) ((OidcPrincipal) request.getUserPrincipal()).getOidcSecurityContext(); + HttpServletResponse response = (HttpServletResponse) servletResponse; + String accessToken = securityContext.getTokenString(); + ServletHttpRequest httpRequest = new ServletHttpRequest(request, new TokenPrincipal() { + @Override + public String getRawToken() { + return accessToken; + } + }); + + PolicyEnforcer policyEnforcer = getOrCreatePolicyEnforcer(httpRequest, securityContext); + AuthorizationContext authzContext = policyEnforcer.enforce(httpRequest, new ServletHttpResponse(response)); + + request.setAttribute(AuthorizationContext.class.getName(), authzContext); + + if (authzContext.isGranted()) { + logger.debug("Request authorized, continuing the filter chain"); + filterChain.doFilter(servletRequest, servletResponse); + } else { + logger.debugf("Unauthorized request to path [%s], aborting the filter chain", request.getRequestURI()); + } + } + + private PolicyEnforcer getOrCreatePolicyEnforcer(HttpRequest request, RefreshableOidcSecurityContext securityContext) { + return policyEnforcer.computeIfAbsent(configResolver.resolve(request), new Function() { + @Override + public PolicyEnforcer apply(PolicyEnforcerConfig enforcerConfig) { + OidcClientConfiguration configuration = securityContext.getOidcClientConfiguration(); + String authServerUrl = configuration.getAuthServerBaseUrl(); + + return PolicyEnforcer.builder() + .authServerUrl(authServerUrl) + .realm(configuration.getRealm()) + .clientId(configuration.getClientId()) + .credentials(configuration.getResourceCredentials()) + .bearerOnly(false) + .enforcerConfig(enforcerConfig) + .httpClient(configuration.getClient()).build(); + } + }); + } +} diff --git a/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/PolicyEnforcerServletContextListener.java b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/PolicyEnforcerServletContextListener.java new file mode 100644 index 0000000000..496937966d --- /dev/null +++ b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/PolicyEnforcerServletContextListener.java @@ -0,0 +1,81 @@ +package org.keycloak.adapters.authorization.integration.elytron; + +import java.io.IOException; +import java.io.InputStream; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.ServiceLoader; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; +import jakarta.servlet.annotation.WebListener; +import org.jboss.logging.Logger; +import org.keycloak.adapters.authorization.spi.ConfigurationResolver; +import org.keycloak.adapters.authorization.spi.HttpRequest; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; +import org.keycloak.util.JsonSerialization; + +/** + * A {@link ServletContextListener} to programmatically configure the {@link ServletContext} in order to + * enable the policy enforcer.

+ * + * By default, the policy enforcer configuration is loaded from a file at {@code WEB-INF/policy-enforcer.json}.

+ * + * Applications can also dynamically resolve the configuration by implementing the {@link ConfigurationResolver} SPI. For that, + * make sure to create a {@link META-INF/services/org.keycloak.adapters.authorization.spi.ConfigurationResolver} to register + * the implementation. + * + * @author Pedro Igor + */ +@WebListener +public class PolicyEnforcerServletContextListener implements ServletContextListener { + + private final Logger logger = Logger.getLogger(getClass()); + + @Override + public void contextInitialized(ServletContextEvent sce) { + ServletContext servletContext = sce.getServletContext(); + Iterator configResolvers = ServiceLoader.load(ConfigurationResolver.class).iterator(); + ConfigurationResolver configResolver; + + if (configResolvers.hasNext()) { + configResolver = configResolvers.next(); + + if (configResolvers.hasNext()) { + throw new IllegalStateException("Multiple " + ConfigurationResolver.class.getName() + " implementations found"); + } + + logger.debugf("Configuration resolver found from classpath: %s", configResolver); + } else { + String enforcerConfigLocation = "WEB-INF/policy-enforcer.json"; + InputStream config = servletContext.getResourceAsStream(enforcerConfigLocation); + + if (config == null) { + logger.debugf("Could not find the policy enforcer configuration file: %s", enforcerConfigLocation); + return; + } + + try { + configResolver = createDefaultConfigurationResolver(JsonSerialization.readValue(config, PolicyEnforcerConfig.class)); + } catch (IOException e) { + throw new RuntimeException("Failed to parse policy enforcer configuration: " + enforcerConfigLocation); + } + } + + logger.debug("Policy enforcement filter is enabled."); + + servletContext.addFilter("keycloak-policy-enforcer", new PolicyEnforcerFilter(configResolver)) + .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*"); + } + + private ConfigurationResolver createDefaultConfigurationResolver(PolicyEnforcerConfig enforcerConfig) { + return new ConfigurationResolver() { + @Override + public PolicyEnforcerConfig resolve(HttpRequest request) { + return enforcerConfig; + } + }; + } +} diff --git a/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/ServletHttpRequest.java b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/ServletHttpRequest.java new file mode 100644 index 0000000000..ed4892980c --- /dev/null +++ b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/ServletHttpRequest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2023 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.authorization.integration.elytron; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.keycloak.adapters.authorization.TokenPrincipal; +import org.keycloak.adapters.authorization.spi.HttpRequest; + +/** + * @author Pedro Igor + */ +public class ServletHttpRequest implements HttpRequest { + + private final HttpServletRequest request; + private final TokenPrincipal tokenPrincipal; + private InputStream inputStream; + + public ServletHttpRequest(HttpServletRequest request, TokenPrincipal tokenPrincipal) { + this.request = request; + this.tokenPrincipal = tokenPrincipal; + } + + @Override + public String getRelativePath() { + return request.getServletPath(); + } + + @Override + public String getMethod() { + return request.getMethod(); + } + + @Override + public String getURI() { + return request.getRequestURI(); + } + + @Override + public List getHeaders(String name) { + return Collections.list(request.getHeaders(name)); + } + + @Override + public String getFirstParam(String name) { + Map parameters = request.getParameterMap(); + String[] values = parameters.get(name); + + if (values == null || values.length == 0) { + return null; + } + + return values[0]; + } + + @Override + public String getCookieValue(String name) { + Cookie[] cookies = request.getCookies(); + + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return cookie.getValue(); + } + } + + return null; + } + + @Override + public String getRemoteAddr() { + return request.getRemoteAddr(); + } + + @Override + public boolean isSecure() { + return request.isSecure(); + } + + @Override + public String getHeader(String name) { + return request.getHeader(name); + } + + @Override + public InputStream getInputStream(boolean buffered) { + if (inputStream != null) { + return inputStream; + } + + if (buffered) { + try { + return inputStream = new BufferedInputStream(request.getInputStream()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + try { + return request.getInputStream(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public TokenPrincipal getPrincipal() { + return tokenPrincipal; + } +} diff --git a/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/ServletHttpResponse.java b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/ServletHttpResponse.java new file mode 100644 index 0000000000..9528224790 --- /dev/null +++ b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/integration/elytron/ServletHttpResponse.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023 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.authorization.integration.elytron; + +import java.io.IOException; + +import jakarta.servlet.http.HttpServletResponse; +import org.keycloak.adapters.authorization.spi.HttpResponse; + +/** + * @author Pedro Igor + */ +public class ServletHttpResponse implements HttpResponse { + + private HttpServletResponse response; + + public ServletHttpResponse(HttpServletResponse response) { + this.response = response; + } + + @Override + public void sendError(int status) { + try { + response.sendError(status); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void sendError(int status, String reason) { + try { + response.sendError(status, reason); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void setHeader(String name, String value) { + response.setHeader(name, value); + } +} diff --git a/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/spi/ConfigurationResolver.java b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/spi/ConfigurationResolver.java new file mode 100644 index 0000000000..158d3f0922 --- /dev/null +++ b/authz/policy-enforcer/src/main/java/org/keycloak/adapters/authorization/spi/ConfigurationResolver.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 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.authorization.spi; + +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; + +/** + * Resolves a {@link PolicyEnforcerConfig} based on the information from the {@link HttpRequest}.

+ * + * @author Pedro Igor + */ +public interface ConfigurationResolver { + + /** + * Resolves a {@link PolicyEnforcerConfig} based on the information from the {@link HttpRequest}. + * + * @param request the request + * @return the policy enforcer configuration for the given request + */ + PolicyEnforcerConfig resolve(HttpRequest request); +} diff --git a/core/src/main/java/org/keycloak/representations/adapters/config/PolicyEnforcerConfig.java b/core/src/main/java/org/keycloak/representations/adapters/config/PolicyEnforcerConfig.java index 8fae65ba4e..ec2016577e 100644 --- a/core/src/main/java/org/keycloak/representations/adapters/config/PolicyEnforcerConfig.java +++ b/core/src/main/java/org/keycloak/representations/adapters/config/PolicyEnforcerConfig.java @@ -23,12 +23,14 @@ import java.util.Map; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import org.keycloak.representations.idm.authorization.ResourceRepresentation; import org.keycloak.representations.idm.authorization.ScopeRepresentation; import java.util.HashSet; import java.util.Set; +import java.util.TreeMap; /** * @author Pedro Igor @@ -39,31 +41,42 @@ public class PolicyEnforcerConfig { private EnforcementMode enforcementMode = EnforcementMode.ENFORCING; @JsonProperty("paths") - @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonInclude(Include.NON_EMPTY) private List paths = new ArrayList<>(); @JsonProperty("path-cache") - @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonInclude(Include.NON_EMPTY) private PathCacheConfig pathCacheConfig; @JsonProperty("lazy-load-paths") private Boolean lazyLoadPaths = Boolean.FALSE; @JsonProperty("on-deny-redirect-to") - @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonInclude(Include.NON_NULL) private String onDenyRedirectTo; @JsonProperty("user-managed-access") - @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonInclude(Include.NON_NULL) private UserManagedAccessConfig userManagedAccess; @JsonProperty("claim-information-point") - @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonInclude(Include.NON_NULL) private Map> claimInformationPointConfig; @JsonProperty("http-method-as-scope") private Boolean httpMethodAsScope; + private String realm; + + @JsonProperty("auth-server-url") + private String authServerUrl; + + @JsonProperty("credentials") + protected Map credentials = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + @JsonProperty("resource") + private String resource; + public List getPaths() { return this.paths; } @@ -128,6 +141,38 @@ public class PolicyEnforcerConfig { this.httpMethodAsScope = httpMethodAsScope; } + public String getRealm() { + return realm; + } + + public void setRealm(String realm) { + this.realm = realm; + } + + public String getAuthServerUrl() { + return authServerUrl; + } + + public void setAuthServerUrl(String authServerUrl) { + this.authServerUrl = authServerUrl; + } + + public Map getCredentials() { + return credentials; + } + + public void setCredentials(Map credentials) { + this.credentials = credentials; + } + + public String getResource() { + return resource; + } + + public void setResource(String resource) { + this.resource = resource; + } + public static class PathConfig { public static Set createPathConfigs(ResourceRepresentation resourceDescription) {