package org.apereo.cas.support.saml.services.idp.metadata.cache;
import com.google.common.base.Function;
import com.google.common.base.Throwables;
import com.google.common.cache.CacheLoader;
import net.shibboleth.ext.spring.resource.ResourceHelper;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apereo.cas.configuration.CasConfigurationProperties;
import org.apereo.cas.configuration.model.support.saml.idp.SamlIdPProperties;
import org.apereo.cas.support.saml.OpenSamlConfigBean;
import org.apereo.cas.support.saml.SamlException;
import org.apereo.cas.support.saml.SamlUtils;
import org.apereo.cas.support.saml.services.SamlRegisteredService;
import org.apereo.cas.util.EncodingUtils;
import org.apereo.cas.util.RegexUtils;
import org.apereo.cas.util.ResourceUtils;
import org.apereo.cas.util.http.HttpClient;
import org.apereo.cas.util.http.HttpClientMultithreadedDownloader;
import org.opensaml.core.xml.persist.FilesystemLoadSaveManager;
import org.opensaml.saml.metadata.resolver.ChainingMetadataResolver;
import org.opensaml.saml.metadata.resolver.MetadataResolver;
import org.opensaml.saml.metadata.resolver.filter.MetadataFilter;
import org.opensaml.saml.metadata.resolver.filter.MetadataFilterChain;
import org.opensaml.saml.metadata.resolver.filter.impl.EntityRoleFilter;
import org.opensaml.saml.metadata.resolver.filter.impl.PredicateFilter;
import org.opensaml.saml.metadata.resolver.filter.impl.RequiredValidUntilFilter;
import org.opensaml.saml.metadata.resolver.filter.impl.SignatureValidationFilter;
import org.opensaml.saml.metadata.resolver.impl.AbstractMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.DOMMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.FileBackedHTTPMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.FunctionDrivenDynamicHTTPMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.LocalDynamicMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.ResourceBackedMetadataResolver;
import org.opensaml.saml.saml2.metadata.IDPSSODescriptor;
import org.opensaml.saml.saml2.metadata.SPSSODescriptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.UrlResource;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import javax.annotation.Nullable;
import javax.xml.namespace.QName;
import java.io.File;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* This is {@link ChainingMetadataResolverCacheLoader} that uses Guava's cache loading strategy
* to keep track of metadata resources and resolvers. The cache loader here supports loading
* metadata resources from SAML services, supports dynamic metadata queries and is able
* to run various validation filters on the metadata before finally caching the resolver.
*
* @author Misagh Moayyed
* @since 5.0.0
*/
public class ChainingMetadataResolverCacheLoader extends CacheLoader<SamlRegisteredService, ChainingMetadataResolver> {
private static final Logger LOGGER = LoggerFactory.getLogger(ChainingMetadataResolverCacheLoader.class);
/**
* The Config bean.
*/
protected OpenSamlConfigBean configBean;
/**
* The Http client.
*/
protected HttpClient httpClient;
private final transient Object lock = new Object();
@Autowired
private CasConfigurationProperties casProperties;
public ChainingMetadataResolverCacheLoader(final OpenSamlConfigBean configBean,
final HttpClient httpClient) {
this.configBean = configBean;
this.httpClient = httpClient;
}
@Override
public ChainingMetadataResolver load(final SamlRegisteredService service) throws Exception {
try {
final ChainingMetadataResolver metadataResolver = new ChainingMetadataResolver();
final List<MetadataResolver> metadataResolvers = new ArrayList<>();
if (isDynamicMetadataQueryConfigured(service)) {
resolveMetadataDynamically(service, metadataResolvers);
} else {
resolveMetadataFromResource(service, metadataResolvers);
}
if (metadataResolvers.isEmpty()) {
throw new SamlException("No metadata resolvers could be configured for service " + service.getName()
+ " with metadata location " + service.getMetadataLocation());
}
synchronized (this.lock) {
metadataResolver.setId(ChainingMetadataResolver.class.getCanonicalName());
metadataResolver.setResolvers(metadataResolvers);
metadataResolver.initialize();
}
return metadataResolver;
} catch (final Exception e) {
throw new SamlException(e.getMessage(), e);
}
}
/**
* Resolve metadata dynamically.
*
* @param service the service
* @param metadataResolvers the metadata resolvers
* @throws Exception the exception
*/
protected void resolveMetadataDynamically(final SamlRegisteredService service, final List<MetadataResolver> metadataResolvers)
throws Exception {
LOGGER.info("Loading metadata dynamically for [{}]", service.getName());
final SamlIdPProperties.Metadata md = casProperties.getAuthn().getSamlIdp().getMetadata();
final FunctionDrivenDynamicHTTPMetadataResolver resolver =
new FunctionDrivenDynamicHTTPMetadataResolver(this.httpClient.getWrappedHttpClient());
resolver.setMinCacheDuration(TimeUnit.MILLISECONDS.convert(md.getCacheExpirationMinutes(), TimeUnit.MINUTES));
resolver.setRequireValidMetadata(md.isRequireValidMetadata());
if (StringUtils.isNotBlank(md.getBasicAuthnPassword()) && StringUtils.isNotBlank(md.getBasicAuthnUsername())) {
resolver.setBasicCredentials(new UsernamePasswordCredentials(md.getBasicAuthnUsername(), md.getBasicAuthnPassword()));
}
if (!md.getSupportedContentTypes().isEmpty()) {
resolver.setSupportedContentTypes(md.getSupportedContentTypes());
}
resolver.setRequestURLBuilder(new Function<String, String>() {
@Nullable
@Override
public String apply(@Nullable final String input) {
try {
if (StringUtils.isNotBlank(input)) {
final String metadataLocation = service.getMetadataLocation().replace("{0}", EncodingUtils.urlEncode(input));
LOGGER.info("Constructed dynamic metadata query [{}] for [{}]", metadataLocation, service.getName());
return metadataLocation;
}
return null;
} catch (final Exception e) {
throw new RuntimeException(e.getMessage(), e);
}
}
});
buildSingleMetadataResolver(resolver, service);
metadataResolvers.add(resolver);
}
/**
* Resolve metadata from resource.
*
* @param service the service
* @param metadataResolvers the metadata resolvers
* @throws Exception the io exception
*/
protected void resolveMetadataFromResource(final SamlRegisteredService service,
final List<MetadataResolver> metadataResolvers) throws Exception {
final String metadataLocation = service.getMetadataLocation();
LOGGER.info("Loading SAML metadata from [{}]", metadataLocation);
final AbstractResource metadataResource = ResourceUtils.getResourceFrom(metadataLocation);
if (metadataResource instanceof FileSystemResource) {
resolveFileSystemBasedMetadataResource(service, metadataResolvers, metadataResource);
}
if (metadataResource instanceof UrlResource) {
resolveUrlBasedMetadataResource(service, metadataResolvers, metadataResource);
}
if (metadataResource instanceof ClassPathResource) {
resolveClasspathBasedMetadataResource(service, metadataResolvers, metadataLocation, metadataResource);
}
}
private void resolveClasspathBasedMetadataResource(final SamlRegisteredService service,
final List<MetadataResolver> metadataResolvers,
final String metadataLocation,
final AbstractResource metadataResource) {
try (InputStream in = metadataResource.getInputStream()) {
LOGGER.debug("Parsing metadata from [{}]", metadataLocation);
final Document document = this.configBean.getParserPool().parse(in);
final Element metadataRoot = document.getDocumentElement();
final DOMMetadataResolver metadataProvider = new DOMMetadataResolver(metadataRoot);
buildSingleMetadataResolver(metadataProvider, service);
metadataResolvers.add(metadataProvider);
} catch (final Exception e) {
throw Throwables.propagate(e);
}
}
private void resolveUrlBasedMetadataResource(final SamlRegisteredService service,
final List<MetadataResolver> metadataResolvers,
final AbstractResource metadataResource) throws Exception {
final SamlIdPProperties.Metadata md = casProperties.getAuthn().getSamlIdp().getMetadata();
final File backupDirectory = new File(md.getLocation().getFile(), "metadata-backups");
final File backupFile = new File(backupDirectory, metadataResource.getFilename());
LOGGER.debug("Metadata backup directory is designated to be [{}]", backupDirectory.getCanonicalPath());
FileUtils.forceMkdir(backupDirectory);
LOGGER.debug("Metadata backup file will be at [{}]", backupFile.getCanonicalPath());
FileUtils.forceMkdirParent(backupFile);
final HttpClientMultithreadedDownloader downloader =
new HttpClientMultithreadedDownloader(metadataResource, backupFile);
final FileBackedHTTPMetadataResolver metadataProvider = new FileBackedHTTPMetadataResolver(
this.httpClient.getWrappedHttpClient(), metadataResource.getURL().toExternalForm(),
backupFile.getCanonicalPath());
buildSingleMetadataResolver(metadataProvider, service);
metadataResolvers.add(metadataProvider);
}
private void resolveFileSystemBasedMetadataResource(final SamlRegisteredService service,
final List<MetadataResolver> metadataResolvers,
final AbstractResource metadataResource) throws Exception {
final File metadataFile = metadataResource.getFile();
final AbstractMetadataResolver metadataResolver;
if (metadataFile.isDirectory()) {
metadataResolver = new LocalDynamicMetadataResolver(new FilesystemLoadSaveManager<>(metadataFile, configBean.getParserPool()));
} else {
metadataResolver = new ResourceBackedMetadataResolver(ResourceHelper.of(metadataResource));
}
buildSingleMetadataResolver(metadataResolver, service);
metadataResolvers.add(metadataResolver);
}
/**
* Is dynamic metadata query configured ?
*
* @param service the service
* @return true/false
*/
protected boolean isDynamicMetadataQueryConfigured(final SamlRegisteredService service) {
return service.getMetadataLocation().trim().endsWith("/entities/{0}");
}
/**
* Build single metadata resolver metadata resolver.
*
* @param metadataProvider the metadata provider
* @param service the service
* @throws Exception the exception
*/
protected void buildSingleMetadataResolver(final AbstractMetadataResolver metadataProvider,
final SamlRegisteredService service) throws Exception {
final SamlIdPProperties.Metadata md = casProperties.getAuthn().getSamlIdp().getMetadata();
metadataProvider.setParserPool(this.configBean.getParserPool());
metadataProvider.setFailFastInitialization(md.isFailFast());
metadataProvider.setRequireValidMetadata(md.isRequireValidMetadata());
metadataProvider.setId(metadataProvider.getClass().getCanonicalName());
buildMetadataFilters(service, metadataProvider);
LOGGER.info("Initializing metadata resolver from [{}]", service.getMetadataLocation());
metadataProvider.initialize();
LOGGER.info("Initialized metadata resolver from [{}]", service.getMetadataLocation());
}
/**
* Build metadata filters.
*
* @param service the service
* @param metadataProvider the metadata provider
* @throws Exception the exception
*/
protected void buildMetadataFilters(final SamlRegisteredService service, final AbstractMetadataResolver metadataProvider) throws
Exception {
final List<MetadataFilter> metadataFilterList = new ArrayList<>();
buildRequiredValidUntilFilterIfNeeded(service, metadataFilterList);
buildSignatureValidationFilterIfNeeded(service, metadataFilterList);
buildEntityRoleFilterIfNeeded(service, metadataFilterList);
buildPredicateFilterIfNeeded(service, metadataFilterList);
if (!metadataFilterList.isEmpty()) {
final MetadataFilterChain metadataFilterChain = new MetadataFilterChain();
metadataFilterChain.setFilters(metadataFilterList);
LOGGER.debug("Metadata filter chain initialized with [{}] filters", metadataFilterList.size());
metadataProvider.setMetadataFilter(metadataFilterChain);
}
}
private static void buildEntityRoleFilterIfNeeded(final SamlRegisteredService service, final List<MetadataFilter> metadataFilterList) {
if (StringUtils.isNotBlank(service.getMetadataCriteriaRoles())) {
final List<QName> roles = new ArrayList<>();
final Set<String> rolesSet = org.springframework.util.StringUtils.commaDelimitedListToSet(service.getMetadataCriteriaRoles());
rolesSet.stream().forEach(s -> {
if (s.equalsIgnoreCase(SPSSODescriptor.DEFAULT_ELEMENT_NAME.getLocalPart())) {
LOGGER.debug("Added entity role filter [{}]", SPSSODescriptor.DEFAULT_ELEMENT_NAME);
roles.add(SPSSODescriptor.DEFAULT_ELEMENT_NAME);
}
if (s.equalsIgnoreCase(IDPSSODescriptor.DEFAULT_ELEMENT_NAME.getLocalPart())) {
LOGGER.debug("Added entity role filter [{}]", IDPSSODescriptor.DEFAULT_ELEMENT_NAME);
roles.add(IDPSSODescriptor.DEFAULT_ELEMENT_NAME);
}
});
final EntityRoleFilter filter = new EntityRoleFilter(roles);
filter.setRemoveEmptyEntitiesDescriptors(service.isMetadataCriteriaRemoveEmptyEntitiesDescriptors());
filter.setRemoveRolelessEntityDescriptors(service.isMetadataCriteriaRemoveRolelessEntityDescriptors());
metadataFilterList.add(filter);
LOGGER.debug("Added entity role filter with roles [{}]", roles);
}
}
private static void buildPredicateFilterIfNeeded(final SamlRegisteredService service, final List<MetadataFilter> metadataFilterList) {
if (StringUtils.isNotBlank(service.getMetadataCriteriaDirection())
&& StringUtils.isNotBlank(service.getMetadataCriteriaPattern())
&& RegexUtils.isValidRegex(service.getMetadataCriteriaPattern())) {
final PredicateFilter.Direction dir = PredicateFilter.Direction.valueOf(service.getMetadataCriteriaDirection());
LOGGER.debug("Metadata predicate filter configuring with direction [{}] and pattern [{}]",
service.getMetadataCriteriaDirection(), service.getMetadataCriteriaPattern());
final PredicateFilter filter = new PredicateFilter(dir, entityDescriptor ->
StringUtils.isNotBlank(entityDescriptor.getEntityID())
&& entityDescriptor.getEntityID().matches(service.getMetadataCriteriaPattern()));
metadataFilterList.add(filter);
LOGGER.debug("Added metadata predicate filter with direction [{}] and pattern [{}]",
service.getMetadataCriteriaDirection(), service.getMetadataCriteriaPattern());
}
}
/**
* Build signature validation filter if needed.
*
* @param service the service
* @param metadataFilterList the metadata filter list
* @throws Exception the exception
*/
protected void buildSignatureValidationFilterIfNeeded(final SamlRegisteredService service, final List<MetadataFilter>
metadataFilterList) throws Exception {
if (StringUtils.isBlank(service.getMetadataSignatureLocation())) {
LOGGER.warn("No metadata signature location is defined for [{}], so SignatureValidationFilter will not be invoked",
service.getMetadataLocation());
return;
}
final SignatureValidationFilter signatureValidationFilter =
SamlUtils.buildSignatureValidationFilter(service.getMetadataSignatureLocation());
if (signatureValidationFilter != null) {
signatureValidationFilter.setRequireSignedRoot(false);
metadataFilterList.add(signatureValidationFilter);
LOGGER.debug("Added metadata SignatureValidationFilter with signature from [{}]", service.getMetadataSignatureLocation());
} else {
LOGGER.warn("Skipped metadata SignatureValidationFilter since signature from [{}] cannot be located",
service.getMetadataLocation());
}
}
/**
* Build required valid until filter if needed. See {@link RequiredValidUntilFilter}.
*
* @param service the service
* @param metadataFilterList the metadata filter list
*/
protected void buildRequiredValidUntilFilterIfNeeded(final SamlRegisteredService service, final List<MetadataFilter>
metadataFilterList) {
if (service.getMetadataMaxValidity() > 0) {
final RequiredValidUntilFilter requiredValidUntilFilter = new RequiredValidUntilFilter(service.getMetadataMaxValidity());
metadataFilterList.add(requiredValidUntilFilter);
LOGGER.debug("Added metadata RequiredValidUntilFilter with max validity of [{}]", service.getMetadataMaxValidity());
} else {
LOGGER.debug("No metadata maximum validity criteria is defined for [{}], so RequiredValidUntilFilter will not be invoked",
service.getMetadataLocation());
}
}
}