AbstractUrlSigningProvider.java

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community 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://opensource.org/licenses/ecl2.txt
 *
 * 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.opencastproject.security.urlsigning.provider.impl;

import org.opencastproject.security.api.Organization;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.urlsigning.exception.UrlSigningException;
import org.opencastproject.security.urlsigning.provider.UrlSigningProvider;
import org.opencastproject.urlsigning.common.Policy;
import org.opencastproject.urlsigning.common.ResourceStrategy;
import org.opencastproject.urlsigning.utils.ResourceRequestUtil;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
import org.slf4j.Logger;

import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Dictionary;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;


public abstract class AbstractUrlSigningProvider implements UrlSigningProvider, ManagedService {
  /** The prefix for key configuration keys */
  public static final String KEY_PROPERTY_PREFIX = "key";

  /** The attribute name in the configuration file to define the encryption key. */
  public static final String SECRET = "secret";

  /** The attribute name in the configuration file to define the matching url. */
  public static final String URL = "url";

  /** The attribute name in the configuration file to define the organization owning the key. */
  public static final String ORGANIZATION = "organization";

  /** Value indicating that the key can be used by any organization */
  public static final String ANY_ORGANIZATION = "*";

  /** The configuration key used for the exlusion list */
  public static final String EXCLUSION_PROPERTY_KEY = "exclude.url.pattern";

  /** The security service */
  protected SecurityService securityService;

  /**
   * @return The method that an implementation class will convert base urls to resource urls.
   */
  public abstract ResourceStrategy getResourceStrategy();

  /**
   * @return The logger to use for this signing provider.
   */
  public abstract Logger getLogger();

  /**
   * A class representing a URL signing key.
   */
  protected static class Key {
    private String id = null;
    private String secret = null;
    private String organizationId = ANY_ORGANIZATION;

    Key(String id) {
      this.id = id;
    }

    public String getSecret() {
      return secret;
    }

    boolean supports(String organizationId) {
      return this.organizationId.equals(ANY_ORGANIZATION) || this.organizationId.equals(organizationId);
    }
  }

  /** A mapping of URL prefixes to keys used to lookup keys for a given URL. */
  protected TreeMap<String, Key> urls = new TreeMap<>();

  /** A regular expression pattern used to identify URLs that shall not be signed. Can be null */
  private Pattern exclusionPattern;

  /**
   * @param securityService
   *          the securityService to set
   */
  public void setSecurityService(SecurityService securityService) {
    this.securityService = securityService;
  }

  /**
   * @return The current set of url beginnings this signing provider is looking for.
   */
  public Set<String> getUris() {
    return Collections.unmodifiableSet(urls.keySet());
  }

  /**
   * Get{@link Key} for a given URL.
   * This method supports multi-tenancy in means of only returning keys that can be used by the current
   * organization. In case the current organization cannot be determined, no key will be returned.
   *
   * @param baseUrl
   *          The URL that needs to be signed.
   * @return The {@link Key} if it is available.
   */
  protected Key getKey(String baseUrl) {
    /* Optimization: Use TreeMap.floorEntry that can retrieve the greatest URL equal to or greater than 'baseUrl'
       in O(log(n)). As we are trying to find an URL that is a prefix of 'baseUrl', candidate.getKey() either is
       that URL (needs to be checked!) or there is no such URL. */
    Map.Entry<String, Key> candidate = urls.floorEntry(baseUrl);
    if (candidate != null && baseUrl.startsWith(candidate.getKey())) {
      Key key = candidate.getValue();

      // Don't accept URLs without an organization context
      // (for example from the ServiceRegistry JobProducerHeartbeat)
      Organization organization = securityService.getOrganization();
      if (organization != null && key.supports(organization.getId())) {
        return key;
      }
    }
    return null;
  }

  @Override
  public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
    getLogger().info("Updating {}", toString());
    if (properties == null) {
      getLogger().warn("{} is unconfigured", toString());
      return;
    }

    // Collect configuration in a new map so we don't partially override the old one in case of error
    TreeMap<String, Key> urls = new TreeMap<>();
    Pattern exclusionPattern = null;

    // Temporary list of key entries to simplify building up the keys
    Map<String, Key> keys = new HashMap<>();

