mirror of
https://github.com/gyurix1968/guacamole-client.git
synced 2025-09-06 05:07:41 +00:00
GUACAMOLE-793: Refactor CAS group parsing to leverage LDAP-aware abstractions and parameters.
This commit is contained in:
@@ -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<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() {
|
||||
|
||||
@Override
|
||||
public String getName() { return "cas-group-dn-format"; }
|
||||
public String getName() { return "cas-group-ldap-attribute"; }
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
@@ -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());
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -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<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
|
||||
* 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<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.
|
||||
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 <String, Object> 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<String, Object> 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<String> 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<String, String> 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;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
Reference in New Issue
Block a user