From 1303dabbb1353bd2049b5e8d36c54ed76ca8b33b Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Sat, 12 Dec 2020 02:27:20 -0800 Subject: [PATCH] GUACAMOLE-793: Refactor CAS group parsing to leverage LDAP-aware abstractions and parameters. --- .../auth/cas/conf/CASGuacamoleProperties.java | 37 +++- .../auth/cas/conf/ConfigurationService.java | 92 ++++++++-- .../cas/conf/LdapNameGuacamoleProperty.java | 49 ++++++ .../cas/ticket/TicketValidationService.java | 161 ++++++++++-------- 4 files changed, 252 insertions(+), 87 deletions(-) create mode 100644 extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/LdapNameGuacamoleProperty.java diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/CASGuacamoleProperties.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/CASGuacamoleProperties.java index 77c7667e5..7bb363f9c 100644 --- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/CASGuacamoleProperties.java +++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/CASGuacamoleProperties.java @@ -19,6 +19,8 @@ package org.apache.guacamole.auth.cas.conf; +import org.apache.guacamole.auth.cas.group.GroupFormat; +import org.apache.guacamole.properties.EnumGuacamoleProperty; import org.apache.guacamole.properties.URIGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty; @@ -71,8 +73,8 @@ public class CASGuacamoleProperties { }; /** - * The attribute used for group membership - * example: memberOf (case sensitive) + * The name of the CAS attribute used for group membership, such as + * "memberOf". This attribute is case sensitive. */ public static final StringGuacamoleProperty CAS_GROUP_ATTRIBUTE = new StringGuacamoleProperty() { @@ -83,14 +85,37 @@ public class CASGuacamoleProperties { }; /** - * The name of the attribute used for group membership, such as "memberOf". - * This attribute is case sensitive. + * The format used by CAS to represent group names. Possible formats are + * "plain" (simple text names) or "ldap" (fully-qualified LDAP DNs). */ - public static final StringGuacamoleProperty CAS_GROUP_DN_FORMAT = + public static final EnumGuacamoleProperty CAS_GROUP_FORMAT = + new EnumGuacamoleProperty(GroupFormat.class) { + + @Override + public String getName() { return "cas-group-format"; } + + }; + + /** + * The LDAP base DN to require for all CAS groups. + */ + public static final LdapNameGuacamoleProperty CAS_GROUP_LDAP_BASE_DN = + new LdapNameGuacamoleProperty() { + + @Override + public String getName() { return "cas-group-ldap-base-dn"; } + + }; + + /** + * The LDAP attribute to require for the names of CAS groups. + */ + public static final StringGuacamoleProperty CAS_GROUP_LDAP_ATTRIBUTE = new StringGuacamoleProperty() { @Override - public String getName() { return "cas-group-dn-format"; } + public String getName() { return "cas-group-ldap-attribute"; } }; + } diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/ConfigurationService.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/ConfigurationService.java index 1285c619c..ce5edd838 100644 --- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/ConfigurationService.java +++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/ConfigurationService.java @@ -22,8 +22,14 @@ package org.apache.guacamole.auth.cas.conf; import com.google.inject.Inject; import java.net.URI; import java.security.PrivateKey; +import javax.naming.ldap.LdapName; import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.auth.cas.group.GroupFormat; import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.auth.cas.group.GroupParser; +import org.apache.guacamole.auth.cas.group.LDAPGroupParser; +import org.apache.guacamole.auth.cas.group.PlainGroupParser; /** * Service for retrieving configuration information regarding the CAS service. @@ -86,8 +92,9 @@ public class ConfigurationService { } /** - * Returns the attribute used to determine group memberships - * in CAS, or null if not defined. + * Returns the CAS attribute that should be used to determine group + * memberships in CAS, such as "memberOf". If no attribute has been + * specified, null is returned. * * @return * The attribute name used to determine group memberships in CAS, @@ -101,24 +108,85 @@ public class ConfigurationService { } /** - * Returns the full DN specification used to format group DN's in CAS, - * or null if not defined. - * If CAS is backed by LDAP, it will return an LDAP DN, such as - * CN=foo,OU=bar,DC=example,DC=com. This DN specification may be set to - * CN=%s,OU=bar,DC=example,DC=com and given the example above, would result - * in a group called "foo". CAS backed by something other than LDAP would - * likely not need this. + * Returns the format that CAS is expected to use for its group names, such + * as {@link GroupFormat#PLAIN} (simple plain-text names) or + * {@link GroupFormat#LDAP} (fully-qualified LDAP DNs). If not specified, + * PLAIN is used by default. * * @return - * The DN format specification. + * The format that CAS is expected to use for its group names. + * + * @throws GuacamoleException + * If the format specified within guacamole.properties is not valid. + */ + public GroupFormat getGroupFormat() throws GuacamoleException { + return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_FORMAT, GroupFormat.PLAIN); + } + + /** + * Returns the base DN that all LDAP-formatted CAS groups must reside + * beneath. Any groups that are not beneath this base DN should be ignored. + * If no such base DN is provided, the tree structure of the ancestors of + * LDAP-formatted CAS groups should not be considered. + * + * @return + * The base DN that all LDAP-formatted CAS groups must reside beneath, + * or null if the tree structure of the ancestors of LDAP-formatted + * CAS groups should not be considered. + * + * @throws GuacamoleException + * If the provided base DN is not a valid LDAP DN. + */ + public LdapName getGroupLDAPBaseDN() throws GuacamoleException { + return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_LDAP_BASE_DN); + } + + /** + * Returns the LDAP attribute that should be required for all LDAP-formatted + * CAS groups. Any groups that do not use this attribute as the last + * (leftmost) attribute of their DN should be ignored. If no such LDAP + * attribute is provided, the last (leftmost) attribute should still be + * used to determine the group name, but the specific attribute involved + * should not be considered. + * + * @return + * The LDAP attribute that should be required for all LDAP-formatted + * CAS groups, or null if any attribute should be allowed. * * @throws GuacamoleException * If guacamole.properties cannot be parsed. */ - public String getGroupDnFormat() throws GuacamoleException { - return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_DN_FORMAT); + public String getGroupLDAPAttribute() throws GuacamoleException { + return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_LDAP_ATTRIBUTE); } + /** + * Returns a GroupParser instance that can be used to parse CAS group + * names. The parser returned will take into account the configured CAS + * group format, as well as any configured LDAP-specific restrictions. + * + * @return + * A GroupParser instance that can be used to parse CAS group names as + * configured in guacamole.properties. + * + * @throws GuacamoleException + * If guacamole.properties cannot be parsed. + */ + public GroupParser getGroupParser() throws GuacamoleException { + switch (getGroupFormat()) { + // Simple, plain-text groups + case PLAIN: + return new PlainGroupParser(); + + // LDAP DNs + case LDAP: + return new LDAPGroupParser(getGroupLDAPAttribute(), getGroupLDAPBaseDN()); + + default: + throw new GuacamoleServerException("Unsupported CAS group format: " + getGroupFormat()); + + } + } } diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/LdapNameGuacamoleProperty.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/LdapNameGuacamoleProperty.java new file mode 100644 index 000000000..5469376bc --- /dev/null +++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/LdapNameGuacamoleProperty.java @@ -0,0 +1,49 @@ +/* + * 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.guacamole.auth.cas.conf; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import org.apache.guacamole.properties.GuacamoleProperty; +import org.apache.guacamole.GuacamoleServerException; + +/** + * A GuacamoleProperty whose value is an LDAP DN. + */ +public abstract class LdapNameGuacamoleProperty implements GuacamoleProperty { + + @Override + public LdapName parseValue(String value) throws GuacamoleServerException { + + // Consider null/empty values to be empty + if (value == null || value.isEmpty()) + return null; + + // Parse provided value as an LDAP DN + try { + return new LdapName(value); + } + catch (InvalidNameException e) { + throw new GuacamoleServerException("Invalid LDAP distinguished name.", e); + } + + } + +} diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/ticket/TicketValidationService.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/ticket/TicketValidationService.java index 24c3e24a8..17ef92342 100644 --- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/ticket/TicketValidationService.java +++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/ticket/TicketValidationService.java @@ -31,13 +31,12 @@ import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.Map.Entry; import java.util.Set; -import java.util.HashSet; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleSecurityException; import org.apache.guacamole.GuacamoleServerException; @@ -80,6 +79,40 @@ public class TicketValidationService { @Inject private Provider authenticatedUserProvider; + /** + * Converts the given CAS attribute value object (whose type is variable) + * to a Set of String values. If the value is already a Collection of some + * kind, its values are converted to Strings and returned as the members of + * the Set. If the value is not already a Collection, it is assumed to be a + * single value, converted to a String, and used as the sole member of the + * set. + * + * @param obj + * The CAS attribute value to convert to a Set of Strings. + * + * @return + * A Set of all String values contained within the given CAS attribute + * value. + */ + private Set toStringSet(Object obj) { + + // Consider null to represent no provided values + if (obj == null) + return Collections.emptySet(); + + // If the provided object is already a Collection, produce a Collection + // where we know for certain that all values are Strings + if (obj instanceof Collection) { + return ((Collection) obj).stream() + .map(Object::toString) + .collect(Collectors.toSet()); + } + + // Otherwise, assume we have only a single value + return Collections.singleton(obj.toString()); + + } + /** * Validates and parses the given ID ticket, returning a map of all * available tokens for the given user based on attributes provided by the @@ -102,81 +135,71 @@ public class TicketValidationService { public CASAuthenticatedUser validateTicket(String ticket, Credentials credentials) throws GuacamoleException { - // Retrieve the configured CAS URL, establish a ticket validator, - // and then attempt to validate the supplied ticket. If that succeeds, - // grab the principal returned by the validator. + // Create a ticket validator that uses the configured CAS URL URI casServerUrl = confService.getAuthorizationEndpoint(); Cas20ProxyTicketValidator validator = new Cas20ProxyTicketValidator(casServerUrl.toString()); validator.setAcceptAnyProxy(true); validator.setEncoding("UTF-8"); + + // Attempt to validate the supplied ticket + Assertion assertion; try { - Map tokens = new HashMap<>(); - Set effectiveGroups = new HashSet<>(); URI confRedirectURI = confService.getRedirectURI(); - Assertion a = validator.validate(ticket, confRedirectURI.toString()); - AttributePrincipal principal = a.getPrincipal(); - Map ticketAttrs = - new HashMap<>(principal.getAttributes()); - - // Retrieve username and set the credentials. - String username = principal.getName(); - if (username == null) - throw new GuacamoleSecurityException("No username provided by CAS."); - - credentials.setUsername(username); - - // Retrieve password, attempt decryption, and set credentials. - Object credObj = ticketAttrs.remove("credential"); - if (credObj != null) { - String clearPass = decryptPassword(credObj.toString()); - if (clearPass != null && !clearPass.isEmpty()) - credentials.setPassword(clearPass); - } - - // Convert remaining attributes that have values to Strings - String groupAttribute = confService.getGroupAttribute(); - // Use cas-member-attribute to retrieve and set group memberships - String groupDnFormat = confService.getGroupDnFormat(); - String groupTemplate = ""; - if (groupDnFormat != null) { - // if CAS is backended to LDAP, groups come in as RFC4514 DN - // syntax. If cas-group-dn-format is set, this strips an - // entry such as "CN=Foo,OU=Bar,DC=example,DC=com" to "Foo" - groupTemplate = groupDnFormat.replace("%s","([A-Za-z0-9_\\(\\)\\-\\.\\s+]+)"); - // the underlying parser aggregates all instances of the same - // attribute, so we need to be able to parse them out - groupTemplate=groupTemplate+",*\\s*"; - } - else { - groupTemplate = "([A-Za-z0-9_\\(\\)\\-\\.\\s+]+,*\\s*)"; - } - Pattern pattern = Pattern.compile(groupTemplate); - - for (Entry attr : ticketAttrs.entrySet()) { - String tokenName = TokenName.canonicalize(attr.getKey(), - CAS_ATTRIBUTE_TOKEN_PREFIX); - Object value = attr.getValue(); - if (value != null) { - String attrValue = value.toString(); - tokens.put(tokenName, attrValue); - if (attr.getKey().equals(groupAttribute)) { - Matcher matcher = - pattern.matcher(attrValue.substring(1,attrValue.length()-1)); - while (matcher.find()) { - effectiveGroups.add(matcher.group(1)); - } - } - } - } - - CASAuthenticatedUser authenticatedUser = authenticatedUserProvider.get(); - authenticatedUser.init(username, credentials, tokens, effectiveGroups); - return authenticatedUser; - } + assertion = validator.validate(ticket, confRedirectURI.toString()); + } catch (TicketValidationException e) { throw new GuacamoleException("Ticket validation failed.", e); } + // Pull user principal and associated attributes + AttributePrincipal principal = assertion.getPrincipal(); + Map ticketAttrs = new HashMap<>(principal.getAttributes()); + + // Retrieve user identity from principal + String username = principal.getName(); + if (username == null) + throw new GuacamoleSecurityException("No username provided by CAS."); + + // Update credentials with username provided by CAS for sake of + // ${GUAC_USERNAME} token + credentials.setUsername(username); + + // Retrieve password, attempt decryption, and set credentials. + Object credObj = ticketAttrs.remove("credential"); + if (credObj != null) { + String clearPass = decryptPassword(credObj.toString()); + if (clearPass != null && !clearPass.isEmpty()) + credentials.setPassword(clearPass); + } + + Set effectiveGroups; + + // Parse effective groups from principal attributes if a specific + // group attribute has been configured + String groupAttribute = confService.getGroupAttribute(); + if (groupAttribute != null) { + effectiveGroups = toStringSet(ticketAttrs.get(groupAttribute)).stream() + .map(confService.getGroupParser()::parse) + .collect(Collectors.toSet()); + } + + // Otherwise, assume no effective groups + else + effectiveGroups = Collections.emptySet(); + + // Convert remaining attributes that have values to Strings + Map tokens = new HashMap<>(ticketAttrs.size()); + ticketAttrs.forEach((key, value) -> { + if (value != null) { + String tokenName = TokenName.canonicalize(key, CAS_ATTRIBUTE_TOKEN_PREFIX); + tokens.put(tokenName, value.toString()); + } + }); + + CASAuthenticatedUser authenticatedUser = authenticatedUserProvider.get(); + authenticatedUser.init(username, credentials, tokens, effectiveGroups); + return authenticatedUser; + } /**