    Enumeration<String> propertyKeys = properties.keys();
    while (propertyKeys.hasMoreElements()) {
      String propertyKey = propertyKeys.nextElement();

      if (propertyKey.startsWith(KEY_PROPERTY_PREFIX + ".")) {

        // We expected the parts [KEY_PROPERTY_PREFIX, id, attribute] or [KEY_PROPERTY_PREFIX, id, URL, name]
        String[] parts = Arrays.stream(propertyKey.split("\\.")).map(String::trim).toArray(String[]::new);
        if ((parts.length != 3) && !(parts.length == 4 && URL.equals(parts[2]))) {
          throw new ConfigurationException(propertyKey, "Wrong property key format");
        }

        String propertyValue = StringUtils.trimToNull(Objects.toString(properties.get(propertyKey), null));
        if (propertyValue == null) {
          throw new ConfigurationException(propertyKey, "Can't be null or empty");
        }

        String id = parts[1];
        Key currentKey = keys.computeIfAbsent(id, __ -> new Key(id));

        String attribute = parts[2];
        switch (attribute) {
          case ORGANIZATION:
            currentKey.organizationId = propertyValue;
            break;
          case URL:
            if (urls.keySet().stream().anyMatch(v -> propertyValue.startsWith(v) || (v.startsWith(propertyValue)))) {
              throw new ConfigurationException(propertyKey,
                      "There is already a key configuration for a URL with the prefix " + propertyValue);
            }
            /* We explicitely support multiple URLs that map to the same key */
            urls.put(propertyValue, currentKey);
            break;
          case SECRET:
            currentKey.secret = propertyValue;
            break;
          default:
            throw new ConfigurationException(propertyKey, "Unknown attribute " + attribute + " for key " + id);
        }
      } else if (EXCLUSION_PROPERTY_KEY.equals(propertyKey)) {
        String propertyValue = Objects.toString(properties.get(propertyKey), "");
        if (!StringUtils.isEmpty(propertyValue)) {
          exclusionPattern = Pattern.compile(propertyValue);
        }
        getLogger().debug("Exclusion pattern: {}", propertyValue);
      }
    }

    /* Validate key entries */
    for (Key key : keys.values()) {
      if (key.secret == null) {
        throw new ConfigurationException(key.id, "No secret set");
      }
    }

    // Has the rewriter been fully configured
    if (urls.size() == 0) {
      getLogger().info("{} configured to not sign any urls.", toString());
    } else {
      getLogger().info("{} configured to sign urls.", toString());
    }

    this.urls = urls;
    this.exclusionPattern = exclusionPattern;
  }

  /**
   * @return true if the url is excluded, false otherwise
   */
  private boolean isExcluded(String url) {
    boolean isExcluded = false;
    Pattern exclusionPattern = this.exclusionPattern;
    if (exclusionPattern != null) {
      Matcher matcher = exclusionPattern.matcher(url);
      isExcluded = matcher.matches();
    }
    return isExcluded;
  }

  /**
   * @return true if the url is valid, false otherwise
   */
  private boolean isValid(String url) {
    try {
      new URI(url);
      return true;
    } catch (URISyntaxException e) {
      getLogger().debug("Unable to support url {} because", url, e);
      return false;
    }
  }

  /**
   * @return true if the url is accepted (is valid, is not excluded and hat a key), false otherwise
   */
  @Override
  public boolean accepts(String baseUrl) {
    return isValid(baseUrl) && !isExcluded(baseUrl) && getKey(baseUrl) != null;
  }

  /**
   * @return the policy signed
   */
  @Override
  public String sign(Policy policy) throws UrlSigningException {
    String url = policy.getBaseUrl();
    Key key = getKey(url);
    if (isExcluded(url) || key == null) {
      throw UrlSigningException.urlNotSupported();
    }

    policy.setResourceStrategy(getResourceStrategy());

    try {
      URI uri = new URI(url);
      List<NameValuePair> queryStringParameters = new ArrayList<>();
      if (uri.getQuery() != null) {
        queryStringParameters = URLEncodedUtils.parse(uri.getQuery(), StandardCharsets.UTF_8);
      }
      queryStringParameters.addAll(URLEncodedUtils.parse(
              ResourceRequestUtil.policyToResourceRequestQueryString(policy, key.id, key.secret),
              StandardCharsets.UTF_8));
      return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), uri.getPath(),
              URLEncodedUtils.format(queryStringParameters, StandardCharsets.UTF_8), null).toString();
    } catch (Exception e) {
      getLogger().error("Unable to create signed URL because {}", ExceptionUtils.getStackTrace(e));
      throw new UrlSigningException(e);
    }
  }
}