View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  package org.opencastproject.security.urlsigning.provider.impl;
22  
23  import org.opencastproject.security.api.Organization;
24  import org.opencastproject.security.api.SecurityService;
25  import org.opencastproject.security.urlsigning.exception.UrlSigningException;
26  import org.opencastproject.security.urlsigning.provider.UrlSigningProvider;
27  import org.opencastproject.urlsigning.common.Policy;
28  import org.opencastproject.urlsigning.common.ResourceStrategy;
29  import org.opencastproject.urlsigning.utils.ResourceRequestUtil;
30  
31  import org.apache.commons.lang3.StringUtils;
32  import org.apache.commons.lang3.exception.ExceptionUtils;
33  import org.apache.http.NameValuePair;
34  import org.apache.http.client.utils.URLEncodedUtils;
35  import org.osgi.service.cm.ConfigurationException;
36  import org.osgi.service.cm.ManagedService;
37  import org.slf4j.Logger;
38  
39  import java.net.URI;
40  import java.net.URISyntaxException;
41  import java.nio.charset.StandardCharsets;
42  import java.util.ArrayList;
43  import java.util.Arrays;
44  import java.util.Collections;
45  import java.util.Dictionary;
46  import java.util.Enumeration;
47  import java.util.HashMap;
48  import java.util.List;
49  import java.util.Map;
50  import java.util.Objects;
51  import java.util.Set;
52  import java.util.TreeMap;
53  import java.util.regex.Matcher;
54  import java.util.regex.Pattern;
55  
56  
57  public abstract class AbstractUrlSigningProvider implements UrlSigningProvider, ManagedService {
58    /** The prefix for key configuration keys */
59    public static final String KEY_PROPERTY_PREFIX = "key";
60  
61    /** The attribute name in the configuration file to define the encryption key. */
62    public static final String SECRET = "secret";
63  
64    /** The attribute name in the configuration file to define the matching url. */
65    public static final String URL = "url";
66  
67    /** The attribute name in the configuration file to define the organization owning the key. */
68    public static final String ORGANIZATION = "organization";
69  
70    /** Value indicating that the key can be used by any organization */
71    public static final String ANY_ORGANIZATION = "*";
72  
73    /** The configuration key used for the exlusion list */
74    public static final String EXCLUSION_PROPERTY_KEY = "exclude.url.pattern";
75  
76    /** The security service */
77    protected SecurityService securityService;
78  
79    /**
80     * @return The method that an implementation class will convert base urls to resource urls.
81     */
82    public abstract ResourceStrategy getResourceStrategy();
83  
84    /**
85     * @return The logger to use for this signing provider.
86     */
87    public abstract Logger getLogger();
88  
89    /**
90     * A class representing a URL signing key.
91     */
92    protected static class Key {
93      private String id = null;
94      private String secret = null;
95      private String organizationId = ANY_ORGANIZATION;
96  
97      Key(String id) {
98        this.id = id;
99      }
100 
101     public String getSecret() {
102       return secret;
103     }
104 
105     boolean supports(String organizationId) {
106       return this.organizationId.equals(ANY_ORGANIZATION) || this.organizationId.equals(organizationId);
107     }
108   }
109 
110   /** A mapping of URL prefixes to keys used to lookup keys for a given URL. */
111   protected TreeMap<String, Key> urls = new TreeMap<>();
112 
113   /** A regular expression pattern used to identify URLs that shall not be signed. Can be null */
114   private Pattern exclusionPattern;
115 
116   /**
117    * @param securityService
118    *          the securityService to set
119    */
120   public void setSecurityService(SecurityService securityService) {
121     this.securityService = securityService;
122   }
123 
124   /**
125    * @return The current set of url beginnings this signing provider is looking for.
126    */
127   public Set<String> getUris() {
128     return Collections.unmodifiableSet(urls.keySet());
129   }
130 
131   /**
132    * Get{@link Key} for a given URL.
133    * This method supports multi-tenancy in means of only returning keys that can be used by the current
134    * organization. In case the current organization cannot be determined, no key will be returned.
135    *
136    * @param baseUrl
137    *          The URL that needs to be signed.
138    * @return The {@link Key} if it is available.
139    */
140   protected Key getKey(String baseUrl) {
141     /* Optimization: Use TreeMap.floorEntry that can retrieve the greatest URL equal to or greater than 'baseUrl'
142        in O(log(n)). As we are trying to find an URL that is a prefix of 'baseUrl', candidate.getKey() either is
143        that URL (needs to be checked!) or there is no such URL. */
144     Map.Entry<String, Key> candidate = urls.floorEntry(baseUrl);
145     if (candidate != null && baseUrl.startsWith(candidate.getKey())) {
146       Key key = candidate.getValue();
147 
148       // Don't accept URLs without an organization context
149       // (for example from the ServiceRegistry JobProducerHeartbeat)
150       Organization organization = securityService.getOrganization();
151       if (organization != null && key.supports(organization.getId())) {
152         return key;
153       }
154     }
155     return null;
156   }
157 
158   @Override
159   public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
160     getLogger().info("Updating {}", toString());
161     if (properties == null) {
162       getLogger().warn("{} is unconfigured", toString());
163       return;
164     }
165 
166     // Collect configuration in a new map so we don't partially override the old one in case of error
167     TreeMap<String, Key> urls = new TreeMap<>();
168     Pattern exclusionPattern = null;
169 
170     // Temporary list of key entries to simplify building up the keys
171     Map<String, Key> keys = new HashMap<>();
172 
173     Enumeration<String> propertyKeys = properties.keys();
174     while (propertyKeys.hasMoreElements()) {
175       String propertyKey = propertyKeys.nextElement();
176 
177       if (propertyKey.startsWith(KEY_PROPERTY_PREFIX + ".")) {
178 
179         // We expected the parts [KEY_PROPERTY_PREFIX, id, attribute] or [KEY_PROPERTY_PREFIX, id, URL, name]
180         String[] parts = Arrays.stream(propertyKey.split("\\.")).map(String::trim).toArray(String[]::new);
181         if ((parts.length != 3) && !(parts.length == 4 && URL.equals(parts[2]))) {
182           throw new ConfigurationException(propertyKey, "Wrong property key format");
183         }
184 
185         String propertyValue = StringUtils.trimToNull(Objects.toString(properties.get(propertyKey), null));
186         if (propertyValue == null) {
187           throw new ConfigurationException(propertyKey, "Can't be null or empty");
188         }
189 
190         String id = parts[1];
191         Key currentKey = keys.computeIfAbsent(id, __ -> new Key(id));
192 
193         String attribute = parts[2];
194         switch (attribute) {
195           case ORGANIZATION:
196             currentKey.organizationId = propertyValue;
197             break;
198           case URL:
199             if (urls.keySet().stream().anyMatch(v -> propertyValue.startsWith(v) || (v.startsWith(propertyValue)))) {
200               throw new ConfigurationException(propertyKey,
201                       "There is already a key configuration for a URL with the prefix " + propertyValue);
202             }
203             /* We explicitely support multiple URLs that map to the same key */
204             urls.put(propertyValue, currentKey);
205             break;
206           case SECRET:
207             currentKey.secret = propertyValue;
208             break;
209           default:
210             throw new ConfigurationException(propertyKey, "Unknown attribute " + attribute + " for key " + id);
211         }
212       } else if (EXCLUSION_PROPERTY_KEY.equals(propertyKey)) {
213         String propertyValue = Objects.toString(properties.get(propertyKey), "");
214         if (!StringUtils.isEmpty(propertyValue)) {
215           exclusionPattern = Pattern.compile(propertyValue);
216         }
217         getLogger().debug("Exclusion pattern: {}", propertyValue);
218       }
219     }
220 
221     /* Validate key entries */
222     for (Key key : keys.values()) {
223       if (key.secret == null) {
224         throw new ConfigurationException(key.id, "No secret set");
225       }
226     }
227 
228     // Has the rewriter been fully configured
229     if (urls.size() == 0) {
230       getLogger().info("{} configured to not sign any urls.", toString());
231     } else {
232       getLogger().info("{} configured to sign urls.", toString());
233     }
234 
235     this.urls = urls;
236     this.exclusionPattern = exclusionPattern;
237   }
238 
239   /**
240    * @return true if the url is excluded, false otherwise
241    */
242   private boolean isExcluded(String url) {
243     boolean isExcluded = false;
244     Pattern exclusionPattern = this.exclusionPattern;
245     if (exclusionPattern != null) {
246       Matcher matcher = exclusionPattern.matcher(url);
247       isExcluded = matcher.matches();
248     }
249     return isExcluded;
250   }
251 
252   /**
253    * @return true if the url is valid, false otherwise
254    */
255   private boolean isValid(String url) {
256     try {
257       new URI(url);
258       return true;
259     } catch (URISyntaxException e) {
260       getLogger().debug("Unable to support url {} because", url, e);
261       return false;
262     }
263   }
264 
265   /**
266    * @return true if the url is accepted (is valid, is not excluded and hat a key), false otherwise
267    */
268   @Override
269   public boolean accepts(String baseUrl) {
270     return isValid(baseUrl) && !isExcluded(baseUrl) && getKey(baseUrl) != null;
271   }
272 
273   /**
274    * @return the policy signed
275    */
276   @Override
277   public String sign(Policy policy) throws UrlSigningException {
278     String url = policy.getBaseUrl();
279     Key key = getKey(url);
280     if (isExcluded(url) || key == null) {
281       throw UrlSigningException.urlNotSupported();
282     }
283 
284     policy.setResourceStrategy(getResourceStrategy());
285 
286     try {
287       URI uri = new URI(url);
288       List<NameValuePair> queryStringParameters = new ArrayList<>();
289       if (uri.getQuery() != null) {
290         queryStringParameters = URLEncodedUtils.parse(uri.getQuery(), StandardCharsets.UTF_8);
291       }
292       queryStringParameters.addAll(URLEncodedUtils.parse(
293               ResourceRequestUtil.policyToResourceRequestQueryString(policy, key.id, key.secret),
294               StandardCharsets.UTF_8));
295       return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), uri.getPath(),
296               URLEncodedUtils.format(queryStringParameters, StandardCharsets.UTF_8), null).toString();
297     } catch (Exception e) {
298       getLogger().error("Unable to create signed URL because {}", ExceptionUtils.getStackTrace(e));
299       throw new UrlSigningException(e);
300     }
301   }
302 }