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