From 6c1d997347afe3e3826209a42a0b696de1efb06b Mon Sep 17 00:00:00 2001
From: Phil McGachey <phil_mcgachey@harvard.edu>
Date: Tue, 14 Apr 2015 14:50:38 -0400
Subject: [PATCH] [LTI Provider] Create LTI Provider app, initial views and
 OAuth signature validation

---
 lms/djangoapps/lti_provider/__init__.py       |   0
 lms/djangoapps/lti_provider/admin.py          |   9 +
 lms/djangoapps/lti_provider/models.py         |  14 +
 .../lti_provider/signature_validator.py       | 244 ++++++++++++++++++
 lms/djangoapps/lti_provider/tests/__init__.py |   0
 .../tests/test_signature_validator.py         | 121 +++++++++
 .../lti_provider/tests/test_views.py          | 180 +++++++++++++
 lms/djangoapps/lti_provider/urls.py           |  14 +
 lms/djangoapps/lti_provider/views.py          | 152 +++++++++++
 lms/envs/test.py                              |   4 +
 lms/urls.py                                   |   5 +
 11 files changed, 743 insertions(+)
 create mode 100644 lms/djangoapps/lti_provider/__init__.py
 create mode 100644 lms/djangoapps/lti_provider/admin.py
 create mode 100644 lms/djangoapps/lti_provider/models.py
 create mode 100644 lms/djangoapps/lti_provider/signature_validator.py
 create mode 100644 lms/djangoapps/lti_provider/tests/__init__.py
 create mode 100644 lms/djangoapps/lti_provider/tests/test_signature_validator.py
 create mode 100644 lms/djangoapps/lti_provider/tests/test_views.py
 create mode 100644 lms/djangoapps/lti_provider/urls.py
 create mode 100644 lms/djangoapps/lti_provider/views.py

