LdapUserProviderInstance.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.userdirectory.ldap;

import org.opencastproject.security.api.JaxbOrganization;
import org.opencastproject.security.api.JaxbRole;
import org.opencastproject.security.api.JaxbUser;
import org.opencastproject.security.api.Organization;
import org.opencastproject.security.api.OrganizationDirectoryService;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.User;
import org.opencastproject.security.api.UserProvider;
import org.opencastproject.userdirectory.JpaGroupRoleProvider;
import org.opencastproject.util.NotFoundException;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.UncheckedExecutionException;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ConfigurationPolicy;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
import org.springframework.security.ldap.search.FilterBasedLdapUserSearch;
import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator;
import org.springframework.security.ldap.userdetails.LdapUserDetailsService;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * A UserProvider that reads user roles from LDAP entries.
 */
@Component(
    immediate = true,
    configurationPid = "org.opencastproject.userdirectory.ldap",
    configurationPolicy = ConfigurationPolicy.REQUIRE,
    service = { UserProvider.class },
    property = {
        "service.description=Provides ldap user directory instances"
    }
)
public class LdapUserProviderInstance implements UserProvider {

  /** The logger */
  private static final Logger logger = LoggerFactory.getLogger(LdapUserProviderInstance.class);

  public static final String PROVIDER_NAME = "ldap";

  /** The key to look up the ldap search filter in the service configuration properties */
  private static final String SEARCH_FILTER_KEY = "org.opencastproject.userdirectory.ldap.searchfilter";

  /** The key to look up the ldap search base in the service configuration properties */
  private static final String SEARCH_BASE_KEY = "org.opencastproject.userdirectory.ldap.searchbase";

  /** The key to look up the ldap server URL in the service configuration properties */
  private static final String LDAP_URL_KEY = "org.opencastproject.userdirectory.ldap.url";

  /** The key to look up the role attributes in the service configuration properties */
  private static final String ROLE_ATTRIBUTES_KEY = "org.opencastproject.userdirectory.ldap.roleattributes";

  /** The key to look up the users name attributes **/
  private static final String USER_NAME_ATTRIBUTES_KEY = "org.opencastproject.userdirectory.ldap.userattributes.name";

  /** The key to look up the users attribute to set its mail address**/
  private static final String USER_MAIL_ATTRIBUTE_KEY = "org.opencastproject.userdirectory.ldap.userattributes.mail";

  /** The key to look up the organization identifier in the service configuration properties */
  private static final String ORGANIZATION_KEY = "org.opencastproject.userdirectory.ldap.org";

  /** The key to look up the user DN to use for performing searches. */
  private static final String SEARCH_USER_DN = "org.opencastproject.userdirectory.ldap.userDn";

  /** The key to look up the password to use for performing searches */
  private static final String SEARCH_PASSWORD = "org.opencastproject.userdirectory.ldap.password";

  /** The key to look up the number of user records to cache */
  private static final String CACHE_SIZE = "org.opencastproject.userdirectory.ldap.cache.size";

  /** The key to look up the number of minutes to cache users */
  private static final String CACHE_EXPIRATION = "org.opencastproject.userdirectory.ldap.cache.expiration";

  /** The key to indicate a prefix that will be added to every role read from the LDAP */
  private static final String ROLE_PREFIX_KEY = "org.opencastproject.userdirectory.ldap.roleprefix";

  /**
   * The key to indicate a comma-separated list of prefixes.
   * The "role prefix" defined with the ROLE_PREFIX_KEY will not be prepended to the roles starting with any of these
   */
  private static final String EXCLUDE_PREFIXES_KEY = "org.opencastproject.userdirectory.ldap.exclude.prefixes";

  /**
   * The key to indicate a prefix,
   * which is used to check whether a roleattribute value shall be added as a group to the user
   */
  private static final String GROUP_CHECK_PREFIX_KEY = "org.opencastproject.userdirectory.ldap.groupcheckprefix";

  /** Specifies, whether the roleattributes should be added as a role */
  private static final String APPLY_ROLEATTRIBUTES_AS_ROLES_KEY
      = "org.opencastproject.userdirectory.ldap.roleattributes.applyasroles";

  /** Specifies, whether the roleattributes should be added as a group */
  private static final String APPLY_ROLEATTRIBUTES_AS_GROUPS_KEY
      = "org.opencastproject.userdirectory.ldap.roleattributes.applyasgroups";

  /** The prefix of the keys, which map a ldap attribute to opencast roles */
  private static final String ATTRIBUTE_MAPPING_KEY_PREFIX = "org.opencastproject.userdirectory.ldap.map.";

  /** The postfix of the attribute maps, which specifiy the value to map */
  private static final String ATTRIBUTE_MAPPING_KEY_POSTFIX_VALUE = "value";

  /** The postfix of the attribute maps, which map a ldap attribute to opencast roles */
  private static final String ATTRIBUTE_MAPPING_KEY_POSTFIX_ROLES = "roles";

  /** The postfix of the attribute maps, which map a ldap attribute to opencast groups */
  private static final String ATTRIBUTE_MAPPING_KEY_POSTFIX_GROUPS = "groups";

  /** The key to indicate whether or not the roles should be converted to uppercase */
  private static final String UPPERCASE_KEY = "org.opencastproject.userdirectory.ldap.uppercase";

  /** The key to indicate a unique identifier for each LDAP connection */
  private static final String INSTANCE_ID_KEY = "org.opencastproject.userdirectory.ldap.id";

  /** The key to indicate a comma-separated list of extra roles to add to the authenticated user */
  private static final String EXTRA_ROLES_KEY = "org.opencastproject.userdirectory.ldap.extra.roles";

  /** The key to setup an LDAP connection ID as an OSGI service property */
  private static final String INSTANCE_ID_SERVICE_PROPERTY_KEY = "instanceId";

  /** The key to setup the organization ID as an OSGI service property */
  private static final String ORGANIZATION_ID_SERVICE_PROPERTY_KEY = "orgId";

  /** The spring ldap userdetails service delegate */
  private LdapUserDetailsService delegate;

  /** The organization id */
  private Organization organization = null;

  /** A cache of users, which lightens the load on the LDAP server */
  private LoadingCache<String, Object> cache = null;

  /** A token to store in the miss cache */
  protected Object nullToken = new Object();

  /** The organization directory service */
  private OrganizationDirectoryService orgDirectory;

  /** The group role provider service */
  private JpaGroupRoleProvider groupRoleProvider;

  /** Opencast's security service */
  private SecurityService securityService;

  /** The registration of the authorities populator registered alongside this user provider */
  private ServiceRegistration authoritiesPopulatorRegistration;

  /** OSGi callback for setting the organization directory service. */
  @Reference
  public void setOrgDirectory(OrganizationDirectoryService orgDirectory) {
    this.orgDirectory = orgDirectory;
  }

  /** OSGi callback for setting the role group service. */
  @Reference
  public void setGroupRoleProvider(JpaGroupRoleProvider groupRoleProvider) {
    this.groupRoleProvider = groupRoleProvider;
  }

  /** OSGi callback for setting the security service. */
  @Reference
  public void setSecurityService(SecurityService securityService) {
    this.securityService = securityService;
  }

  /**
   * Retrieve configuration values and check for a proper value.
   *
   * @param properties
   *      Configuration map
   * @param key
   *      Configuration key to check for
   * @return
   *      The configuration value
   * @throws ConfigurationException
   *      Thrown if the configuration value is blank
   */
  private String getRequiredProperty(final Map<String, Object> properties, final String key)
          throws ConfigurationException {
    final String value = (String) properties.get(key);
    if (StringUtils.isBlank(value)) {
      throw new ConfigurationException(key, "missing configuration value");
    }
    return value;
  }

