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.aai;
22  
23  import static org.opencastproject.security.api.SecurityConstants.GLOBAL_ADMIN_ROLE;
24  
25  import org.opencastproject.security.api.JaxbOrganization;
26  import org.opencastproject.security.api.JaxbRole;
27  import org.opencastproject.security.api.Organization;
28  import org.opencastproject.security.api.Role;
29  import org.opencastproject.security.api.RoleProvider;
30  import org.opencastproject.security.api.SecurityService;
31  import org.opencastproject.security.api.UserProvider;
32  import org.opencastproject.security.impl.jpa.JpaOrganization;
33  import org.opencastproject.security.impl.jpa.JpaRole;
34  import org.opencastproject.security.impl.jpa.JpaUserReference;
35  import org.opencastproject.security.shibboleth.ShibbolethLoginHandler;
36  import org.opencastproject.userdirectory.api.UserReferenceProvider;
37  
38  import org.apache.commons.lang3.BooleanUtils;
39  import org.apache.commons.lang3.StringUtils;
40  import org.osgi.framework.BundleContext;
41  import org.osgi.framework.FrameworkUtil;
42  import org.osgi.service.cm.ConfigurationException;
43  import org.osgi.service.cm.ManagedService;
44  import org.slf4j.Logger;
45  import org.slf4j.LoggerFactory;
46  import org.springframework.security.core.userdetails.UsernameNotFoundException;
47  
48  import java.nio.charset.StandardCharsets;
49  import java.util.Arrays;
50  import java.util.Collections;
51  import java.util.Date;
52  import java.util.Dictionary;
53  import java.util.HashSet;
54  import java.util.Hashtable;
55  import java.util.Iterator;
56  import java.util.List;
57  import java.util.Set;
58  import java.util.regex.Pattern;
59  
60  import javax.servlet.http.HttpServletRequest;
61  
62  /**
63   * This configurable implementation of the ShibbolethLoginHandler uses the UserReferenceProvider interface to create
64   * and update Opencast reference users provided and authenticated by an external identity provider.
65   * Note that this configurable implementation aims at requiring the minimum number of Shibboleth attributes
66   * to make Opencast work with most Shibboleth-based Authentication and Authorization Infrastractures (AAI).
67   */
68  public class ConfigurableLoginHandler implements ShibbolethLoginHandler, RoleProvider, ManagedService {
69  
70    /** Name of the configuration property that specifies whether AAI authencation is enabled. This is used to avoid log
71        messages in case the module is included in the distribution but not in use */
72    private static final String CFG_AAI_ENABLED_KEY = "enabled";
73  
74    /** Default value of the configuration property CFG_AAI_ENABLED_KEY **/
75    private static final boolean CFG_AAI_ENABLED_DEFAULT = false;
76  
77    /** Name of the configuration property specifying the ID of the bootstrap user. The bootstrap user
78      * will be assigned the global admin role */
79    private static final String CFG_BOOTSTRAP_USER_ID_KEY = "bootstrap.user.id";
80  
81    /** Shibboleth header configuration */
82  
83    /** Name of the configuration property specifying the name of the HTTP request header where the users name can be
84        extracted */
85    private static final String CFG_HEADER_GIVEN_NAME_KEY = "header.given_name";
86  
87    /** Name of the configuration property specifying the name of the HTTP request header where the users surname can be
88        extracted */
89    private static final String CFG_HEADER_SURNAME_KEY = "header.surname";
90  
91    /** Name of the configuration property specifying the name of the HTTP request header where the users e-mail can be
92        extracted */
93    private static final String CFG_HEADER_MAIL_KEY = "header.mail";
94  
95    /** Name of the optional configuration property specifying a list of home organizations */
96    private static final String CFG_HEADER_HOME_ORGANIZATION_KEY = "header.home_organization";
97  
98    /** Name of the optional configuration property specifying the name of the HTTP request header where affiliations
99        can be extracted */
100   private static final String CFG_HEADER_AFFILIATION_KEY = "header.affiliation";
101 
102   /** Shibboleth roles configuration */
103 
104   /**
105    * Name of the configuration property that specifies the prefix of the user role uniquely identifying a Shibboleth
106    * authenticated users. The user role will be of the form ROLE_USER_PREFIX + SHIBBOLETH_UNIQUE_ID
107    */
108   private static final String CFG_ROLE_USER_PREFIX_KEY = "role.user.prefix";
109 
110   /** Default value of configuration property CFG_ROLE_USER_PREFIX_KEY */
111   private static final String CFG_ROLE_USER_PREFIX_DEFAULT = "ROLE_AAI_USER_";
112 
113   /** The organization membership role indicates that a user belong to a specific AAI home organization.
114       It has the from: valueOf(role.organization.prefix) + homeOrganization + valueOf(role.organization.suffix) */
115 
116   /** Name of the configuration property that specifies the prefix of the organization membership role */
117   private static final String CFG_ROLE_ORGANIZATION_PREFIX_KEY = "role.organization.prefix";
118 
119   /** Default value of configuration property CFG_ROLE_ORGANIZATION_PREFIX_KEY */
120   private static final String CFG_ROLE_ORGANIZATION_PREFIX_DEFAULT = "ROLE_AAI_ORG_";
121 
122   /** Name of the configuration property that specifies the prefix of the organization membership role */
123   private static final String CFG_ROLE_ORGANIZATION_SUFFIX_KEY = "role.organization.suffix";
124 
125   /** Default value of configuration property CFG_ROLE_ORGANIZATION_SUFFIX_KEY */
126   private static final String CFG_ROLE_ORGANIZATION_SUFFIX_DEFAULT = "_MEMBER";
127 
128   /** Name of the configuration property that specifies the name of the role assigned to all Shibboleth authenticated
129       users, i.e. members of an Sibboleth federation */
130   private static final String CFG_ROLE_FEDERATION_KEY = "role.federation";
131 
132   /** Default value of the configuration property CFG_ROLE_FEDERATION_KEY */
133   private static final String CFG_ROLE_FEDERATION_DEFAULT = "ROLE_AAI_USER";
134 
135   /**
136    * Name of the configuration property that specifies the prefix of the affiliation role for Shibboleth
137    * authenticated users. The role will be of the form ROLE_AFFILIATION_PREFIX + SHIBBOLETH_EDUPERSONAFFILIATION
138    */
139   private static final String CFG_ROLE_AFFILIATION_PREFIX_KEY = "role.affiliation.prefix";
140 
141   /** Default value of configuration property CFG_ROLE_USER_PREFIX_KEY */
142   private static final String CFG_ROLE_AFFILIATION_PREFIX_DEFAULT = "ROLE_AAI_USER_AFFILIATION_";
143 
144   /** The logging facility */
145   private static final Logger logger = LoggerFactory.getLogger(ConfigurableLoginHandler.class);
146 
147   /** The user reference provider */
148   private UserReferenceProvider userReferenceProvider = null;
149 
150   /** The security service */
151   private SecurityService securityService = null;
152 
153   /** Whether the configurable Shibboleth login handler */
154   private boolean enabled = CFG_AAI_ENABLED_DEFAULT;
155 
156   /** The ID of the bootstrap user if configured */
157   private String bootstrapUserId = null;
158 
159   /** Header to extract the given name (first name) from */
160   private String headerGivenName = null;
161 
162   /** Header to extract to surname */
163   private String headerSurname = null;
164 
165   /** Header to extract the e-mail address */
166   private String headerMail = null;
167 
168   /** Header to extract the home organization */
169   private String headerHomeOrganization = null;
170 
171   /** Header to extract the affiliation */
172   private String headerAffiliation = null;
173 
174   /** Role assigned to all Shibboleth authenticated users */
175   private String roleFederationMember = CFG_ROLE_FEDERATION_DEFAULT;
176 
177   /** Prefix of unique Shibboleth user role */
178   private String roleUserPrefix = CFG_ROLE_USER_PREFIX_DEFAULT;
179 
180   /** Prefix of the home organization membership role */
181   private String roleOrganizationPrefix = CFG_ROLE_ORGANIZATION_PREFIX_DEFAULT;
182 
183   /** Suffix of the home organization membership role */
184   private String roleOrganizationSuffix = CFG_ROLE_ORGANIZATION_SUFFIX_DEFAULT;
185 
186   /** Prefix of the affiliation role */
187   private String roleAffiliationPrefix = CFG_ROLE_AFFILIATION_PREFIX_DEFAULT;
188 
189   /*
190    * It is the bundle kernel what will need to instantiate the ConfigurableLoginHandler
191    * since it is wired using Spring Security.
192    * Since Shibboleth support is supposed to be an optional extension of the bundle kernel,
193    * we implement this as fragment bundle.
194    * The use of the Service Component Runtime (SCR) would require us to declare this bundle as service
195    * component in kernel which we don't want since it is optional.
196    * To make us visible to the config admin and take advantage of the ManagedService mechanism, we
197    * register us as ManagedService in the constructor.
198    * An alternative solution would be to include the manifest of all fragments in kernel, i.e.
199    * by specifying OSGI-INF/*.xml as service component in kernel.
200    */
201   public ConfigurableLoginHandler() {
202     BundleContext bundleContext = FrameworkUtil.getBundle(this.getClass()).getBundleContext();
203     registerAsManagedService(bundleContext);
204   }
205 
206   protected ConfigurableLoginHandler(BundleContext bundleContext) {
207     registerAsManagedService(bundleContext);
208   }
209 
210   private void registerAsManagedService(BundleContext bundleContext) {
211     Dictionary<String, String> properties = new Hashtable<String, String>();
212     properties.put("service.pid", this.getClass().getName());
213     bundleContext.registerService(ManagedService.class.getName(), this, properties);
214   }
215 
216   @Override
217   public void updated(Dictionary properties) throws ConfigurationException {
218     if (properties == null) {
219       return;
220     }
221 
222     String cfgEnabled = StringUtils.trimToNull((String) properties.get(CFG_AAI_ENABLED_KEY));
223     if (cfgEnabled != null) {
224       enabled = BooleanUtils.toBoolean(cfgEnabled);
225     }
226 
227     if (enabled) {
228       logger.info("AAI login handler is enabled.");
229     } else {
230       logger.info("AAI login handler is disabled.");
231       return;
232     }
233 
234     String cfgBootstrapUserId = StringUtils.trimToNull((String) properties.get(CFG_BOOTSTRAP_USER_ID_KEY));
235     if (cfgBootstrapUserId != null) {
236       bootstrapUserId = cfgBootstrapUserId;
237       logger.warn("AAI User ID '{}' is configured as AAI boostrap user. You want to disable this after bootstrapping.",
238               bootstrapUserId);
239     } else {
240       bootstrapUserId = null;
241     }
242 
243     /* Shibboleth header configuration */
244 
245     String cfgGivenName = StringUtils.trimToNull((String) properties.get(CFG_HEADER_GIVEN_NAME_KEY));
246     if (cfgGivenName != null) {
247       headerGivenName = cfgGivenName;
248       logger.info("Header '{}' set to '{}'", CFG_HEADER_GIVEN_NAME_KEY, headerGivenName);
249     } else {
250       logger.error("Header '{}' is not configured ", CFG_HEADER_GIVEN_NAME_KEY);
251     }
252 
253     String cfgSurname = StringUtils.trimToNull((String) properties.get(CFG_HEADER_SURNAME_KEY));
254     if (cfgSurname != null) {
255       headerSurname = cfgSurname;
256       logger.info("Header '{}' set to '{}'", CFG_HEADER_SURNAME_KEY, headerSurname);
257     } else {
258       logger.error("Header '{}' is not configured ", CFG_HEADER_SURNAME_KEY);
259     }
260 
261     String cfgMail = StringUtils.trimToNull((String) properties.get(CFG_HEADER_MAIL_KEY));
262     if (cfgMail != null) {
263       headerMail = cfgMail;
264       logger.info("Header '{}' set to '{}'", CFG_HEADER_MAIL_KEY, headerMail);
265     } else {
266       logger.error("Header '{}' is not configured ", CFG_HEADER_MAIL_KEY);
267     }
268 
269     String cfgHomeOrganization = StringUtils.trimToNull((String) properties.get(CFG_HEADER_HOME_ORGANIZATION_KEY));
270     if (cfgHomeOrganization != null) {
271       headerHomeOrganization = cfgHomeOrganization;
272       logger.info("Header '{}' set to '{}'", CFG_HEADER_HOME_ORGANIZATION_KEY, headerHomeOrganization);
273     } else {
274       logger.warn("Optional header '{}' is not configured ", CFG_HEADER_HOME_ORGANIZATION_KEY);
275     }
276 
277     String cfgAffiliation = StringUtils.trimToNull((String) properties.get(CFG_HEADER_AFFILIATION_KEY));
278     if (cfgAffiliation != null) {
279       headerAffiliation = cfgAffiliation;
280       logger.info("Header '{}' set to '{}'", CFG_HEADER_AFFILIATION_KEY, headerAffiliation);
281     } else {
282       logger.warn("Optional header '{}' is not configured ", CFG_HEADER_AFFILIATION_KEY);
283     }
284 
285     /* Shibboleth roles configuration */
286 
287     String cfgRoleFederationMember = StringUtils.trimToNull((String) properties.get(CFG_ROLE_FEDERATION_KEY));
288     if (cfgRoleFederationMember != null) {
289       roleFederationMember = cfgRoleFederationMember;
290       logger.info("AAI federation membership role '{}' set to '{}'", CFG_ROLE_FEDERATION_KEY,
291               roleFederationMember);
292     } else {
293       roleFederationMember = CFG_ROLE_FEDERATION_DEFAULT;
294       logger.info("AAI federation membership role '{}' is not configured, using default '{}'",
295               CFG_ROLE_FEDERATION_KEY, roleFederationMember);
296     }
297 
298     String cfgRoleUserPrefix = StringUtils.trimToNull((String) properties.get(CFG_ROLE_USER_PREFIX_KEY));
299     if (cfgRoleUserPrefix != null) {
300       roleUserPrefix = cfgRoleUserPrefix;
301       logger.info("AAI user role prefix '{}' set to '{}'", CFG_ROLE_USER_PREFIX_KEY, roleUserPrefix);
302     } else {
303       roleUserPrefix = CFG_ROLE_USER_PREFIX_DEFAULT;
304       logger.info("AAI user role prefix '{}' is not configured, using default '{}'", CFG_ROLE_USER_PREFIX_KEY,
305               roleUserPrefix);
306     }
307 
308     String cfgRoleOrganizationPrefix = StringUtils.trimToNull((String) properties.get(
309             CFG_ROLE_ORGANIZATION_PREFIX_KEY));
310     if (cfgRoleOrganizationPrefix != null) {
311       roleOrganizationPrefix = cfgRoleOrganizationPrefix;
312       logger.info("AAI organization membership role prefix '{}' set to '{}'", CFG_ROLE_ORGANIZATION_PREFIX_KEY,
313               cfgRoleOrganizationPrefix);
314     } else {
315       roleOrganizationPrefix = CFG_ROLE_ORGANIZATION_PREFIX_DEFAULT;
316       logger.info("AAI organization membership role prefix '{}' is not configured, using default '{}'",
317               CFG_ROLE_ORGANIZATION_PREFIX_KEY, roleOrganizationPrefix);
318     }
319 
320     String cfgRoleOrganizationSuffix = StringUtils.trimToNull((String) properties.get(
321             CFG_ROLE_ORGANIZATION_SUFFIX_KEY));
322     if (cfgRoleOrganizationSuffix != null) {
323       roleOrganizationSuffix = cfgRoleOrganizationSuffix;
324       logger.info("AAI organization membership role suffix '{}' set to '{}'", CFG_ROLE_ORGANIZATION_SUFFIX_KEY,
325               cfgRoleOrganizationSuffix);
326     } else {
327       roleOrganizationSuffix = CFG_ROLE_ORGANIZATION_SUFFIX_DEFAULT;
328       logger.info("AAI organization membership role suffix '{}' is not configured, using default '{}'",
329               CFG_ROLE_ORGANIZATION_SUFFIX_KEY, roleOrganizationSuffix);
330     }
331 
332     String cfgRoleAffiliationPrefix = StringUtils.trimToNull((String) properties.get(
333             CFG_ROLE_AFFILIATION_PREFIX_KEY));
334     if (cfgRoleAffiliationPrefix != null) {
335       roleAffiliationPrefix = cfgRoleAffiliationPrefix;
336       logger.info("AAI affiliation role prefix '{}' set to '{}'", CFG_ROLE_AFFILIATION_PREFIX_KEY,
337               cfgRoleAffiliationPrefix);
338     } else {
339       roleAffiliationPrefix = CFG_ROLE_AFFILIATION_PREFIX_DEFAULT;
340       logger.info("AAI affiliation role prefix '{}' is not configured, using default '{}'",
341               CFG_ROLE_AFFILIATION_PREFIX_KEY, roleAffiliationPrefix);
342     }
343   }
344 
345   /**
346    * Handle a new user login.
347    *
348    * @param id
349    *          The identity of the user, ideally the Shibboleth persistent unique identifier
350    * @param request
351    *          The request, for accessing any other Shibboleth variables
352    */
353   @Override
354   public void newUserLogin(String id, HttpServletRequest request) {
355     String name = extractName(request);
356     String email = extractEmail(request);
357     Date loginDate = new Date();
358     JpaOrganization organization = fromOrganization(securityService.getOrganization());
359 
360     // Compile the list of roles
361     Set<JpaRole> roles = extractRoles(id, request);
362 
363     // Create the user reference
364     JpaUserReference userReference = new JpaUserReference(id, name, email, MECH_SHIBBOLETH, loginDate, organization,
365             roles);
366 
367     logger.debug("Shibboleth user '{}' logged in for the first time", id);
368     userReferenceProvider.addUserReference(userReference, MECH_SHIBBOLETH);
369   }
370 
371   /**
372    * Handle an existing user login.
373    *
374    * @param id
375    *          The identity of the user, ideally the Shibboleth persistent unique identifier
376    * @param request
377    *          The request, for accessing any other Shibboleth variables
378    */
379   @Override
380   public void existingUserLogin(String id, HttpServletRequest request) {
381     Organization organization = securityService.getOrganization();
382 
383     // Load the user reference
384     JpaUserReference userReference = userReferenceProvider.findUserReference(id, organization.getId());
385     if (userReference == null) {
386       throw new UsernameNotFoundException("User reference '" + id + "' was not found");
387     }
388 
389     // Update the reference
390     userReference.setName(extractName(request));
391     userReference.setEmail(extractEmail(request));
392     userReference.setLastLogin(new Date());
393     Set<JpaRole> roles = extractRoles(id, request);
394     userReference.setRoles(roles);
395 
396     logger.debug("Shibboleth user '{}' logged in", id);
397     userReferenceProvider.updateUserReference(userReference);
398   }
399 
400   /**
401    * Sets the security service.
402    *
403    * @param securityService
404    *          the security service
405    */
406   public void setSecurityService(SecurityService securityService) {
407     this.securityService = securityService;
408   }
409 
410   /**
411    * Sets the user reference provider.
412    *
413    * @param userReferenceProvider
414    *          the user reference provider
415    */
416   public void setUserReferenceProvider(UserReferenceProvider userReferenceProvider) {
417     this.userReferenceProvider = userReferenceProvider;
418   }
419 
420   /**
421    * Extracts the name from the request.
422    *
423    * @param request
424    *          the request
425    * @return the name
426    */
427   private String extractName(HttpServletRequest request) {
428     String givenName = StringUtils.isBlank(request.getHeader(headerGivenName)) ? ""
429             : new String(request.getHeader(headerGivenName).getBytes(StandardCharsets.ISO_8859_1),
430                     StandardCharsets.UTF_8);
431     String surname = StringUtils.isBlank(request.getHeader(headerSurname)) ? ""
432             : new String(request.getHeader(headerSurname).getBytes(StandardCharsets.ISO_8859_1),
433                     StandardCharsets.UTF_8);
434 
435     return StringUtils.join(new String[] { givenName, surname }, " ");
436   }
437 
438   /**
439    * Extracts the e-mail from the request.
440    *
441    * @param request
442    *          the request
443    * @return the e-mail address
444    */
445   private String extractEmail(HttpServletRequest request) {
446     return request.getHeader(headerMail);
447   }
448 
449   /**
450    * Extracts the roles from the request.
451    *
452    * @param request
453    *          the request
454    * @return the roles
455    */
456   private Set<JpaRole> extractRoles(String id, HttpServletRequest request) {
457     JpaOrganization organization = fromOrganization(securityService.getOrganization());
458     Set<JpaRole> roles = new HashSet<JpaRole>();
459     roles.add(new JpaRole(roleFederationMember, organization));
460     roles.add(new JpaRole(roleUserPrefix + id, organization));
461     roles.add(new JpaRole(organization.getAnonymousRole(), organization));
462     if (headerHomeOrganization != null) {
463       String homeOrganization = request.getHeader(headerHomeOrganization);
464       roles.add(new JpaRole(roleOrganizationPrefix + homeOrganization + roleOrganizationSuffix, organization));
465     }
466     if (StringUtils.equals(id, bootstrapUserId)) {
467       roles.add(new JpaRole(GLOBAL_ADMIN_ROLE, organization));
468     }
469     if (headerAffiliation != null) {
470       String affiliation = request.getHeader(headerAffiliation);
471       if (affiliation != null) {
472         List<String> affiliations = Arrays.asList(affiliation.split(";"));
473         for (String eachAffiliation : affiliations) {
474           roles.add(new JpaRole(roleAffiliationPrefix + eachAffiliation, organization));
475         }
476       }
477     }
478 
479     return roles;
480   }
481 
482   /**
483    * Creates a JpaOrganization from an organization
484    *
485    * @param org
486    *          the organization
487    */
488   private JpaOrganization fromOrganization(Organization org) {
489     if (org instanceof JpaOrganization) {
490       return (JpaOrganization) org;
491     } else {
492       return new JpaOrganization(org.getId(), org.getName(), org.getServers(), org.getAdminRole(),
493            org.getAnonymousRole(), org.getProperties());
494     }
495   }
496 
497   /**
498    * @see org.opencastproject.security.api.RoleProvider#getRolesForUser(String)
499    */
500   @Override
501   public List<Role> getRolesForUser(String userName) {
502     return Collections.emptyList();
503   }
504 
505   /**
506    * @see org.opencastproject.security.api.RoleProvider#getOrganization()
507    */
508   @Override
509   public String getOrganization() {
510     return UserProvider.ALL_ORGANIZATIONS;
511   }
512 
513   /**
514    * @see org.opencastproject.security.api.RoleProvider#findRoles(String, Role.Target, int, int)
515    */
516   @Override
517   public Iterator<Role> findRoles(String query, Role.Target target, int offset, int limit) {
518     if (query == null) {
519       throw new IllegalArgumentException("Query must be set");
520     }
521     JaxbOrganization organization = JaxbOrganization.fromOrganization(securityService.getOrganization());
522     HashSet<Role> roles = new HashSet<>(2);
523     final String[] roleNames = new String[] {roleFederationMember, organization.getAnonymousRole()};
524     for (String name: roleNames) {
525       if (like(name, query)) {
526         roles.add(new JaxbRole(name, organization));
527       }
528     }
529     return offsetLimitCollection(offset, limit, roles).iterator();
530   }
531 
532   private <T> HashSet<T> offsetLimitCollection(int offset, int limit, HashSet<T> entries) {
533     HashSet<T> result = new HashSet<T>();
534     int i = 0;
535     for (T entry : entries) {
536       if (limit != 0 && result.size() >= limit) {
537         break;
538       }
539       if (i >= offset) {
540         result.add(entry);
541       }
542       i++;
543     }
544     return result;
545   }
546 
547   private boolean like(String string, final String query) {
548     if (string == null) {
549       return false;
550     }
551     String regex = query.replace("_", ".").replace("%", ".*?");
552     Pattern p = Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
553     return p.matcher(string).matches();
554   }
555 
556 }