diff --git a/lms/djangoapps/lti_provider/__init__.py b/lms/djangoapps/lti_provider/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/lms/djangoapps/lti_provider/admin.py b/lms/djangoapps/lti_provider/admin.py
new file mode 100644
index 00000000000..d330d708c95
--- /dev/null
+++ b/lms/djangoapps/lti_provider/admin.py
@@ -0,0 +1,9 @@
+"""
+Admin interface for LTI Provider app.
+"""
+
+from django.contrib import admin
+
+from .models import LtiConsumer
+
+admin.site.register(LtiConsumer)
diff --git a/lms/djangoapps/lti_provider/models.py b/lms/djangoapps/lti_provider/models.py
new file mode 100644
index 00000000000..cd4d065d299
--- /dev/null
+++ b/lms/djangoapps/lti_provider/models.py
@@ -0,0 +1,14 @@
+"""
+Database models for the LTI provider feature.
+"""
+from django.db import models
+
+
+class LtiConsumer(models.Model):
+    """
+    Database model representing an LTI consumer. This model stores the consumer
+    specific settings, such as the OAuth key/secret pair and any LTI fields
+    that must be persisted.
+    """
+    key = models.CharField(max_length=32, unique=True, db_index=True)
+    secret = models.CharField(max_length=32, unique=True)
diff --git a/lms/djangoapps/lti_provider/signature_validator.py b/lms/djangoapps/lti_provider/signature_validator.py
new file mode 100644
index 00000000000..7376666cb9e
--- /dev/null
+++ b/lms/djangoapps/lti_provider/signature_validator.py
@@ -0,0 +1,244 @@
+"""
+Subclass of oauthlib's RequestValidator that checks an OAuth signature.
+"""
+
+from django.core.exceptions import ObjectDoesNotExist
+
+from oauthlib.oauth1 import SignatureOnlyEndpoint
+from oauthlib.oauth1 import RequestValidator
+
+from lti_provider.models import LtiConsumer
+
+
+class SignatureValidator(RequestValidator):
+    """
+    Helper class that verifies the OAuth signature on a request.
+
+    The pattern required by the oauthlib library mandates that subclasses of
+    RequestValidator contain instance methods that can be called back into in
+    order to fetch the consumer secret or to check that fields conform to
+    application-specific requirements.
+    """
+
+    def __init__(self):
+        super(SignatureValidator, self).__init__()
+        self.endpoint = SignatureOnlyEndpoint(self)
+
+    # The OAuth signature uses the endpoint URL as part of the request to be
+    # hashed. By default, the oauthlib library rejects any URLs that do not
+    # use HTTPS. We turn this behavior off in order to allow edX to run without
+    # SSL in development mode. When the platform is deployed and running with
+    # SSL enabled, the URL passed to the signature verifier must start with
+    # 'https', otherwise the message signature would not match the one generated
+    # on the platform.
+    enforce_ssl = False
+
+    def check_client_key(self, key):
+        """
+        Verify that the key supplied by the LTI consumer is valid for an LTI
+        launch. This method is only concerned with the structure of the key;
+        whether the key is associated with a known LTI consumer is checked in
+        validate_client_key. This method signature is required by the oauthlib
+        library.
+
+        :return: True if the client key is valid, or False if it is not.
+        """
+        return key is not None and 0 < len(key) <= 32
+
+    def check_nonce(self, nonce):
+        """
+        Verify that the nonce value that accompanies the OAuth signature is
+        valid. This method is concerned only with the structure of the nonce;
+        the validate_timestamp_and_nonce method will check that the nonce has
+        not been used within the specified time frame. This method signature is
+        required by the oauthlib library.
+
+        :return: True if the OAuth nonce is valid, or False if it is not.
+        """
+        return nonce is not None and 0 < len(nonce) <= 64
+
+    def validate_timestamp_and_nonce(self, client_key, timestamp, nonce,
+                                     request, request_token=None,
+                                     access_token=None):
+        """
+        Verify that the request is not too old (according to the timestamp), and
+        that the nonce value has not been used already within the period of time
+        in which the timestamp marks a request as valid. This method signature
+        is required by the oauthlib library.
+
+        :return: True if the OAuth nonce and timestamp are valid, False if they
+        are not.
+        """
+        return True
+
+    def validate_client_key(self, client_key, request):
+        """
+        Ensure that the client key supplied with the LTI launch is on that has
+        been generated by our platform, and that it has an associated client
+        secret.
+
+        :return: True if the key is valid, False if it is not.
+        """
+        return LtiConsumer.objects.filter(key=client_key).count() == 1
+
+    def get_client_secret(self, client_key, request):
+        """
+        Fetch the client secret from the database. This method signature is
+        required by the oauthlib library.
+
+        :return: the client secret that corresponds to the supplied key if
+        present, or None if the key does not exist in the database.
+        """
+        try:
+            return LtiConsumer.objects.get(key=client_key).secret
+        except ObjectDoesNotExist:
+            return None
+
+    def verify(self, request):
+        """
+        Check the OAuth signature on a request. This method uses the
+        SignatureEndpoint class in the oauthlib library that in turn calls back
+        to the other methods in this class.
+
+        :param request: the HttpRequest object to be verified
+        :return: True if the signature matches, False if it does not.
+        """
+
+        method = unicode(request.method)
+        url = request.build_absolute_uri()
+        body = request.body
+
+        # The oauthlib library assumes that headers are passed directly from the
+        # request, but Django mangles them into its own format. The only header
+        # that the library requires (for now) is 'Content-Type', so we
+        # reconstruct just that one.
+        headers = {"Content-Type": request.META['CONTENT_TYPE']}
+        result, __ = self.endpoint.validate_request(url, method, body, headers)
+        return result
+
+    def get_request_token_secret(self, client_key, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def get_redirect_uri(self, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def get_realms(self, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def invalidate_request_token(self, client_key, request_token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def get_rsa_key(self, client_key, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def dummy_access_token(self):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def dummy_client(self):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def verify_realms(self, token, realms, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def validate_realms(self, client_key, token, request, uri=None,
+                        realms=None):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def save_verifier(self, token, verifier, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def dummy_request_token(self):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def validate_redirect_uri(self, client_key, redirect_uri, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def verify_request_token(self, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def validate_request_token(self, client_key, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def get_default_realms(self, client_key, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def validate_access_token(self, client_key, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def save_access_token(self, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def validate_requested_realms(self, client_key, realms, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def validate_verifier(self, client_key, token, verifier, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def save_request_token(self, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
+
+    def get_access_token_secret(self, client_key, token, request):
+        """
+        Unused abstract method from super class. See documentation in RequestValidator
+        """
+        raise NotImplementedError
diff --git a/lms/djangoapps/lti_provider/tests/__init__.py b/lms/djangoapps/lti_provider/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/lms/djangoapps/lti_provider/tests/test_signature_validator.py b/lms/djangoapps/lti_provider/tests/test_signature_validator.py
new file mode 100644
index 00000000000..9b19c6896f8
--- /dev/null
+++ b/lms/djangoapps/lti_provider/tests/test_signature_validator.py
@@ -0,0 +1,121 @@
+"""
+Tests for the SignatureValidator class.
+"""
+
+from django.test import TestCase
+from django.test.client import RequestFactory
+from mock import patch
+
+from lti_provider.models import LtiConsumer
+from lti_provider.signature_validator import SignatureValidator
+
+
+class SignatureValidatorTest(TestCase):
+    """
+    Tests for the custom SignatureValidator class that uses the oauthlib library
+    to check message signatures. Note that these tests mock out the library
+    itself, since we assume it to be correct.
+    """
+
+    def test_valid_client_key(self):
+        """
+        Verify that check_client_key succeeds with a valid key
+        """
+        key = 'valid_key'
+        self.assertTrue(SignatureValidator().check_client_key(key))
+
+    def test_long_client_key(self):
+        """
+        Verify that check_client_key fails with a key that is too long
+        """
+        key = '0123456789012345678901234567890123456789'
+        self.assertFalse(SignatureValidator().check_client_key(key))
+
+    def test_empty_client_key(self):
+        """
+        Verify that check_client_key fails with a key that is an empty string
+        """
+        key = ''
+        self.assertFalse(SignatureValidator().check_client_key(key))
+
+    def test_null_client_key(self):
+        """
+        Verify that check_client_key fails with a key that is None
+        """
+        key = None
+        self.assertFalse(SignatureValidator().check_client_key(key))
+
+    def test_valid_nonce(self):
+        """
+        Verify that check_nonce succeeds with a key of maximum length
+        """
+        nonce = '0123456789012345678901234567890123456789012345678901234567890123'
+        self.assertTrue(SignatureValidator().check_nonce(nonce))
+
+    def test_long_nonce(self):
+        """
+        Verify that check_nonce fails with a key that is too long
+        """
+        nonce = '01234567890123456789012345678901234567890123456789012345678901234'
+        self.assertFalse(SignatureValidator().check_nonce(nonce))
+
+    def test_empty_nonce(self):
+        """
+        Verify that check_nonce fails with a key that is an empty string
+        """
+        nonce = ''
+        self.assertFalse(SignatureValidator().check_nonce(nonce))
+
+    def test_null_nonce(self):
+        """
+        Verify that check_nonce fails with a key that is None
+        """
+        nonce = None
+        self.assertFalse(SignatureValidator().check_nonce(nonce))
+
+    def test_validate_existing_key(self):
+        """
+        Verify that validate_client_key succeeds if the client key exists in the
+        database
+        """
+        LtiConsumer.objects.create(key='client_key', secret='client_secret')
+        self.assertTrue(SignatureValidator().validate_client_key('client_key', None))
+
+    def test_validate_missing_key(self):
+        """
+        Verify that validate_client_key fails if the client key is not in the
+        database
+        """
+        self.assertFalse(SignatureValidator().validate_client_key('client_key', None))
+
+    def test_get_existing_client_secret(self):
+        """
+        Verify that get_client_secret returns the right value if the key is in
+        the database
+        """
+        LtiConsumer.objects.create(key='client_key', secret='client_secret')
+        secret = SignatureValidator().get_client_secret('client_key', None)
+        self.assertEqual(secret, 'client_secret')
+
+    def test_get_missing_client_secret(self):
+        """
+        Verify that get_client_secret returns None if the key is not in the
+        database
+        """
+        secret = SignatureValidator().get_client_secret('client_key', None)
+        self.assertIsNone(secret)
+
+    @patch('oauthlib.oauth1.SignatureOnlyEndpoint.validate_request',
+           return_value=(True, None))
+    def test_verification_parameters(self, verify_mock):
+        """
+        Verify that the signature validaton library method is called using the
+        correct parameters derived from the HttpRequest.
+        """
+        body = 'oauth_signature_method=HMAC-SHA1&oauth_version=1.0'
+        content_type = 'application/x-www-form-urlencoded'
+        request = RequestFactory().post('/url', body, content_type=content_type)
+        headers = {'Content-Type': content_type}
+        SignatureValidator().verify(request)
+        verify_mock.assert_called_once_with(
+            request.build_absolute_uri(), 'POST', body, headers)
diff --git a/lms/djangoapps/lti_provider/tests/test_views.py b/lms/djangoapps/lti_provider/tests/test_views.py
new file mode 100644
index 00000000000..fdee142fdbc
--- /dev/null
+++ b/lms/djangoapps/lti_provider/tests/test_views.py
@@ -0,0 +1,180 @@
+"""
+Tests for the LTI provider views
+"""
+
+from django.test import TestCase
+from django.test.client import RequestFactory
+from mock import patch, MagicMock
+
+from lti_provider import views
+from lti_provider.signature_validator import SignatureValidator
+from student.tests.factories import UserFactory
+
+
+LTI_DEFAULT_PARAMS = {
+    'roles': u'Instructor,urn:lti:instrole:ims/lis/Administrator',
+    'context_id': u'lti_launch_context_id',
+    'oauth_version': u'1.0',
+    'oauth_consumer_key': u'consumer_key',
+    'oauth_signature': u'OAuth Signature',
+    'oauth_signature_method': u'HMAC-SHA1',
+    'oauth_timestamp': u'OAuth Timestamp',
+    'oauth_nonce': u'OAuth Nonce',
+}
+
+
+class LtiLaunchTest(TestCase):
+    """
+    Tests for the lti_launch view
+    """
+
+    @patch.dict('django.conf.settings.FEATURES', {'ENABLE_LTI_PROVIDER': True})
+    def setUp(self):
+        super(LtiLaunchTest, self).setUp()
+        # Always accept the OAuth signature
+        SignatureValidator.verify = MagicMock(return_value=True)
+
+    def build_request(self, authenticated=True):
+        """
+        Helper method to create a new request object for the LTI launch.
+        """
+        request = RequestFactory().post('/')
+        request.user = UserFactory.create()
+        request.user.is_authenticated = MagicMock(return_value=authenticated)
+        request.session = {}
+        request.POST.update(LTI_DEFAULT_PARAMS)
+        return request
+
+    def test_valid_launch(self):
+        """
+        Verifies that the LTI launch succeeds when passed a valid request.
+        """
+        request = self.build_request()
+        response = views.lti_launch(request, None, None)
+        self.assertEqual(response.status_code, 200)
+
+    def launch_with_missing_parameter(self, missing_param):
+        """
+        Helper method to remove a parameter from the LTI launch and call the view
+        """
+        request = self.build_request()
+        del request.POST[missing_param]
+        return views.lti_launch(request, None, None)
+
+    def test_launch_with_missing_parameters(self):
+        """
+        Runs through all required LTI parameters and verifies that the lti_launch
+        view returns Bad Request if any of them are missing.
+        """
+        for missing_param in views.REQUIRED_PARAMETERS:
+            response = self.launch_with_missing_parameter(missing_param)
+            self.assertEqual(
+                response.status_code, 400,
+                'Launch should fail when parameter ' + missing_param + ' is missing'
+            )
+
+    def test_launch_with_disabled_feature_flag(self):
+        """
+        Verifies that the LTI launch will fail if the ENABLE_LTI_PROVIDER flag
+        is not set
+        """
+        with patch.dict('django.conf.settings.FEATURES', {'ENABLE_LTI_PROVIDER': False}):
+            request = self.build_request()
+            response = views.lti_launch(request, None, None)
+            self.assertEqual(response.status_code, 403)
+
+    @patch('lti_provider.views.lti_run')
+    def test_session_contents_after_launch(self, _run):
+        """
+        Verifies that the LTI parameters and the course and usage IDs are
+        properly stored in the session
+        """
+        request = self.build_request()
+        views.lti_launch(request, 'CourseID', 'UsageID')
+        session = request.session[views.LTI_SESSION_KEY]
+        self.assertEqual(session['course_id'], 'CourseID', 'Course ID not set in the session')
+        self.assertEqual(session['usage_id'], 'UsageID', 'Usage ID not set in the session')
+        for key in views.REQUIRED_PARAMETERS:
+            self.assertEqual(session[key], request.POST[key], key + ' not set in the session')
+
+    def test_redirect_for_non_authenticated_user(self):
+        """
+        Verifies that if the lti_launch view is called by an unauthenticated
+        user, the response will redirect to the login page with the correct
+        URL
+        """
+        request = self.build_request(False)
+        response = views.lti_launch(request, None, None)
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response['Location'], '/accounts/login?next=/lti_provider/lti_run')
+
+    def test_forbidden_if_signature_fails(self):
+        """
+        Verifies that the view returns Forbidden if the LTI OAuth signature is
+        incorrect.
+        """
+        SignatureValidator.verify = MagicMock(return_value=False)
+        request = self.build_request()
+        response = views.lti_launch(request, None, None)
+        self.assertEqual(response.status_code, 403)
+
+
+class LtiRunTest(TestCase):
+    """
+    Tests for the lti_run view
+    """
+
+    def build_request(self, authenticated=True):
+        """
+        Helper method to create a new request object
+        """
+        request = RequestFactory().get('/')
+        request.user = UserFactory.create()
+        request.user.is_authenticated = MagicMock(return_value=authenticated)
+        params = {'course_id': 'CourseID', 'usage_id': 'UsageID'}
+        params.update(LTI_DEFAULT_PARAMS)
+        request.session = {views.LTI_SESSION_KEY: params}
+        return request
+
+    def test_valid_launch(self):
+        """
+        Verifies that the view returns OK if called with the correct context
+        """
+        request = self.build_request()
+        response = views.lti_run(request)
+        self.assertEqual(response.status_code, 200)
+
+    def test_forbidden_if_session_key_missing(self):
+        """
+        Verifies that the lti_run view returns a Forbidden status if the session
+        doesn't have an entry for the LTI parameters.
+        """
+        request = self.build_request()
+        del request.session[views.LTI_SESSION_KEY]
+        response = views.lti_run(request)
+        self.assertEqual(response.status_code, 403)
+
+    def test_forbidden_if_session_incomplete(self):
+        """
+        Verifies that the lti_run view returns a Forbidden status if the session
+        is missing any of the required LTI parameters or course information.
+        """
+        extra_keys = ['course_id', 'usage_id']
+        for key in views.REQUIRED_PARAMETERS + extra_keys:
+            request = self.build_request()
+            del request.session[views.LTI_SESSION_KEY][key]
+            response = views.lti_run(request)
+            self.assertEqual(
+                response.status_code,
+                403,
+                'Expected Forbidden response when session is missing ' + key
+            )
+
+    def test_session_cleared_in_view(self):
+        """
+        Verifies that the LTI parameters are cleaned out of the session after
+        launching the view to prevent a launch being replayed.
+        """
+        request = self.build_request()
+        views.lti_run(request)
+        self.assertNotIn(views.LTI_SESSION_KEY, request.session)
diff --git a/lms/djangoapps/lti_provider/urls.py b/lms/djangoapps/lti_provider/urls.py
new file mode 100644
index 00000000000..c6f6f8edc74
--- /dev/null
+++ b/lms/djangoapps/lti_provider/urls.py
@@ -0,0 +1,14 @@
+"""
+LTI Provider API endpoint urls.
+"""
+
+from django.conf import settings
+from django.conf.urls import patterns, url
+
+urlpatterns = patterns(
+    '',
+
+    url(r'^courses/{}/(?P<usage_id>[^/]*)$'.format(settings.COURSE_ID_PATTERN),
+        'lti_provider.views.lti_launch', name="lti_provider_launch"),
+    url(r'^lti_run$', 'lti_provider.views.lti_run', name="lti_provider_run"),
+)
diff --git a/lms/djangoapps/lti_provider/views.py b/lms/djangoapps/lti_provider/views.py
new file mode 100644
index 00000000000..b74b8d193d1
--- /dev/null
+++ b/lms/djangoapps/lti_provider/views.py
@@ -0,0 +1,152 @@
+"""
+LTI Provider view functions
+"""
+
+from django.conf import settings
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.views import redirect_to_login
+from django.core.urlresolvers import reverse
+from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
+from django.views.decorators.csrf import csrf_exempt
+
+from lti_provider.signature_validator import SignatureValidator
+
+# LTI launch parameters that must be present for a successful launch
+REQUIRED_PARAMETERS = [
+    'roles', 'context_id', 'oauth_version', 'oauth_consumer_key',
+    'oauth_signature', 'oauth_signature_method', 'oauth_timestamp',
+    'oauth_nonce'
+]
+
+LTI_SESSION_KEY = 'lti_provider_parameters'
+
+
+@csrf_exempt
+def lti_launch(request, course_id, usage_id):
+    """
+    Endpoint for all requests to embed edX content via the LTI protocol. This
+    endpoint will be called by a POST message that contains the parameters for
+    an LTI launch (we support version 1.2 of the LTI specification):
+        http://www.imsglobal.org/lti/ltiv1p2/ltiIMGv1p2.html
+
+    An LTI launch is successful if:
+        - The launch contains all the required parameters
+        - The launch data is correctly signed using a known client key/secret
+          pair
+        - The user is logged into the edX instance
+
+    Authentication in this view is a little tricky, since clients use a POST
+    with parameters to fetch it. We can't just use @login_required since in the
+    case where a user is not logged in it will redirect back after login using a
+    GET request, which would lose all of our LTI parameters.
+
+    Instead, we verify the LTI launch in this view before checking if the user
+    is logged in, and store the required LTI parameters in the session. Then we
+    do the authentication check, and if login is required we redirect back to
+    the lti_run view. If the user is already logged in, we just call that view
+    directly.
+    """
+    if not settings.FEATURES['ENABLE_LTI_PROVIDER']:
+        return HttpResponseForbidden()
+
+    # Check the OAuth signature on the message
+    if not SignatureValidator().verify(request):
+        return HttpResponseForbidden()
+
+    params = get_required_parameters(request.POST)
+    if not params:
+        return HttpResponseBadRequest()
+    # Store the course, and usage ID in the session to prevent privilege
+    # escalation if a staff member in one course tries to access material in
+    # another.
+    params['course_id'] = course_id
+    params['usage_id'] = usage_id
+    request.session[LTI_SESSION_KEY] = params
+
+    if not request.user.is_authenticated():
+        run_url = reverse('lti_provider.views.lti_run')
+        return redirect_to_login(run_url, settings.LOGIN_URL)
+
+    return lti_run(request)
+
+
+@login_required
+def lti_run(request):
+    """
+    This method can be reached in two ways, and must always follow a POST to
+    lti_launch:
+     - The user was logged in, so this method was called by lti_launch
+     - The user was not logged in, so the login process redirected them back here.
+
+    In either case, the session was populated by lti_launch, so all the required
+    LTI parameters will be stored there. Note that the request passed here may
+    or may not contain the LTI parameters (depending on how the user got here),
+    and so we should only use LTI parameters from the session.
+
+    Users should never call this view directly; if a user attempts to call it
+    without having first gone through lti_launch (and had the LTI parameters
+    stored in the session) they will get a 403 response.
+    """
+
+    # Check the parameters to make sure that the session is associated with a
+    # valid LTI launch
+    params = restore_params_from_session(request)
+    if not params:
+        # This view has been called without first setting the session
+        return HttpResponseForbidden()
+    # Remove the parameters from the session to prevent replay
+    del request.session[LTI_SESSION_KEY]
+
+    return render_courseware()
+
+
+def get_required_parameters(dictionary, additional_params=None):
+    """
+    Extract all required LTI parameters from a dictionary and verify that none
+    are missing.
+
+    :param dictionary: The dictionary that should contain all required parameters
+    :param additional_params: Any expected parameters, beyond those required for
+        the LTI launch.
+
+    :return: A new dictionary containing all the required parameters from the
+        original dictionary and additional parameters, or None if any expected
+        parameters are missing.
+    """
+    params = {}
+    additional_params = additional_params or []
+    for key in REQUIRED_PARAMETERS + additional_params:
+        if key not in dictionary:
+            return None
+        params[key] = dictionary[key]
+    return params
+
+
+def restore_params_from_session(request):
+    """
+    Fetch the parameters that were stored in the session by an LTI launch, and
+    verify that all required parameters are present. Missing parameters could
+    indicate that a user has directly called the lti_run endpoint, rather than
+    going through the LTI launch.
+
+    :return: A dictionary of all LTI parameters from the session, or None if
+             any parameters are missing.
+    """
+    if LTI_SESSION_KEY not in request.session:
+        return None
+    session_params = request.session[LTI_SESSION_KEY]
+    additional_params = ['course_id', 'usage_id']
+    return get_required_parameters(session_params, additional_params)
+
+
+def render_courseware():
+    """
+    Render the content requested for the LTI launch.
+    TODO: This method depends on the current refactoring work on the
+    courseware/courseware.html template. It's signature may change depending on
+    the requirements for that template once the refactoring is complete.
+
+    :return: an HttpResponse object that contains the template and necessary
+    context to render the courseware.
+    """
+    return HttpResponse('TODO: Render refactored courseware view.')
diff --git a/lms/envs/test.py b/lms/envs/test.py
index c2019fe76e6..ab098bad309 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -489,3 +489,7 @@ PROFILE_IMAGE_DEFAULT_FILE_EXTENSION = 'png'
 PROFILE_IMAGE_SECRET_KEY = 'secret'
 PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
 PROFILE_IMAGE_MIN_BYTES = 100
+
+# Enable the LTI provider feature for testing
+FEATURES['ENABLE_LTI_PROVIDER'] = True
+INSTALLED_APPS += ('lti_provider',)
diff --git a/lms/urls.py b/lms/urls.py
index 61b2b0efc33..c359412e97e 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -636,6 +636,11 @@ if settings.FEATURES["CUSTOM_COURSES_EDX"]:
             include('ccx.urls')),
     )
 
+# Access to courseware as an LTI provider
+if settings.FEATURES.get("ENABLE_LTI_PROVIDER"):
+    urlpatterns += (
+        url(r'^lti_provider/', include('lti_provider.urls')),
+    )
 
 urlpatterns = patterns(*urlpatterns)
 
-- 
GitLab