GUACAMOLE-793: Refactor CAS group parsing to leverage LDAP-aware abstractions and parameters.

This commit is contained in:
Michael Jumper
2020-12-12 02:27:20 -08:00
parent 749e53b9c3
commit 1303dabbb1
4 changed files with 252 additions and 87 deletions

View File

@@ -19,6 +19,8 @@
package org.apache.guacamole.auth.cas.conf; 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.URIGuacamoleProperty;
import org.apache.guacamole.properties.StringGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty;
@@ -71,8 +73,8 @@ public class CASGuacamoleProperties {
}; };
/** /**
* The attribute used for group membership * The name of the CAS attribute used for group membership, such as
* example: memberOf (case sensitive) * "memberOf". This attribute is case sensitive.
*/ */
public static final StringGuacamoleProperty CAS_GROUP_ATTRIBUTE = public static final StringGuacamoleProperty CAS_GROUP_ATTRIBUTE =
new StringGuacamoleProperty() { new StringGuacamoleProperty() {
@@ -83,14 +85,37 @@ public class CASGuacamoleProperties {
}; };
/** /**
* The name of the attribute used for group membership, such as "memberOf". * The format used by CAS to represent group names. Possible formats are
* This attribute is case sensitive. * "plain" (simple text names) or "ldap" (fully-qualified LDAP DNs).
*/ */
public static final StringGuacamoleProperty CAS_GROUP_DN_FORMAT = public static final EnumGuacamoleProperty<GroupFormat> CAS_GROUP_FORMAT =
new EnumGuacamoleProperty<GroupFormat>(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() { new StringGuacamoleProperty() {
@Override @Override
public String getName() { return "cas-group-dn-format"; } public String getName() { return "cas-group-ldap-attribute"; }
}; };
} }

View File

@@ -22,8 +22,14 @@ package org.apache.guacamole.auth.cas.conf;
import com.google.inject.Inject; import com.google.inject.Inject;
import java.net.URI; import java.net.URI;
import java.security.PrivateKey; import java.security.PrivateKey;
import javax.naming.ldap.LdapName;
import org.apache.guacamole.GuacamoleException; 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.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. * Service for retrieving configuration information regarding the CAS service.
@@ -86,8 +92,9 @@ public class ConfigurationService {
} }
/** /**
* Returns the attribute used to determine group memberships * Returns the CAS attribute that should be used to determine group
* in CAS, or null if not defined. * memberships in CAS, such as "memberOf". If no attribute has been
* specified, null is returned.
* *
* @return * @return
* The attribute name used to determine group memberships in CAS, * 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, * Returns the format that CAS is expected to use for its group names, such
* or null if not defined. * as {@link GroupFormat#PLAIN} (simple plain-text names) or
* If CAS is backed by LDAP, it will return an LDAP DN, such as * {@link GroupFormat#LDAP} (fully-qualified LDAP DNs). If not specified,
* CN=foo,OU=bar,DC=example,DC=com. This DN specification may be set to * PLAIN is used by default.
* 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.
* *
* @return * @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 * @throws GuacamoleException
* If guacamole.properties cannot be parsed. * If guacamole.properties cannot be parsed.
*/ */
public String getGroupDnFormat() throws GuacamoleException { public String getGroupLDAPAttribute() throws GuacamoleException {
return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_DN_FORMAT); 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());
}
}
} }

View File

@@ -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<LdapName> {
@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);
}
}
}

View File

@@ -31,13 +31,12 @@ import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException; import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException; import javax.crypto.NoSuchPaddingException;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry;
import java.util.Set; import java.util.Set;
import java.util.HashSet; import java.util.stream.Collectors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.GuacamoleSecurityException; import org.apache.guacamole.GuacamoleSecurityException;
import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.GuacamoleServerException;
@@ -80,6 +79,40 @@ public class TicketValidationService {
@Inject @Inject
private Provider<CASAuthenticatedUser> authenticatedUserProvider; private Provider<CASAuthenticatedUser> 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<String> 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 * Validates and parses the given ID ticket, returning a map of all
* available tokens for the given user based on attributes provided by the * available tokens for the given user based on attributes provided by the
@@ -102,27 +135,33 @@ public class TicketValidationService {
public CASAuthenticatedUser validateTicket(String ticket, public CASAuthenticatedUser validateTicket(String ticket,
Credentials credentials) throws GuacamoleException { Credentials credentials) throws GuacamoleException {
// Retrieve the configured CAS URL, establish a ticket validator, // Create a ticket validator that uses the configured CAS URL
// and then attempt to validate the supplied ticket. If that succeeds,
// grab the principal returned by the validator.
URI casServerUrl = confService.getAuthorizationEndpoint(); URI casServerUrl = confService.getAuthorizationEndpoint();
Cas20ProxyTicketValidator validator = new Cas20ProxyTicketValidator(casServerUrl.toString()); Cas20ProxyTicketValidator validator = new Cas20ProxyTicketValidator(casServerUrl.toString());
validator.setAcceptAnyProxy(true); validator.setAcceptAnyProxy(true);
validator.setEncoding("UTF-8"); validator.setEncoding("UTF-8");
try {
Map<String, String> tokens = new HashMap<>();
Set<String> effectiveGroups = new HashSet<>();
URI confRedirectURI = confService.getRedirectURI();
Assertion a = validator.validate(ticket, confRedirectURI.toString());
AttributePrincipal principal = a.getPrincipal();
Map<String, Object> ticketAttrs =
new HashMap<>(principal.getAttributes());
// Retrieve username and set the credentials. // Attempt to validate the supplied ticket
Assertion assertion;
try {
URI confRedirectURI = confService.getRedirectURI();
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<String, Object> ticketAttrs = new HashMap<>(principal.getAttributes());
// Retrieve user identity from principal
String username = principal.getName(); String username = principal.getName();
if (username == null) if (username == null)
throw new GuacamoleSecurityException("No username provided by CAS."); throw new GuacamoleSecurityException("No username provided by CAS.");
// Update credentials with username provided by CAS for sake of
// ${GUAC_USERNAME} token
credentials.setUsername(username); credentials.setUsername(username);
// Retrieve password, attempt decryption, and set credentials. // Retrieve password, attempt decryption, and set credentials.
@@ -133,49 +172,33 @@ public class TicketValidationService {
credentials.setPassword(clearPass); credentials.setPassword(clearPass);
} }
// Convert remaining attributes that have values to Strings Set<String> effectiveGroups;
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 <String, Object> attr : ticketAttrs.entrySet()) { // Parse effective groups from principal attributes if a specific
String tokenName = TokenName.canonicalize(attr.getKey(), // group attribute has been configured
CAS_ATTRIBUTE_TOKEN_PREFIX); String groupAttribute = confService.getGroupAttribute();
Object value = attr.getValue(); 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<String, String> tokens = new HashMap<>(ticketAttrs.size());
ticketAttrs.forEach((key, value) -> {
if (value != null) { if (value != null) {
String attrValue = value.toString(); String tokenName = TokenName.canonicalize(key, CAS_ATTRIBUTE_TOKEN_PREFIX);
tokens.put(tokenName, attrValue); tokens.put(tokenName, value.toString());
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(); CASAuthenticatedUser authenticatedUser = authenticatedUserProvider.get();
authenticatedUser.init(username, credentials, tokens, effectiveGroups); authenticatedUser.init(username, credentials, tokens, effectiveGroups);
return authenticatedUser; return authenticatedUser;
}
catch (TicketValidationException e) {
throw new GuacamoleException("Ticket validation failed.", e);
}
} }