package org.apereo.cas.services; import com.fasterxml.jackson.annotation.JsonIgnore; import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apereo.cas.util.CollectionUtils; import org.apereo.cas.util.RegexUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.URI; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * This is {@link DefaultRegisteredServiceAccessStrategy} * that allows the following rules: * * <ul> * <li>A service may be disallowed to use CAS for authentication</li> * <li>A service may be disallowed to take part in CAS single sign-on such that * presentation of credentials would always be required.</li> * <li>A service may be prohibited from receiving a service ticket * if the existing principal attributes don't contain the required attributes * that otherwise grant access to the service.</li> * </ul> * * @author Misagh Moayyed mmoayyed@unicon.net * @since 4.1 */ public class DefaultRegisteredServiceAccessStrategy implements RegisteredServiceAccessStrategy { private static final long serialVersionUID = 1245279151345635245L; private static final Logger LOGGER = LoggerFactory.getLogger(DefaultRegisteredServiceAccessStrategy.class); /** Is the service allowed at all? **/ private boolean enabled = true; /** Is the service allowed to use SSO? **/ private boolean ssoEnabled = true; private URI unauthorizedRedirectUrl; /** * Defines the attribute aggregation behavior when checking for required attributes. * Default requires that all attributes be present and match the principal's. */ private boolean requireAllAttributes = true; /** * Collection of required attributes * for this service to proceed. */ private Map<String, Set<String>> requiredAttributes = new HashMap<>(); /** * Collection of attributes * that will be rejected which will cause this * policy to refuse access. */ private Map<String, Set<String>> rejectedAttributes = new HashMap<>(); /** * Indicates whether matching on required attribute values * should be done in a case-insensitive manner. */ private boolean caseInsensitive; /** * Instantiates a new Default registered service authorization strategy. * By default, rules indicate that services are both enabled * and can participate in SSO. */ public DefaultRegisteredServiceAccessStrategy() { this(true, true); } /** * Instantiates a new Default registered service authorization strategy. * * @param enabled the enabled * @param ssoEnabled the sso enabled */ public DefaultRegisteredServiceAccessStrategy(final boolean enabled, final boolean ssoEnabled) { this.enabled = enabled; this.ssoEnabled = ssoEnabled; } public void setEnabled(final boolean enabled) { this.enabled = enabled; } /** * Set to enable/authorize this service. * @param ssoEnabled true to enable service */ public void setSsoEnabled(final boolean ssoEnabled) { this.ssoEnabled = ssoEnabled; } public boolean isEnabled() { return this.enabled; } public boolean isSsoEnabled() { return this.ssoEnabled; } /** * Defines the attribute aggregation when checking for required attributes. * Default requires that all attributes be present and match the principal's. * @param requireAllAttributes the require all attributes */ public void setRequireAllAttributes(final boolean requireAllAttributes) { this.requireAllAttributes = requireAllAttributes; } public boolean isRequireAllAttributes() { return this.requireAllAttributes; } public Map<String, Set<String>> getRequiredAttributes() { return new HashMap<>(this.requiredAttributes); } public void setUnauthorizedRedirectUrl(final URI unauthorizedRedirectUrl) { this.unauthorizedRedirectUrl = unauthorizedRedirectUrl; } @Override public URI getUnauthorizedRedirectUrl() { return this.unauthorizedRedirectUrl; } /** * Is attribute value matching case insensitive? * * @return true/false */ public boolean isCaseInsensitive() { return this.caseInsensitive; } /** * Sets case insensitive. * * @param caseInsensitive the case insensitive * @since 5.0.0 */ public void setCaseInsensitive(final boolean caseInsensitive) { this.caseInsensitive = caseInsensitive; } /** * Defines the required attribute names and values that * must be available to the principal before the flow * can proceed to the next step. Every attribute in * the map can be linked to multiple values. * * @param requiredAttributes the required attributes */ public void setRequiredAttributes(final Map<String, Set<String>> requiredAttributes) { this.requiredAttributes = requiredAttributes; } /** * Sets rejected attributes. If the policy finds any of the attributes defined * here, it will simply reject and refuse access. * * @param rejectedAttributes the rejected attributes */ public void setRejectedAttributes(final Map<String, Set<String>> rejectedAttributes) { this.rejectedAttributes = rejectedAttributes; } public Map<String, Set<String>> getRejectedAttributes() { return this.rejectedAttributes; } /** * {@inheritDoc} * * Verify presence of service required attributes. * <ul> * <li>If no rejected attributes are specified, authz is granted.</li> * <li>If no required attributes are specified, authz is granted.</li> * <li>If ALL attributes must be present, and the principal contains all and there is * at least one attribute value that matches the rejected, authz is denied.</li> * <li>If ALL attributes must be present, and the principal contains all and there is * at least one attribute value that matches the required, authz is granted.</li> * <li>If ALL attributes don't have to be present, and there is at least * one principal attribute present whose value matches the rejected, authz is denied.</li> * <li>If ALL attributes don't have to be present, and there is at least * one principal attribute present whose value matches the required, authz is granted.</li> * <li>Otherwise, access is denied</li> * </ul> */ @Override public boolean doPrincipalAttributesAllowServiceAccess(final String principal, final Map<String, Object> principalAttributes) { if (this.rejectedAttributes.isEmpty() && this.requiredAttributes.isEmpty()) { LOGGER.debug("Skipping access strategy policy, since no attributes rules are defined"); return true; } if (!enoughAttributesAvailableToProcess(principal, principalAttributes)) { LOGGER.debug("Access is denied. There are not enough attributes available to satisfy requirements"); return false; } if (doRejectedAttributesRefusePrincipalAccess(principalAttributes)) { LOGGER.debug("Access is denied. The principal carries attributes that would reject service access"); return false; } if (!doRequiredAttributesAllowPrincipalAccess(principalAttributes)) { LOGGER.debug("Access is denied. The principal does not have the required attributes specified by this strategy"); return false; } return true; } private boolean doRequiredAttributesAllowPrincipalAccess(final Map<String, Object> principalAttributes) { LOGGER.debug("These required attributes [{}] are examined against [{}] before service can proceed.", requiredAttributes, principalAttributes); if (requiredAttributes.isEmpty()) { return true; } return common(principalAttributes, requiredAttributes); } private boolean doRejectedAttributesRefusePrincipalAccess(final Map<String, Object> principalAttributes) { LOGGER.debug("These rejected attributes [{}] are examined against [{}] before service can proceed.", rejectedAttributes, principalAttributes); if (rejectedAttributes.isEmpty()) { return false; } return common(principalAttributes, rejectedAttributes); } /** * Enough attributes available to process? Check collection sizes and determine * if we have enough data to move on. * * @param principal the principal * @param principalAttributes the principal attributes * @return true/false */ protected boolean enoughAttributesAvailableToProcess(final String principal, final Map<String, Object> principalAttributes) { if (principalAttributes.isEmpty() && !this.requiredAttributes.isEmpty()) { LOGGER.debug("No principal attributes are found to satisfy defined attribute requirements"); return false; } if (principalAttributes.size() < this.rejectedAttributes.size()) { LOGGER.debug("The size of the principal attributes that are [{}] does not match defined rejected attributes, " + "which means the principal is not carrying enough data to grant authorization", principalAttributes); return false; } if (principalAttributes.size() < this.requiredAttributes.size()) { LOGGER.debug("The size of the principal attributes that are [{}] does not match defined required attributes, " + "which indicates the principal is not carrying enough data to grant authorization", principalAttributes); return false; } return true; } @JsonIgnore @Override public boolean isServiceAccessAllowedForSso() { if (!this.ssoEnabled) { LOGGER.trace("Service is not authorized to participate in SSO."); } return this.ssoEnabled; } @JsonIgnore @Override public boolean isServiceAccessAllowed() { if (!this.enabled) { LOGGER.trace("Service is not enabled in service registry."); } return this.enabled; } @Override public boolean equals(final Object obj) { if (obj == null) { return false; } if (obj == this) { return true; } if (obj.getClass() != getClass()) { return false; } final DefaultRegisteredServiceAccessStrategy rhs = (DefaultRegisteredServiceAccessStrategy) obj; return new EqualsBuilder() .append(this.enabled, rhs.enabled) .append(this.ssoEnabled, rhs.ssoEnabled) .append(this.requireAllAttributes, rhs.requireAllAttributes) .append(this.requiredAttributes, rhs.requiredAttributes) .append(this.unauthorizedRedirectUrl, rhs.unauthorizedRedirectUrl) .append(this.caseInsensitive, rhs.caseInsensitive) .append(this.rejectedAttributes, rhs.rejectedAttributes) .isEquals(); } @Override public int hashCode() { return new HashCodeBuilder() .append(this.enabled) .append(this.ssoEnabled) .append(this.requireAllAttributes) .append(this.requiredAttributes) .append(this.unauthorizedRedirectUrl) .append(this.caseInsensitive) .append(this.rejectedAttributes) .toHashCode(); } @Override public String toString() { return new ToStringBuilder(this) .append("enabled", this.enabled) .append("ssoEnabled", this.ssoEnabled) .append("requireAllAttributes", this.requireAllAttributes) .append("requiredAttributes", this.requiredAttributes) .append("unauthorizedRedirectUrl", this.unauthorizedRedirectUrl) .append("caseInsensitive", this.caseInsensitive) .append("rejectedAttributes", this.rejectedAttributes) .toString(); } private boolean common(final Map<String, Object> principalAttributes, final Map<String, Set<String>> attributes) { final Set<String> difference = attributes.keySet().stream() .filter(a -> principalAttributes.keySet().contains(a)) .collect(Collectors.toSet()); if (this.requireAllAttributes && difference.size() < attributes.size()) { return false; } return difference.stream().anyMatch(key -> { final Set<String> values = attributes.get(key); final Set<Object> availableValues = CollectionUtils.toCollection(principalAttributes.get(key)); final Pattern pattern = RegexUtils.concatenate(values, this.caseInsensitive); if (pattern != RegexUtils.MATCH_NOTHING_PATTERN) { return availableValues.stream().map(Object::toString).anyMatch(pattern.asPredicate()); } return availableValues.stream().anyMatch(values::contains); }); } }