Commit b2ac33db by David Ormsbee

Merge pull request #7689 from mcgachey/mcgachey-lti

[LTI Provider] Create LTI Provider app, initial views and OAuth signature validation
parents 1f8f8bbf 6c1d9973
"""
Admin interface for LTI Provider app.
"""
from django.contrib import admin
from .models import LtiConsumer
admin.site.register(LtiConsumer)
"""
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)
"""
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
"""
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)
"""
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)
"""
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"),
)
"""
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)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment