diff --git a/gateway-provider-security-shiro/pom.xml b/gateway-provider-security-shiro/pom.xml index 004a62370e..fb380c2e61 100644 --- a/gateway-provider-security-shiro/pom.xml +++ b/gateway-provider-security-shiro/pom.xml @@ -45,6 +45,10 @@ org.apache.knox gateway-util-common + + org.apache.knox + gateway-util-urltemplate + org.jboss.shrinkwrap @@ -87,6 +91,11 @@ commons-io + + org.apache.commons + commons-lang3 + + javax.servlet javax.servlet-api diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/ShiroMessages.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/ShiroMessages.java new file mode 100644 index 0000000000..5a51664bf3 --- /dev/null +++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/ShiroMessages.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.knox.gateway; + +import org.apache.knox.gateway.i18n.messages.Message; +import org.apache.knox.gateway.i18n.messages.MessageLevel; +import org.apache.knox.gateway.i18n.messages.Messages; + +@Messages(logger="org.apache.knox.gateway") +public interface ShiroMessages { + @Message(level = MessageLevel.INFO, text = "Request {0} matches unauthenticated path configured in topology, letting it through" ) + void unauthenticatedPathBypass(String uri); + + @Message( level = MessageLevel.WARN, text = "Invalid URL pattern for rule: {0}" ) + void invalidURLPattern(String rule); +} diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/deploy/impl/ShiroDeploymentContributor.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/deploy/impl/ShiroDeploymentContributor.java index 345727f0df..61b1a4f140 100644 --- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/deploy/impl/ShiroDeploymentContributor.java +++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/deploy/impl/ShiroDeploymentContributor.java @@ -32,6 +32,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; public class ShiroDeploymentContributor extends ProviderDeploymentContributorBase { @@ -43,8 +44,10 @@ public class ShiroDeploymentContributor extends ProviderDeploymentContributorBas private static final String SESSION_TIMEOUT = "sessionTimeout"; private static final String REMEMBER_ME = "rememberme"; private static final String SHRIO_CONFIG_FILE_NAME = "shiro.ini"; + private static final String SHIRO_URL_CONFIG = "urls"; private static final int DEFAULT_SESSION_TIMEOUT = 30; // 30min + @Override public String getRole() { return "authentication"; @@ -132,6 +135,17 @@ public void contributeFilter( DeploymentContext context, Provider provider, resource.addFilter().name( getName() ).role( getRole() ).impl( SHIRO_FILTER_CLASSNAME ).params( params ); + + /* Collect all shiro `urls.` params and them as filter params to POST_FILTER_CLASSNAME */ + Map shiroURLs = providerParams.entrySet().stream().filter(e -> e.getKey().startsWith(SHIRO_URL_CONFIG)).collect( + Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + for(final Map.Entry m: shiroURLs.entrySet()) { + params.add( resource.createFilterParam() + .name( m.getKey() ) + .value( m.getValue() ) ); + } + resource.addFilter().name( "Post" + getName() ).role( getRole() ).impl( POST_FILTER_CLASSNAME ).params( params ); } diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/ShiroSubjectIdentityAdapter.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/ShiroSubjectIdentityAdapter.java index 4fb945791b..0f8ef22b0f 100644 --- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/ShiroSubjectIdentityAdapter.java +++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/ShiroSubjectIdentityAdapter.java @@ -17,21 +17,8 @@ */ package org.apache.knox.gateway.filter; -import java.io.IOException; -import java.security.Principal; -import java.security.PrivilegedExceptionAction; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; -import java.util.concurrent.Callable; - -import javax.servlet.Filter; -import javax.servlet.FilterChain; -import javax.servlet.FilterConfig; -import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; - +import org.apache.commons.lang3.StringUtils; +import org.apache.knox.gateway.ShiroMessages; import org.apache.knox.gateway.audit.api.Action; import org.apache.knox.gateway.audit.api.ActionOutcome; import org.apache.knox.gateway.audit.api.AuditContext; @@ -40,20 +27,76 @@ import org.apache.knox.gateway.audit.api.Auditor; import org.apache.knox.gateway.audit.api.ResourceType; import org.apache.knox.gateway.audit.log4j.audit.AuditConstants; +import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.security.GroupPrincipal; import org.apache.knox.gateway.security.PrimaryPrincipal; +import org.apache.knox.gateway.util.urltemplate.Matcher; +import org.apache.knox.gateway.util.urltemplate.Parser; +import org.apache.knox.gateway.util.urltemplate.Template; import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.net.URISyntaxException; +import java.security.Principal; +import java.security.PrivilegedExceptionAction; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; + public class ShiroSubjectIdentityAdapter implements Filter { + private static final ShiroMessages LOG = MessagesFactory.get(ShiroMessages.class); private static final String SUBJECT_USER_GROUPS = "subject.userGroups"; private static AuditService auditService = AuditServiceFactory.getAuditService(); private static Auditor auditor = auditService.getAuditor( AuditConstants.DEFAULT_AUDITOR_NAME, AuditConstants.KNOX_SERVICE_NAME, AuditConstants.KNOX_COMPONENT_NAME ); + private static final String SHIRO_URL_CONFIG = "urls"; + /* Map of shiro url configs */ + private static Map urls = new HashMap<>(); + + /* List of URLs with anon authentication */ + private static List anonUrls = new ArrayList<>(); @Override public void init( FilterConfig filterConfig ) throws ServletException { + /* Create a shiro urls config map */ + final Enumeration params = filterConfig.getInitParameterNames(); + while (params.hasMoreElements()) { + String param = params.nextElement(); + if (StringUtils.startsWithIgnoreCase(param, SHIRO_URL_CONFIG)) { + String value = filterConfig.getInitParameter(param); + final String pathParam = param.substring(param.indexOf('.') + 1); + urls.put(pathParam, value); + if("anon".equalsIgnoreCase(value)) { + final Template urlPatternTemplate; + final Matcher urlMatcher = new Matcher(); + try { + urlPatternTemplate = Parser.parseTemplate(pathParam); + urlMatcher.add(urlPatternTemplate, pathParam); + anonUrls.add(urlMatcher); + } catch (URISyntaxException e) { + LOG.invalidURLPattern(param); + throw new ServletException(e); + } + + } + + } + } } @Override @@ -96,56 +139,110 @@ public Void run() throws Exception { }; Subject shiroSubject = SecurityUtils.getSubject(); + /** + * For cases when we want anonymous authentication to urls in shiro. + * This is when do not want authentication for jwks endpoints using + * shiro. + */ if (shiroSubject == null || shiroSubject.getPrincipal() == null) { - throw new IllegalStateException("Unable to determine authenticated user from Shiro, please check that your Knox Shiro configuration is correct"); - } - final String principal = shiroSubject.getPrincipal().toString(); - Set principals = new HashSet<>(); - Principal p = new PrimaryPrincipal(principal); - principals.add(p); - AuditContext context = auditService.getContext(); - context.setUsername( principal ); - auditService.attachContext(context); - String sourceUri = (String)request.getAttribute( AbstractGatewayFilter.SOURCE_REQUEST_CONTEXT_URL_ATTRIBUTE_NAME ); - auditor.audit( Action.AUTHENTICATION , sourceUri, ResourceType.URI, ActionOutcome.SUCCESS ); - - Set userGroups; - // map ldap groups saved in session to Java Subject GroupPrincipal(s) - if (SecurityUtils.getSubject().getSession().getAttribute(SUBJECT_USER_GROUPS) != null) { - userGroups = (Set)SecurityUtils.getSubject().getSession().getAttribute(SUBJECT_USER_GROUPS); - } else { // KnoxLdapRealm case - if( shiroSubject.getPrincipal() instanceof String ) { - userGroups = new HashSet<>(shiroSubject.getPrincipals().asSet()); - userGroups.remove(principal); - } else { // KnoxPamRealm case - Set shiroPrincipals = new HashSet<>(shiroSubject.getPrincipals().asSet()); - userGroups = new HashSet<>(); // Here we are creating a new UserGroup - // so we don't need to remove a Principal - // In the case of LDAP the userGroup may have already Principal - // added to the list of groups, so it is not needed. - for( Principal shiroPrincipal: shiroPrincipals ) { - userGroups.add(shiroPrincipal.toString() ); - } + if(!isRequestPathInShiroConfig(((HttpServletRequest)request))) { + throw new IllegalStateException("Unable to determine authenticated user from Shiro, please check that your Knox Shiro configuration is correct"); } + + LOG.unauthenticatedPathBypass( + ((HttpServletRequest) request).getRequestURI()); + final String principal = "anonymous"; + javax.security.auth.Subject subject = new javax.security.auth.Subject(); + subject.getPrincipals().add(new PrimaryPrincipal(principal)); + AuditContext context = auditService.getContext(); + context.setUsername(principal); + auditService.attachContext(context); + javax.security.auth.Subject.doAs(subject, action); + } else { + + final String principal = shiroSubject.getPrincipal().toString(); + Set principals = new HashSet<>(); + Principal p = new PrimaryPrincipal(principal); + principals.add(p); + AuditContext context = auditService.getContext(); + context.setUsername(principal); + auditService.attachContext(context); + String sourceUri = (String) request.getAttribute( + AbstractGatewayFilter.SOURCE_REQUEST_CONTEXT_URL_ATTRIBUTE_NAME); + auditor.audit(Action.AUTHENTICATION, sourceUri, ResourceType.URI, + ActionOutcome.SUCCESS); + + Set userGroups; + // map ldap groups saved in session to Java Subject GroupPrincipal(s) + if (SecurityUtils.getSubject().getSession() + .getAttribute(SUBJECT_USER_GROUPS) != null) { + userGroups = (Set) SecurityUtils.getSubject().getSession() + .getAttribute(SUBJECT_USER_GROUPS); + } else { // KnoxLdapRealm case + if (shiroSubject.getPrincipal() instanceof String) { + userGroups = new HashSet<>(shiroSubject.getPrincipals().asSet()); + userGroups.remove(principal); + } else { // KnoxPamRealm case + Set shiroPrincipals = new HashSet<>( + shiroSubject.getPrincipals().asSet()); + userGroups = new HashSet<>(); // Here we are creating a new UserGroup + // so we don't need to remove a Principal + // In the case of LDAP the userGroup may have already Principal + // added to the list of groups, so it is not needed. + for (Principal shiroPrincipal : shiroPrincipals) { + userGroups.add(shiroPrincipal.toString()); + } + } + } + for (String userGroup : userGroups) { + Principal gp = new GroupPrincipal(userGroup); + principals.add(gp); + } + auditor.audit(Action.AUTHENTICATION, sourceUri, ResourceType.URI, + ActionOutcome.SUCCESS, "Groups: " + userGroups); + + // The newly constructed Sets check whether this Subject has been set read-only + // before permitting subsequent modifications. The newly created Sets also prevent + // illegal modifications by ensuring that callers have sufficient permissions. + // + // To modify the Principals Set, the caller must have AuthPermission("modifyPrincipals"). + // To modify the public credential Set, the caller must have AuthPermission("modifyPublicCredentials"). + // To modify the private credential Set, the caller must have AuthPermission("modifyPrivateCredentials"). + javax.security.auth.Subject subject = new javax.security.auth.Subject( + true, principals, Collections.emptySet(), Collections.emptySet()); + javax.security.auth.Subject.doAs(subject, action); } - for (String userGroup : userGroups) { - Principal gp = new GroupPrincipal(userGroup); - principals.add(gp); - } - auditor.audit( Action.AUTHENTICATION , sourceUri, ResourceType.URI, ActionOutcome.SUCCESS, "Groups: " + userGroups ); - - // The newly constructed Sets check whether this Subject has been set read-only - // before permitting subsequent modifications. The newly created Sets also prevent - // illegal modifications by ensuring that callers have sufficient permissions. - // - // To modify the Principals Set, the caller must have AuthPermission("modifyPrincipals"). - // To modify the public credential Set, the caller must have AuthPermission("modifyPublicCredentials"). - // To modify the private credential Set, the caller must have AuthPermission("modifyPrivateCredentials"). - javax.security.auth.Subject subject = new javax.security.auth.Subject(true, principals, Collections.emptySet(), Collections.emptySet()); - javax.security.auth.Subject.doAs( subject, action ); return null; } } + + /** + * A helper function that checks whether the request path is defined in shiro + * config, specifically under the urls section with anon authentication. e.g. + * + * urls./knoxtoken/api/v1/jwks.json + * anon + * + * + * @param request + * @return true if request has anon auth. + * @throws URISyntaxException + */ + private static boolean isRequestPathInShiroConfig( + final HttpServletRequest request) throws URISyntaxException { + boolean isPathInConfig = false; + final String requestContextPath = StringUtils.startsWith( + request.getPathInfo(), "/") ? + request.getPathInfo() : + "/" + request.getPathInfo(); + final Template requestUrlTemplate = Parser.parseLiteral(requestContextPath); + for (final Matcher m : anonUrls) { + if (m.match(requestUrlTemplate) != null) { + isPathInConfig = true; + } + } + return isPathInConfig; + } } diff --git a/gateway-release/home/conf/topologies/sandbox.xml b/gateway-release/home/conf/topologies/sandbox.xml index d6a4dbcef3..646bd8cc2f 100644 --- a/gateway-release/home/conf/topologies/sandbox.xml +++ b/gateway-release/home/conf/topologies/sandbox.xml @@ -56,6 +56,14 @@ main.ldapRealm.contextFactory.authenticationMechanism simple + + urls./knoxtoken/api/v2/jwks.json + anon + + + urls./knoxtoken/api/v1/jwks.json + anon + urls./** authcBasic diff --git a/gateway-test/src/test/java/org/apache/knox/gateway/GatewayShiroAuthTest.java b/gateway-test/src/test/java/org/apache/knox/gateway/GatewayShiroAuthTest.java index 3a199a53c0..ce13a493c2 100644 --- a/gateway-test/src/test/java/org/apache/knox/gateway/GatewayShiroAuthTest.java +++ b/gateway-test/src/test/java/org/apache/knox/gateway/GatewayShiroAuthTest.java @@ -48,12 +48,16 @@ public class GatewayShiroAuthTest { private static final Logger LOG = LoggerFactory.getLogger(GatewayShiroAuthTest.class); private static final String SHIRO_URL_PATTERN_VALID = "/**"; private static final String SHIRO_URL_PATTERN_INVALID = "/invalid/**"; + private static final String SHIRO_URL_PATTERN_JWKS = "/knoxtoken/api/v1/jwks.json"; private static final String USERNAME = "guest"; private static final String PASSWORD = "guest-password"; private static final String TOPOLOGY_VALID = "shiro-test-cluster"; private static final String TOPOLOGY_IN_VALID_URL = "shiro-test-cluster-invalid"; private static final String TOPOLOGY_BLOCK_UNSAFE_CHARS = "shiro-test-cluster-unsafe"; + private static final String TOPOLOGY_JWKS_URL = "shiro-test-jwks"; + private static final String TOPOLOGY_INVALID_JWKS_URL = "shiro-test-invalid-jwks"; private static final String SERVICE_RESOURCE_NAME = "/test-service-path/test-service-resource"; + private static final String SERVICE_TOKEN_STATE_NAME = "/knoxtoken/api/v1/jwks.json"; public static GatewayConfig config; public static GatewayServer gateway; public static String gatewayUrl; @@ -116,6 +120,18 @@ public static void setupGateway() throws Exception { createTopology(SHIRO_URL_PATTERN_VALID, true ).toStream(stream); } + /* create a topology with valid shiro url */ + File jwks_descriptor = new File(topoDir, TOPOLOGY_JWKS_URL+".xml"); + try (OutputStream stream = Files.newOutputStream(jwks_descriptor.toPath())) { + createJWKSTestTopology(SHIRO_URL_PATTERN_JWKS).toStream(stream); + } + + /* create a topology with valid shiro url */ + File jwks_invalid_descriptor = new File(topoDir, TOPOLOGY_INVALID_JWKS_URL+".xml"); + try (OutputStream stream = Files.newOutputStream(jwks_invalid_descriptor.toPath())) { + createJWKSTestTopology("/some-random-path/*").toStream(stream); + } + DefaultGatewayServices srvcs = new DefaultGatewayServices(); Map options = new HashMap<>(); options.put("persist-master", "false"); @@ -215,6 +231,40 @@ private static XMLTag createTopology(final String pattern, final boolean blockUn } } + private static XMLTag createJWKSTestTopology(final String pattern) { + return XMLDoc.newDocument(true) + .addRoot("topology") + .addTag("gateway") + .addTag("provider") + .addTag("role").addText("authentication") + .addTag("name").addText("ShiroProvider") + .addTag("enabled").addText("true") + .addTag("param") + .addTag("name").addText("main.ldapRealm") + .addTag("value").addText("org.apache.knox.gateway.shirorealm.KnoxLdapRealm").gotoParent() + .addTag("param") + .addTag("name").addText("main.ldapRealm.userDnTemplate") + .addTag("value").addText("uid={0},ou=people,dc=hadoop,dc=apache,dc=org").gotoParent() + .addTag("param") + .addTag("name").addText("main.ldapRealm.contextFactory.url") + .addTag("value").addText(driver.getLdapUrl()).gotoParent() + .addTag("param") + .addTag("name").addText("main.ldapRealm.contextFactory.authenticationMechanism") + .addTag("value").addText("simple").gotoParent() + .addTag("param") + .addTag("name").addText("urls." + pattern) + .addTag("value").addText("anon").gotoParent().gotoParent() + .addTag("provider") + .addTag("role").addText("identity-assertion") + .addTag("enabled").addText("true") + .addTag("name").addText("Default").gotoParent() + .addTag("provider") + .gotoRoot() + .addTag("service") + .addTag("role").addText("KNOXTOKEN") + .gotoRoot(); + } + /** * Make sure semicolons are allowed in the URL out-of-the-box */ @@ -266,4 +316,28 @@ private void testShiroAuthSuccess(final String serviceUrl, final String expected .when().get(serviceUrl); } + /** + * Test a case where configured shiro url is invalid and there is auth failure. + */ + @Test + public void testJWKSEndpointShiro() { + final String serviceUrl = gatewayUrl + '/' + TOPOLOGY_JWKS_URL + SERVICE_TOKEN_STATE_NAME; + given() + .auth().none() + .then() + .statusCode(HttpStatus.SC_OK) + .contentType("") + .body(containsString("{\"keys\":[{\"kty\":\"RSA\"")) + .when().get(serviceUrl); + } + + /** + * Test a case where configured shiro url is invalid and there is auth failure. + */ + @Test + public void testUnConfiguredJWKSEndpointShiro() { + final String serviceUrl = gatewayUrl + '/' + TOPOLOGY_INVALID_JWKS_URL + SERVICE_TOKEN_STATE_NAME; + testShiroAuthFailure(HttpStatus.SC_INTERNAL_SERVER_ERROR, serviceUrl, "Unable to determine authenticated user from Shiro, please check that your Knox Shiro configuration is correct"); + } + }