Merge pull request #7689 from mcgachey/mcgachey-lti
[LTI Provider] Create LTI Provider app, initial views and OAuth signature validation
This commit is contained in:
0
lms/djangoapps/lti_provider/__init__.py
Normal file
0
lms/djangoapps/lti_provider/__init__.py
Normal file
9
lms/djangoapps/lti_provider/admin.py
Normal file
9
lms/djangoapps/lti_provider/admin.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Admin interface for LTI Provider app.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import LtiConsumer
|
||||
|
||||
admin.site.register(LtiConsumer)
|
||||
14
lms/djangoapps/lti_provider/models.py
Normal file
14
lms/djangoapps/lti_provider/models.py
Normal file
@@ -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)
|
||||
244
lms/djangoapps/lti_provider/signature_validator.py
Normal file
244
lms/djangoapps/lti_provider/signature_validator.py
Normal file
@@ -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
|
||||
0
lms/djangoapps/lti_provider/tests/__init__.py
Normal file
0
lms/djangoapps/lti_provider/tests/__init__.py
Normal file
121
lms/djangoapps/lti_provider/tests/test_signature_validator.py
Normal file
121
lms/djangoapps/lti_provider/tests/test_signature_validator.py
Normal file
@@ -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)
|
||||
180
lms/djangoapps/lti_provider/tests/test_views.py
Normal file
180
lms/djangoapps/lti_provider/tests/test_views.py
Normal file
@@ -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)
|
||||
14
lms/djangoapps/lti_provider/urls.py
Normal file
14
lms/djangoapps/lti_provider/urls.py
Normal file
@@ -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"),
|
||||
)
|
||||
152
lms/djangoapps/lti_provider/views.py
Normal file
152
lms/djangoapps/lti_provider/views.py
Normal file
@@ -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.')
|
||||
@@ -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',)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user