  // CHECKSTYLE:OFF
  @Activate
  @Modified
  public void updated(BundleContext bundleContext, Map<String, Object> properties) throws ConfigurationException {
    // CHECKSTYLE:ON

    // required settings
    String searchBase = getRequiredProperty(properties, SEARCH_BASE_KEY);
    String searchFilter = getRequiredProperty(properties, SEARCH_FILTER_KEY);
    String url = getRequiredProperty(properties, LDAP_URL_KEY);
    String instanceId = getRequiredProperty(properties, INSTANCE_ID_KEY);
    String roleAttributesGlob = getRequiredProperty(properties, ROLE_ATTRIBUTES_KEY);

    // optional settings
    String organizationId = (String) properties.get(ORGANIZATION_KEY);
    String userDn = (String) properties.get(SEARCH_USER_DN);
    String password = (String) properties.get(SEARCH_PASSWORD);
    String[] userAttributeName = StringUtils.split((String) properties.get(USER_NAME_ATTRIBUTES_KEY), ',');
    String userAttributeMail = (String) properties.get(USER_MAIL_ATTRIBUTE_KEY);

    // optional with default values
    String rolePrefix = Objects.toString(properties.get(ROLE_PREFIX_KEY), "ROLE_");
    String[] excludePrefixes = StringUtils.split((String) properties.get(EXCLUDE_PREFIXES_KEY), ",");
    String groupCheckPrefix = Objects.toString(properties.get(GROUP_CHECK_PREFIX_KEY), "ROLE_GROUP_");
    boolean convertToUppercase = BooleanUtils.toBoolean(Objects.toString(properties.get(UPPERCASE_KEY), "true"));
    int cacheSize = NumberUtils.toInt((String) properties.get(CACHE_SIZE), 1000);
    int cacheExpiration = NumberUtils.toInt((String) properties.get(CACHE_EXPIRATION), 5);
    boolean applyRoleattributesAsRoles = BooleanUtils.toBoolean(Objects.toString(
            properties.get(APPLY_ROLEATTRIBUTES_AS_ROLES_KEY), "true"));
    boolean applyRoleattributesAsGroups = BooleanUtils.toBoolean(Objects.toString(
            properties.get(APPLY_ROLEATTRIBUTES_AS_GROUPS_KEY), "true"));
    if (applyRoleattributesAsGroups && !applyRoleattributesAsRoles) {
      throw new ConfigurationException(APPLY_ROLEATTRIBUTES_AS_GROUPS_KEY,
              "'" + APPLY_ROLEATTRIBUTES_AS_ROLES_KEY + "' needs to be 'true' to enable this option");
    }

    // extra roles
    String[] extraRoles = StringUtils.split(Objects.toString(properties.get(EXTRA_ROLES_KEY), ""), ",");
    Set<String> extraRoleSet = new HashSet<>(Arrays.asList(extraRoles));
    extraRoleSet.addAll(Arrays.asList("ROLE_ANONYMOUS", "ROLE_USER"));
    extraRoles = extraRoleSet.toArray(new String[extraRoles.length]);

    // maps
    HashMap<String, HashMap<String, String>> ldapAssignmentMappingsPreparation = new HashMap();
    for (String key : properties.keySet()) {
      if (key.startsWith(ATTRIBUTE_MAPPING_KEY_PREFIX)) {
        final String[] postfix = key.substring(ATTRIBUTE_MAPPING_KEY_PREFIX.length()).split("\\.");

        if (postfix.length != 2) {
          throw new ConfigurationException(key,
                  "Invalid Configkey format, the following format is needed: "
                  + ATTRIBUTE_MAPPING_KEY_PREFIX + "<identifier>.<key>");
        }

        final String mappingIdentifier = postfix[0];
        final String mappingKey = postfix[1];

        HashMap keyValueMap = ldapAssignmentMappingsPreparation.getOrDefault(mappingIdentifier, new HashMap());

        keyValueMap.put(mappingKey, (String) properties.get(key));

        ldapAssignmentMappingsPreparation.put(mappingIdentifier, keyValueMap);
      }
    }
    HashMap<String, String[]> ldapAssignmentRoleMap = new HashMap();
    HashMap<String, String[]> ldapAssignmentGroupMap = new HashMap();
    for (HashMap.Entry<String, HashMap<String, String>> entry : ldapAssignmentMappingsPreparation.entrySet()) {
      HashMap<String, String> mappingConf = entry.getValue();
      String value = StringUtils.trimToNull(mappingConf.get(ATTRIBUTE_MAPPING_KEY_POSTFIX_VALUE));
      String roles = StringUtils.trimToNull(mappingConf.get(ATTRIBUTE_MAPPING_KEY_POSTFIX_ROLES));
      String groups = StringUtils.trimToNull(mappingConf.get(ATTRIBUTE_MAPPING_KEY_POSTFIX_GROUPS));

      if (value == null) {
        throw new ConfigurationException(ATTRIBUTE_MAPPING_KEY_PREFIX + entry.getKey() + ".*",
                "LDAP mapping incomplete, the key 'value' is needed");
      }
      if (roles == null && groups == null) {
        throw new ConfigurationException(ATTRIBUTE_MAPPING_KEY_PREFIX + entry.getKey() + ".*",
                "LDAP mapping incomplete, one of the keys 'roles' or 'groups' is needed");
      }

      if (convertToUppercase) {
        value = value.toUpperCase();
      }

      if (roles != null) {
        if (convertToUppercase) {
          roles = roles.toUpperCase();
        }
        ldapAssignmentRoleMap.put(value,
                ArrayUtils.addAll(
                        ldapAssignmentRoleMap.getOrDefault(value, new String[0]),
                        Arrays.stream(roles.split(","))
                                .map(r -> StringUtils.trimToNull(r))
                                .filter(r -> r != null)
                                .toArray(String[]::new)
                )
        );
      }

      if (groups != null) {
        if (convertToUppercase) {
          groups = groups.toUpperCase();
        }
        ldapAssignmentGroupMap.put(value,
                ArrayUtils.addAll(
                        ldapAssignmentGroupMap.getOrDefault(value, new String[0]),
                        Arrays.stream(groups.split(","))
                                .map(r -> StringUtils.trimToNull(r))
                                .filter(r -> r != null)
                                .toArray(String[]::new)
                )
        );
      }
    }

    // Defaults to first available organization
    Organization org;
    try {
      if (StringUtils.isNoneBlank(organizationId)) {
        org = orgDirectory.getOrganization(organizationId);
      } else {
        if (orgDirectory.getOrganizations().size() != 1) {
          throw new NotFoundException("Multiple organizations exist but none is specified");
        }
        org = orgDirectory.getOrganizations().get(0);
        organizationId = org.getId();
      }
    } catch (NotFoundException e) {
      throw new ConfigurationException(ORGANIZATION_KEY, "no organization with configured id", e);
    }

    // Remove the previous authorities populator registration, if any, before registering the new one
    if (authoritiesPopulatorRegistration != null) {
      authoritiesPopulatorRegistration.unregister();
      authoritiesPopulatorRegistration = null;
    }

    // Dictionary to include a property to identify this LDAP instance in the security.xml file
    Hashtable<String, String> dict = new Hashtable<>();
    dict.put(INSTANCE_ID_SERVICE_PROPERTY_KEY, instanceId);
    dict.put(ORGANIZATION_ID_SERVICE_PROPERTY_KEY, organizationId);

    OpencastLdapAuthoritiesPopulator authoritiesPopulator = new OpencastLdapAuthoritiesPopulator(roleAttributesGlob,
            rolePrefix, excludePrefixes, groupCheckPrefix, applyRoleattributesAsRoles, applyRoleattributesAsGroups,
            ldapAssignmentRoleMap, ldapAssignmentGroupMap, convertToUppercase, org, securityService,
            groupRoleProvider, extraRoles);

    // Also, register this instance as LdapAuthoritiesPopulator so that it can be used within the security.xml file
    authoritiesPopulatorRegistration =
        bundleContext.registerService(LdapAuthoritiesPopulator.class.getName(), authoritiesPopulator, dict);

    OpencastUserDetailsContextMapper mapper = null;
    if (userAttributeName != null && userAttributeMail != null) {
      mapper = new OpencastUserDetailsContextMapper(userAttributeName, userAttributeMail);
    }

    this.organization = org;
    logger.debug("Creating LdapUserProvider instance with organization=" + organizationId
            + ", to LDAP server at url:  " + url);

    DefaultSpringSecurityContextSource contextSource = new DefaultSpringSecurityContextSource(url);
    if (StringUtils.isNotBlank(userDn)) {
      contextSource.setPassword(password);
      contextSource.setUserDn(userDn);
      // Required so that authentication will actually be used
      contextSource.setAnonymousReadOnly(false);
    } else {
      // No password set so try to connect anonymously.
      contextSource.setAnonymousReadOnly(true);
    }

    try {
      contextSource.afterPropertiesSet();
    } catch (Exception e) {
      throw new org.opencastproject.util.ConfigurationException("Unable to create a spring context source", e);
    }
    FilterBasedLdapUserSearch userSearch = new FilterBasedLdapUserSearch(searchBase, searchFilter, contextSource);
    userSearch.setReturningAttributes(roleAttributesGlob.split(","));

    delegate = new LdapUserDetailsService(userSearch, authoritiesPopulator);

    if (mapper != null) {
      userSearch.setReturningAttributes(
          Stream.of(roleAttributesGlob.split(","), mapper.getAttributes())
              .flatMap(Stream::of)
              .collect(Collectors.toList()).toArray(new String[] { })
      );
      delegate.setUserDetailsMapper(mapper);
    }

    // Setup the caches
    cache = CacheBuilder.newBuilder().maximumSize(cacheSize).expireAfterWrite(cacheExpiration, TimeUnit.MINUTES)
            .build(new CacheLoader<String, Object>() {
              @Override
              public Object load(String id) throws Exception {
                User user = loadUserFromLdap(id);
                return user == null ? nullToken : user;
              }
            });
  }

