All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.opencastproject.security.lti.LtiLaunchAuthenticationHandler Maven / Gradle / Ivy

/*
 * 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.lti;

import org.opencastproject.security.api.Organization;
import org.opencastproject.security.api.SecurityConstants;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.impl.jpa.JpaOrganization;
import org.opencastproject.security.impl.jpa.JpaRole;
import org.opencastproject.security.impl.jpa.JpaUserReference;
import org.opencastproject.security.util.SecurityUtil;
import org.opencastproject.userdirectory.api.UserReferenceProvider;

import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth.provider.ConsumerAuthentication;
import org.springframework.security.oauth.provider.OAuthAuthenticationHandler;
import org.springframework.security.oauth.provider.token.OAuthAccessProviderToken;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;

import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;

import javax.persistence.RollbackException;
import javax.servlet.http.HttpServletRequest;

@Component(
        property = {
                "service.description=Lti User Login"
        },
        immediate = true,
        service = { LtiLaunchAuthenticationHandler.class, OAuthAuthenticationHandler.class }
)

/**
 * Callback interface for handing authentication details that are used when an authenticated request for a protected
 * resource is received.
 */
public class LtiLaunchAuthenticationHandler implements OAuthAuthenticationHandler {

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

  /** The Http request parameter, sent by the LTI consumer, containing the user ID. */
  private static final String LTI_USER_ID_PARAM = "user_id";

  /** The http request parameter containing the Consumer GUI **/
  private static final String LTI_CONSUMER_GUID = "tool_consumer_instance_guid";

  /** LTI field containing a comma delimited list of roles */
  private static final String ROLES = "roles";

  /** The LTI field containing the context_id */
  private static final String CONTEXT_ID = "context_id";

  /** The prefix for LTI user ids */
  private static final String LTI_USER_ID_PREFIX = "lti";

  /** The delimiter to use in generated OAUTH id's **/
  private static final String LTI_ID_DELIMITER = ":";

  /** The Opencast Role for OAUTH users **/
  private static final String ROLE_OAUTH_USER = "ROLE_OAUTH_USER";

  /** The default context for LTI **/
  private static final String DEFAULT_CONTEXT = "LTI";

  /** The default learner for LTI **/
  private static final String DEFAULT_LEARNER = "USER";

  /** The prefix of the key to look up a consumer key. */
  private static final String HIGHLY_TRUSTED_CONSUMER_KEY_PREFIX = "lti.oauth.highly_trusted_consumer_key.";

  /** The prefix of the key to look up a blacklisted user. */
  private static final String BLACKLIST_USER_PREFIX = "lti.blacklist.user.";

  /** The key to look up whether the admin user should be able to authenticate via LTI **/
  private static final String ALLOW_SYSTEM_ADMINISTRATOR_KEY = "lti.allow_system_administrator";

  /** The key to look up whether the digest user should be able to authenticate via LTI **/
  private static final String ALLOW_DIGEST_USER_KEY = "lti.allow_digest_user";

  /** The key to look up whether a JpaUserReferences should be created on login **/
  private static final String CREATE_JPA_USER_REFERENCE_KEY = "lti.create_jpa_user_reference";

  /** The key to look up the LTI roles for which a JpaUserReference should be created **/
  private static final String LTI_ROLES_TO_CREATE_JPA_USER_REFERENCES_FROM = "lti.create_jpa_user_reference.roles";

  /** The security service */
  private SecurityService securityService = null;

  /** The user reference provider */
  private UserReferenceProvider userReferenceProvider = null;

  /** The role name of the user to add custom Roles to (Legacy) **/
  private static final String CUSTOM_ROLE_NAME = "lti.custom_role_name";

  /** A List of Roles to add to the user if he has the custom role name (Legacy) **/
  private static final String CUSTOM_ROLES = "lti.custom_roles";

  /** Key prefix for names of the custom roles of the user **/
  private static final String CUSTOM_ROLE_NAME_PREFIX = "lti.custom_role_name.";

  /** Key prefix for list of roles to add to user if the user has the custom role **/
  private static final String CUSTOM_ROLES_LIST_PREFIX = "lti.custom_roles.";

