Policy Enforcer built-in support for Elytron and Jakarta

Closes #19540
This commit is contained in:
Pedro Igor 2023-04-05 08:52:39 -03:00 committed by Marek Posolda
parent f55794f8bf
commit 409e1c3581
8 changed files with 500 additions and 27 deletions

View file

@ -30,35 +30,29 @@
<name>Keycloak Authz: Policy Enforcer</name>
<packaging>jar</packaging>
<properties>
<jakarta.servlet.version>6.0.0</jakarta.servlet.version>
<wildfly-elytron.version>2.0.0.Final</wildfly-elytron.version>
</properties>
<dependencies>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-authz-client</artifactId>
</dependency>
<!-- Built-in Elytron/Jakarta Servlet integration -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>${jakarta.servlet.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<groupId>org.wildfly.security</groupId>
<artifactId>wildfly-elytron-http-oidc</artifactId>
<version>${wildfly-elytron.version}</version>
<optional>true</optional>
</dependency>
</dependencies>

View file

@ -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();

View file

@ -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.</p>
*
* For authenticated subjects, this filter delegates the access decision to the {@link PolicyEnforcer} and decide if
* the request should continue.</p>
*
* If access is not granted, this filter aborts the request and relies on the {@link PolicyEnforcer} to properly
* respond to client.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class PolicyEnforcerFilter implements Filter, ServletContextAttributeListener {
private final Logger logger = Logger.getLogger(getClass());
private final Map<PolicyEnforcerConfig, PolicyEnforcer> 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<PolicyEnforcerConfig, PolicyEnforcer>() {
@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();
}
});
}
}

View file

@ -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.</p>
*
* By default, the policy enforcer configuration is loaded from a file at {@code WEB-INF/policy-enforcer.json}.</p>
*
* 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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
@WebListener
public class PolicyEnforcerServletContextListener implements ServletContextListener {
private final Logger logger = Logger.getLogger(getClass());
@Override
public void contextInitialized(ServletContextEvent sce) {
ServletContext servletContext = sce.getServletContext();
Iterator<ConfigurationResolver> 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;
}
};
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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<String> getHeaders(String name) {
return Collections.list(request.getHeaders(name));
}
@Override
public String getFirstParam(String name) {
Map<String, String[]> 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;
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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);
}
}

View file

@ -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}.</p>
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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);
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -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<PathConfig> 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<String, Map<String, Object>> claimInformationPointConfig;
@JsonProperty("http-method-as-scope")
private Boolean httpMethodAsScope;
private String realm;
@JsonProperty("auth-server-url")
private String authServerUrl;
@JsonProperty("credentials")
protected Map<String, Object> credentials = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
@JsonProperty("resource")
private String resource;
public List<PathConfig> 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<String, Object> getCredentials() {
return credentials;
}
public void setCredentials(Map<String, Object> credentials) {
this.credentials = credentials;
}
public String getResource() {
return resource;
}
public void setResource(String resource) {
this.resource = resource;
}
public static class PathConfig {
public static Set<PathConfig> createPathConfigs(ResourceRepresentation resourceDescription) {