  @Deactivate
  public void deactivate() {
    if (authoritiesPopulatorRegistration != null) {
      authoritiesPopulatorRegistration.unregister();
      authoritiesPopulatorRegistration = null;
    }
  }

  @Override
  public String getName() {
    return PROVIDER_NAME;
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#getOrganization()
   */
  @Override
  public String getOrganization() {
    return organization.getId();
  }

  /**
   * {@inheritDoc}
   *
   * @see org.opencastproject.security.api.UserProvider#loadUser(java.lang.String)
   */
  @Override
  public User loadUser(String userName) {
    logger.debug("LdapUserProvider is loading user " + userName);
    try {
      // use #getUnchecked since the loader does not throw any checked exceptions
      Object user = cache.getUnchecked(userName);
      if (user == nullToken) {
        return null;
      } else {
        return (JaxbUser) user;
      }
    } catch (UncheckedExecutionException e) {
      logger.warn("Exception while loading user " + userName, e);
      return null;
    }
  }

  /**
   * Loads a user from LDAP.
   *
   * @param userName
   *          the username
   * @return the user
   */
  protected User loadUserFromLdap(String userName) {
    if (delegate == null || cache == null) {
      throw new IllegalStateException("The LDAP user detail service has not yet been configured");
    }
    UserDetails userDetails = null;

    Thread currentThread = Thread.currentThread();
    ClassLoader originalClassloader = currentThread.getContextClassLoader();
    try {
      currentThread.setContextClassLoader(LdapUserProviderInstance.class.getClassLoader());
      try {
        userDetails = delegate.loadUserByUsername(userName);
      } catch (UsernameNotFoundException e) {
        cache.put(userName, nullToken);
        return null;
      }
      JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);

      Set<JaxbRole> roles = userDetails.getAuthorities()
          .stream()
          .map(a -> new JaxbRole(a.getAuthority(), jaxbOrganization))
          .collect(Collectors.toUnmodifiableSet());

      User user;
      if (userDetails instanceof OpencastUserDetails) {
        user = new JaxbUser(userDetails.getUsername(),null,
            ((OpencastUserDetails) userDetails).getName(),
            ((OpencastUserDetails) userDetails).getMail(), PROVIDER_NAME, jaxbOrganization, roles);
      } else {
        user = new JaxbUser(userDetails.getUsername(), PROVIDER_NAME, jaxbOrganization, roles);
      }

      cache.put(userName, user);
      return user;
    } finally {
      currentThread.setContextClassLoader(originalClassloader);
    }
  }