  /** Key prefix for configuring consumer role prefixes */
  private static final String ROLE_PREFIX_KEY = "lti.consumer_role_prefix.";

  /** Consumer role prefix store */
  private final ConcurrentHashMap rolePrefixes = new ConcurrentHashMap<>();

  /** Custom role regex pattern */
  private HashMap customRolePatterns = new HashMap<>();

  /** The user details service */
  private UserDetailsService userDetailsService;

  /** OSGi component context */
  private ComponentContext componentContext;

  /** Set of OAuth consumer keys that are highly trusted */
  private Set highlyTrustedConsumerKeys = new HashSet<>();

  /** Set of usernames that should not authenticated as themselves even if the OAuth consumer keys is trusted */
  private Set usernameBlacklist = new HashSet<>();

  /** concurrent attemtps */
  private Map activePersistenceTransactions = new ConcurrentHashMap<>(128);

  /** Determines whether a JpaUserReference should be created on lti login */
  private boolean createJpaUserReference = true;

  /** LTI roles a user must have, so a JpaUserReference is created */
  private List ltiRolesForUserCreation;

  @Reference
  public void setUserDetailsService(UserDetailsService userDetailsService) {
    this.userDetailsService = userDetailsService;
  }

  /**
   * Sets the user reference provider.
   *
   * @param userReferenceProvider
   *          the user reference provider
   */
  @Reference
  public void setUserReferenceProvider(UserReferenceProvider userReferenceProvider) {
    this.userReferenceProvider = userReferenceProvider;
  }

  /**
   * Sets the security service.
   *
   * @param securityService
   *          the security service
   */
  @Reference
  public void setSecurityService(SecurityService securityService) {
    this.securityService = securityService;
  }

  @Activate
  protected void activate(ComponentContext cc) {
    logger.info("Activating LtiLaunchAuthenticationHandler");
    componentContext = cc;
    Dictionary properties = cc.getProperties();

    logger.debug("Updating LtiLaunchAuthenticationHandler");

    highlyTrustedConsumerKeys.clear();
    usernameBlacklist.clear();

    if (properties == null) {
      logger.warn("LtiLaunchAuthenticationHandler is not configured");
      return;
    }

    // Highly trusted OAuth consumer keys
    for (int i = 1; true; i++) {
      logger.debug("Looking for configuration of {}", HIGHLY_TRUSTED_CONSUMER_KEY_PREFIX + i);
      String consumerKey = StringUtils.trimToNull((String) properties.get(HIGHLY_TRUSTED_CONSUMER_KEY_PREFIX + i));
      if (consumerKey == null) {
        break;
      }
      highlyTrustedConsumerKeys.add(consumerKey);
    }

    // User blacklist
    if (!BooleanUtils.toBoolean(StringUtils.trimToNull((String) properties.get(ALLOW_SYSTEM_ADMINISTRATOR_KEY)))) {
      String adminUsername = StringUtils.trimToNull((String)
              properties.get(SecurityConstants.GLOBAL_ADMIN_USER_PROPERTY));
      if (adminUsername != null) {
        usernameBlacklist.add(adminUsername);
      }
    }
    if (!BooleanUtils.toBoolean(StringUtils.trimToNull((String) properties.get(ALLOW_DIGEST_USER_KEY)))) {
      usernameBlacklist.add(SecurityUtil.getSystemUserName(componentContext));
    }

    for (int i = 1; true; i++) {
      logger.debug("Looking for configuration of {}", BLACKLIST_USER_PREFIX + i);
      String username = StringUtils.trimToNull((String) properties.get(BLACKLIST_USER_PREFIX + i));
      if (username == null) {
        break;
      }
      usernameBlacklist.add(username);
    }

    String createJpaUserRefStr = (String) properties.get(CREATE_JPA_USER_REFERENCE_KEY);
    createJpaUserReference = BooleanUtils.toBooleanDefaultIfNull(
            BooleanUtils.toBooleanObject(StringUtils.trimToNull(createJpaUserRefStr)),
            true);

    ltiRolesForUserCreation = extractLtiRolesForUserCreation(
            Objects.toString(properties.get(LTI_ROLES_TO_CREATE_JPA_USER_REFERENCES_FROM), "*"));

    for (int i = 1; true; i++) {
      logger.debug("Looking for custom role configuration of {}", CUSTOM_ROLE_NAME_PREFIX + i);
      String customRoleName = StringUtils.trimToNull((String) properties.get(CUSTOM_ROLE_NAME_PREFIX + i));
      if (customRoleName == null) {
        break;
      }
      String customRolesString = StringUtils.trimToNull((String) properties.get(CUSTOM_ROLES_LIST_PREFIX + i));
      if (customRolesString == null) {
        continue;
      }
      enrichLtiCustomRoles(customRoleName, customRolesString);
    }

    // Add support for legacy custom_role_name
    String customRoleName = StringUtils.trimToNull((String) properties.get(CUSTOM_ROLE_NAME));

    if (customRoleName != null) {
      String customRolesString = StringUtils.trimToNull((String) properties.get(CUSTOM_ROLES));
      enrichLtiCustomRoles(customRoleName, customRolesString);
    }

    // Allow configuring prefixes for certain consumer
    for (String key: Collections.list(properties.keys())) {
      if (key.startsWith(ROLE_PREFIX_KEY)) {
        final String consumerKey = key.substring(ROLE_PREFIX_KEY.length());
        final String prefix = Objects.toString(properties.get(key), "");
        logger.debug("Adding role prefix '{}' for consumer using OAuth key '{}'", prefix, consumerKey);
        rolePrefixes.put(consumerKey, prefix);
      }
    }

  }

