LtiServlet.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.lti;

import org.apache.commons.lang3.StringUtils;
import org.json.simple.JSONObject;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardContextSelect;
import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardServletName;
import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardServletPattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.SortedSet;
import java.util.TreeSet;

import javax.servlet.Servlet;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.ws.rs.core.UriBuilder;

/**
 * A servlet to accept an LTI login via POST. The actual authentication happens in LtiProcessingFilter. GET requests
 * produce JSON containing the LTI parameters passed during LTI launch.
 */
@Component(
    service = Servlet.class,
    property = {
        "service.description=LTI Servlet",
    }
)
@HttpWhiteboardServletName("/lti")
@HttpWhiteboardServletPattern("/lti/*")
@HttpWhiteboardContextSelect("(osgi.http.whiteboard.context.name=opencast)")
public class LtiServlet extends HttpServlet {

  private static final String LTI_CUSTOM_PREFIX = "custom_";
  private static final String LTI_CUSTOM_TOOL = "custom_tool";
  private static final String LTI_CUSTOM_TEST = "custom_test";

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

  /** The serialization uid */
  private static final long serialVersionUID = 6138043870346176520L;

  /** The key used to store the LTI attributes in the HTTP session */
  public static final String SESSION_ATTRIBUTE_KEY = "org.opencastproject.lti.LtiServlet";

  /** Path under which all the LTI tools are available */
  private static final String TOOLS_URL = "/ltitools";

  // The following LTI launch parameters are made available to GET requests at the /lti endpoint.
  // See https://www.imsglobal.org/specs/ltiv1p2/implementation-guide for the meaning of each.

  /** See the LTI specification */
  public static final String LTI_MESSAGE_TYPE = "lti_message_type";

  /** See the LTI specification */
  public static final String LTI_VERSION = "lti_version";

  /** See the LTI specification */
  public static final String RESOURCE_LINK_ID = "resource_link_id";

  /** See the LTI specification */
  public static final String RESOURCE_LINK_TITLE = "resource_link_title";

  /** See the LTI specification */
  public static final String RESOURCE_LINK_DESCRIPTION = "resource_link_description";

  /** See the LTI specification */
  public static final String USER_ID = "user_id";

  /** See the LTI specification */
  public static final String USER_IMAGE = "user_image";

  /** See the LTI specification */
  public static final String ROLES = "roles";

  /** See the LTI specification */
  public static final String GIVEN_NAME = "lis_person_name_given";

  /** See the LTI specification */
  public static final String FAMILY_NAME = "lis_person_name_family";

  /** See the LTI specification */
  public static final String FULL_NAME = "lis_person_name_full";

  /** See the LTI specification */
  public static final String EMAIL = "lis_person_contact_email_primary";

  /** See the LTI specification */
  public static final String CONTEXT_ID = "context_id";

  /** See the LTI specification */
  public static final String CONTEXT_TYPE = "context_type";

  /** See the LTI specification */
  public static final String CONTEXT_TITLE = "context_title";

  /** See the LTI specification */
  public static final String CONTEXT_LABEL = "context_label";

  /** See the LTI specification */
  public static final String LOCALE = "launch_presentation_locale";

  /** See the LTI specification */
  public static final String TARGET = "launch_presentation_document_target";

  /** See the LTI specification */
  public static final String WIDTH = "launch_presentation_width";

  /** See the LTI specification */
  public static final String HEIGHT = "launch_presentation_height";

  /** See the LTI specification */
  public static final String RETURN_URL = "launch_presentation_return_url";

  /** See the LTI specification */
  public static final String CONSUMER_GUID = "tool_consumer_instance_guid";

  /** See the LTI specification */
  public static final String CONSUMER_NAME = "tool_consumer_instance_name";

  /** See the LTI specification */
  public static final String CONSUMER_DESCRIPTION = "tool_consumer_instance_description";

  /** See the LTI specification */
  public static final String CONSUMER_URL = "tool_consumer_instance_url";

  /** See the LTI specification */
  public static final String CONSUMER_CONTACT = "tool_consumer_instance_contact_email";

  /** See the LTI specification */
  public static final String COURSE_OFFERING = "lis_course_offering_sourcedid";

  /** See the LTI specification */
  public static final String COURSE_SECTION = "lis_course_section_sourcedid";

  public static final SortedSet<String> LTI_CONSTANTS;

  static {
    LTI_CONSTANTS = new TreeSet<String>();
    LTI_CONSTANTS.add(LTI_MESSAGE_TYPE);
    LTI_CONSTANTS.add(LTI_VERSION);
    LTI_CONSTANTS.add(RESOURCE_LINK_ID);
    LTI_CONSTANTS.add(RESOURCE_LINK_TITLE);
    LTI_CONSTANTS.add(RESOURCE_LINK_DESCRIPTION);
    LTI_CONSTANTS.add(USER_ID);
    LTI_CONSTANTS.add(USER_IMAGE);
    LTI_CONSTANTS.add(ROLES);
    LTI_CONSTANTS.add(GIVEN_NAME);
    LTI_CONSTANTS.add(FAMILY_NAME);
    LTI_CONSTANTS.add(FULL_NAME);
    LTI_CONSTANTS.add(EMAIL);
    LTI_CONSTANTS.add(CONTEXT_ID);
    LTI_CONSTANTS.add(CONTEXT_TYPE);
    LTI_CONSTANTS.add(CONTEXT_TITLE);
    LTI_CONSTANTS.add(CONTEXT_LABEL);
    LTI_CONSTANTS.add(LOCALE);
    LTI_CONSTANTS.add(TARGET);
    LTI_CONSTANTS.add(WIDTH);
    LTI_CONSTANTS.add(HEIGHT);
    LTI_CONSTANTS.add(RETURN_URL);
    LTI_CONSTANTS.add(CONSUMER_GUID);
    LTI_CONSTANTS.add(CONSUMER_NAME);
    LTI_CONSTANTS.add(CONSUMER_DESCRIPTION);
    LTI_CONSTANTS.add(CONSUMER_URL);
    LTI_CONSTANTS.add(CONSUMER_CONTACT);
    LTI_CONSTANTS.add(COURSE_OFFERING);
    LTI_CONSTANTS.add(COURSE_SECTION);
  }