  @Override
  public Iterator<User> findUsers(String query, int offset, int limit) {
    if (query == null) {
      throw new IllegalArgumentException("Query must be set");
    }
    // TODO implement a LDAP wildcard search
    // FIXME We return the current user, rather than an empty list, to make sure the current user's role is displayed in
    // the admin UI (MH-12526).
    User currentUser = securityService.getUser();
    if (loadUser(currentUser.getUsername()) != null) {
      List<User> retVal = new ArrayList<>();
      retVal.add(securityService.getUser());
      return retVal.iterator();
    }
    return Collections.emptyIterator();
  }

  @Override
  public Iterator<User> getUsers() {
    // TODO implement LDAP get all users
    // FIXME We return the current user, rather than an empty list,
    // to make sure the current user's role is displayed in
    // the admin UI (MH-12526).
    User currentUser = securityService.getUser();
    if (loadUser(currentUser.getUsername()) != null) {
      List<User> retVal = new ArrayList<>();
      retVal.add(securityService.getUser());
      return retVal.iterator();
    }
    return Collections.emptyIterator();
  }

  @Override
  public long countUsers() {
    // TODO implement LDAP count users
    // FIXME Because of MH-12526, we return conditionally 1 when the previous methods return the current user
    if (loadUser(securityService.getUser().getUsername()) != null) {
      return 1;
    }
    return 0;
  }

  @Override
  public void invalidate(String userName) {
    cache.invalidate(userName);
  }
}