  /**
   * {@inheritDoc}
   *
   * @see org.springframework.security.oauth.provider.OAuthAuthenticationHandler#createAuthentication(
   *      javax.servlet.http.HttpServletRequest,
   *      org.springframework.security.oauth.provider.ConsumerAuthentication,
   *      org.springframework.security.oauth.provider.token.OAuthAccessProviderToken)
   */
  @Override
  public Authentication createAuthentication(HttpServletRequest request, ConsumerAuthentication authentication,
          OAuthAccessProviderToken authToken) {
    // The User ID must be provided by the LTI consumer
    String userIdFromConsumer = request.getParameter(LTI_USER_ID_PARAM);

    if (StringUtils.isBlank(userIdFromConsumer)) {
      logger.warn("Received authentication request without user id ({})", LTI_USER_ID_PARAM);
      return null;
    }

    // Get the consumer guid if provided
    String consumerGUID = request.getParameter(LTI_CONSUMER_GUID);
    // This is an optional field, so it could be blank
    if (StringUtils.isBlank(consumerGUID)) {
      consumerGUID = "UnknownConsumer";
    }

    // We need to construct a complex ID to avoid confusion
    String username = LTI_USER_ID_PREFIX + LTI_ID_DELIMITER + consumerGUID + LTI_ID_DELIMITER + userIdFromConsumer;

    final String oaAuthKey = request.getParameter("oauth_consumer_key");

    final String rolePrefix = rolePrefixes.getOrDefault(oaAuthKey, "");

    // if this is a trusted consumer we trust their details
    if (highlyTrustedConsumerKeys.contains(oaAuthKey)) {
      logger.debug("{} is a trusted key", oaAuthKey);

      // If supplied we use the human readable name coming from:
      //   1. ext_user_username    (optional Moodle-only field)
      //   2. lis_person_sourcedid (optional standard field)
      String ltiUsername = request.getParameter("ext_user_username");
      if (StringUtils.isBlank(ltiUsername)) {
        ltiUsername = request.getParameter("lis_person_sourcedid");
        if (StringUtils.isBlank(ltiUsername)) {
          // If no eid is set we use the supplied ID
          ltiUsername = userIdFromConsumer;
        }
      }

      // Check if the provided username should be trusted
      if (usernameBlacklist.contains(ltiUsername)) {
        // Do not trust the username
        logger.debug("{} is blacklisted", ltiUsername);
      } else {
        username = ltiUsername;
      }
    }

    if (logger.isDebugEnabled()) {
      logger.debug("LTI user id is : {}", username);
    }

    UserDetails userDetails;
    Collection userAuthorities;
    try {
      userDetails = userDetailsService.loadUserByUsername(username);

      // userDetails returns a Collection, which cannot be directly casted to a
      // Collection.
      // On the other hand, one cannot add non-null elements or modify the existing ones in a Collection. Therefore, we *must* instantiate a new Collection (an ArrayList in this
      // case) and populate it with whatever elements are returned by getAuthorities()
      userAuthorities = new HashSet<>(userDetails.getAuthorities());

      // we still need to enrich this user with the LTI Roles
      String roles = request.getParameter(ROLES);
      String context = request.getParameter(CONTEXT_ID);
      enrichRoleGrants(roles, context, rolePrefix, userAuthorities);
    } catch (UsernameNotFoundException e) {
      logger.trace("This user is known to the tool consumer only. Creating an Opencast user on the fly.", e);

      userAuthorities = new HashSet<>();
      // We should add the authorities passed in from the tool consumer?
      String roles = request.getParameter(ROLES);
      String context = request.getParameter(CONTEXT_ID);
      enrichRoleGrants(roles, context, rolePrefix, userAuthorities);

      logger.debug("Returning user with {} authorities", userAuthorities.size());

      userDetails = new User(username, "oauth", true, true, true, true, userAuthorities);
    }

    // All users need the OAUTH, USER and ANONYMOUS roles
    userAuthorities.add(new SimpleGrantedAuthority(ROLE_OAUTH_USER));
    userAuthorities.add(new SimpleGrantedAuthority("ROLE_USER"));
    userAuthorities.add(new SimpleGrantedAuthority("ROLE_ANONYMOUS"));

    // Create/Update the user reference
    if (createJpaUserReference && ltiRolesForUserCreation.size() > 0) {
      if (activePersistenceTransactions.putIfAbsent(username, Boolean.TRUE) != null) {
        logger.debug("Concurrent access of user {}. Ignoring.", username);
      } else if (!ltiRolesForUserCreation.contains("*") && !requestContainsMatchingRoles(request)) {
        logger.debug("No JpaUserReference will be created for LTI user {}.", username);
      } else {
        try {
          JpaOrganization organization = fromOrganization(securityService.getOrganization());
          JpaUserReference jpaUserReference = userReferenceProvider.findUserReference(username, organization.getId());
          Set jpaRoles = new HashSet<>();
          for (GrantedAuthority authority : userAuthorities) {
            jpaRoles.add(new JpaRole(authority.getAuthority(), organization));
          }

          Date loginDate = new Date();

          // Get some user details
          String name = request.getParameter("lis_person_name_full");
          if (name == null) {
            final String familyName = Objects.toString(request.getParameter("lis_person_name_family"), "");
            final String givenName = Objects.toString(request.getParameter("lis_person_name_given"), "");
            name = String.format("%s %s", givenName, familyName).trim();
            if (name.isEmpty()) {
              name = username;
            }
          }
          final String email = request.getParameter("lis_person_contact_email_primary");

          // Create new JpaUserReference if none exists or update existing
          if (jpaUserReference == null) {
            final String jpaContext = Objects.toString(request.getParameter(CONTEXT_ID), DEFAULT_CONTEXT);
            JpaUserReference userReference = new JpaUserReference(username, name, email, jpaContext, loginDate,
                organization, jpaRoles);
            userReferenceProvider.addUserReference(userReference, jpaContext);
          } else {
            jpaUserReference.setLastLogin(loginDate);
            jpaUserReference.setName(name);
            jpaUserReference.setEmail(email);
            jpaUserReference.setRoles(jpaRoles);
            userReferenceProvider.updateUserReference(jpaUserReference);
          }
        } catch (RollbackException e) {
          // In the unlikely case that concurrency happens at the same time on multiple servers, we catch the
          // rollback, assuming that the user is already persisted and let the user pass. Worst case, this means that
          // the user is only temporarily authenticated.
          logger.warn("Could not store reference since database was changed during update by another process", e);
        } finally {
          activePersistenceTransactions.remove(username);
        }
      }
    }
    //Create/Update UserReference End

    Authentication ltiAuth = new PreAuthenticatedAuthenticationToken(userDetails, authentication.getCredentials(),
            userAuthorities);
    SecurityContextHolder.getContext().setAuthentication(ltiAuth);
    return ltiAuth;
  }