  /**
   * {@inheritDoc}
   *
   * @see javax.servlet.http.HttpServlet#doPost(javax.servlet.http.HttpServletRequest,
   *      javax.servlet.http.HttpServletResponse)
   */
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    // Store the LTI data as a map in the session
    HttpSession session = req.getSession(false);
    session.setAttribute(SESSION_ATTRIBUTE_KEY, getLtiValuesAsMap(req));

    // We must return a 200 for some OAuth client libraries to accept this as a valid response

    // The URL of the LTI tool. If no specific tool is passed we use the test tool
    UriBuilder builder;
    try {
      String customTool = URLDecoder
              .decode(StringUtils.trimToEmpty(req.getParameter(LTI_CUSTOM_TOOL)), StandardCharsets.UTF_8.displayName());
      customTool = customTool.replaceAll(
          "/?ltitools/(?<tool>[^/]*)/index.html\\??",
          "/ltitools/index.html?subtool=${tool}&"
      );
      URI toolUri = new URI(customTool);

      if (toolUri.getPath().isEmpty()) {
        throw new URISyntaxException(toolUri.toString(), "Provided 'custom_tool' has an empty path");
      }

      // Make sure that the URI path starts with '/'. Otherwise, UriBuilder handles URIs incorrectly
      if (!toolUri.isOpaque() && !toolUri.getPath().startsWith("/")) {
        // Also, remove the schema and "authority" parts of the URI for security reasons
        builder = UriBuilder
                .fromUri(new URI(null, null, '/' + toolUri.getPath(), toolUri.getQuery(), toolUri.getFragment()));
      } else {
        // Remove the schema and "authority" parts of the URI for security reasons.
        // "authority" consists of user-info, host and port.
        builder = UriBuilder.fromUri(toolUri).scheme(null).host(null).userInfo(null).port(-1);
      }
    } catch (URISyntaxException ex) {
      logger.debug("The 'custom_tool' parameter was invalid: '{}'. Reverting to default: '{}'",
              Arrays.toString(req.getParameterValues(LTI_CUSTOM_TOOL)), TOOLS_URL);
      builder = UriBuilder.fromPath(TOOLS_URL);
    }

    // We need to add the custom params to the outgoing request
    for (String key : req.getParameterMap().keySet()) {
      logger.debug("Found query parameter '{}'", key);
      if (key.startsWith(LTI_CUSTOM_PREFIX) && (!LTI_CUSTOM_TOOL.equals(key))) {
        String paramValue = req.getParameter(key);
        // we need to remove the prefix custom_
        String paramName = key.substring(LTI_CUSTOM_PREFIX.length());
        builder.queryParam(paramName, paramValue);
      }
    }

    // Add locale param from LMS
    String localeParamValue = req.getParameter(LOCALE);
    if (StringUtils.isNotBlank(localeParamValue)) {
      // 'lng' is query param for i18next-browser-languagedetector
      builder.queryParam("lng", localeParamValue);
    }

    // Build the final URL (as a string)
    String redirectUrl = builder.build().toString();

    // Always set the session cookie
    resp.setHeader("Set-Cookie", "JSESSIONID=" + session.getId() + ";Path=/");

    // The client can specify debug option by passing a value to test
    // if in test mode display details where we go
    if (Boolean.valueOf(StringUtils.trimToEmpty(req.getParameter(LTI_CUSTOM_TEST)))) {
      resp.setContentType("text/html");
      resp.getWriter().write("<html><body>Welcome to Opencast LTI; you are going to " + redirectUrl + "<br>");
      resp.getWriter().write("<a href=\"" + redirectUrl + "\">continue...</a></body></html>");
      // TODO we should probably print the parameters.
    } else {
      resp.sendRedirect(redirectUrl);
    }
  }

  /**
   * Builds a map of LTI parameters
   *
   * @param req
   *          the LTI Launch HttpServletRequest
   * @return the map of LTI parameters to the values for this launch
   */
  protected Map<String, String> getLtiValuesAsMap(HttpServletRequest req) {
    Map<String, String> ltiValues = new HashMap<String, String>();
    for (String key : LTI_CONSTANTS) {
      String value = StringUtils.trimToNull(req.getParameter(key));
      if (value != null) {
        ltiValues.put(key, value);
      }
    }
    return ltiValues;
  }

  /**
   * {@inheritDoc}
   *
   * @see javax.servlet.http.HttpServlet#doGet(javax.servlet.http.HttpServletRequest,
   *      javax.servlet.http.HttpServletResponse)
   */
  @SuppressWarnings("unchecked")
  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    HttpSession session = req.getSession(false);
    if (session == null) {
      // If there is no session, there is nothing to see here
      resp.sendError(HttpServletResponse.SC_NOT_FOUND);
    } else {
      Map<String, String> ltiAttributes = (Map<String, String>) session.getAttribute(SESSION_ATTRIBUTE_KEY);
      if (ltiAttributes == null) {
        ltiAttributes = new HashMap<>();
        ltiAttributes.put("roles", "Instructor");
      }
      resp.setContentType("application/json");
      JSONObject.writeJSONString(ltiAttributes, resp.getWriter());
    }
  }
}