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_SECRET_KEY = 'secret'
|
||||||
PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
|
PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
|
||||||
PROFILE_IMAGE_MIN_BYTES = 100
|
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')),
|
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)
|
urlpatterns = patterns(*urlpatterns)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user