  /**
   * Enrich A collection of role grants with specified LTI memberships.
   *
   * @param roles
   *          String of LTI roles.
   * @param context
   *          LTI context ID.
   * @param userAuthorities
   *          Collection to append to.
   */
  private void enrichRoleGrants(String roles, String context, final String rolePrefix,
      Collection userAuthorities) {
    if (roles != null) {
      // Roles could be a list
      String[] roleList = roles.split(",");

      // Use a generic context and learner if none is given:
      context = StringUtils.isBlank(context) ? DEFAULT_CONTEXT : context;

      for (final String ltiRole : roleList) {
        // Build the role
        for (Pattern rolePattern : customRolePatterns.keySet()) {
          logger.debug("Matching role pattern '{}' against '{}'", rolePattern.pattern(), ltiRole);
          if (rolePattern.matcher(ltiRole).matches()) {
            for (String roleName : customRolePatterns.get(rolePattern)) {
              userAuthorities.add(new SimpleGrantedAuthority(roleName));
            }
          }
        }

        final String normalizedLtiRole = StringUtils.defaultIfBlank(ltiRole, DEFAULT_LEARNER);
        final String role = rolePrefix + context + "_" + normalizedLtiRole;

        // Make sure to not accept ROLE_…
        if (role.trim().toUpperCase().startsWith("ROLE_")) {
          logger.warn("Discarding attempt to acquire role “{}”", role);
          continue;
        }

        // Add this role
        logger.debug("Adding role: {}", role);
        userAuthorities.add(new SimpleGrantedAuthority(role));
      }
    }
  }

  private void enrichLtiCustomRoles(String roleName, String customRolesString) {
    String[] customRoles = customRolesString.split(",");
    Pattern customRolePattern = Pattern.compile(roleName);
    customRolePatterns.put(customRolePattern, customRoles);
  }

  /**
   * Creates a JpaOrganization from an organization
   *
   * @param org
   *          the organization
   */
  private JpaOrganization fromOrganization(Organization org) {
    if (org instanceof JpaOrganization) {
      return (JpaOrganization) org;
    } else {
      return new JpaOrganization(org.getId(), org.getName(), org.getServers(), org.getAdminRole(),
              org.getAnonymousRole(), org.getProperties());
    }
  }

  /**
   * Extracts LTI roles from a cfg key. This roles determine whether a JpaUserReference
   * shall be created on a LTI login or not.
   * If no roles are given, no JPA references will be created.
   * The default value is '*', which means JPA references will be created always.
   *
   * @param ltiRoles The roles from the cfg.
   * @return The list of the LTI roles where a jpaUserReference will be created.
   */
  private List extractLtiRolesForUserCreation(String ltiRoles) {
    ltiRoles = StringUtils.trimToNull(ltiRoles);
    if ("*".equals(ltiRoles.trim())) {
      return Collections.singletonList("*");
    }
    ltiRoles = ltiRoles.toLowerCase();
    return Arrays.asList(ltiRoles.trim().split("\\s*,\\s*"));
  }

  /**
   * Gets the LTI roles from the request and checks if one role is allowed
   * for jpaUserReference creation.
   *
   * @param request The HttpServletRequest with the roles
   * @return create JpaUserReference or not
   */
  private boolean requestContainsMatchingRoles(HttpServletRequest request) {
    String roles = request.getParameter(ROLES);
    if (roles != null) {
      List requestRoleList = Arrays.asList(roles.toLowerCase().trim().split("\\s*,\\s*"));
      return !Collections.disjoint(requestRoleList, ltiRolesForUserCreation);
    }
    return false;
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy