Skip to content

Commit

Permalink
Merge pull request #52 from uw-it-aca/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
jlaney authored Oct 19, 2018
2 parents d99da3e + 6574c9f commit 4df50a6
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 120 deletions.
6 changes: 5 additions & 1 deletion blti/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ def set_session(self, request, **kwargs):
request.session.create()

kwargs['_blti_session_id'] = request.session.session_key
request.session['blti'] = self._encrypt_session(kwargs)
request.session['blti'] = self._encrypt_session(
self.filter_oauth_params(kwargs))

def get_session(self, request):
if 'blti' not in request.session:
Expand All @@ -38,6 +39,9 @@ def pop_session(self, request):
if 'blti' in request.session:
request.session.pop('blti', None)

def filter_oauth_params(self, params):
return {k: v for k, v in params.items() if not k.startswith('oauth_')}

def _encrypt_session(self, data):
aes = aes128cbc(settings.BLTI_AES_KEY, settings.BLTI_AES_IV)
return aes.encrypt(json.dumps(data))
Expand Down
103 changes: 77 additions & 26 deletions blti/tests.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,68 @@
from django.conf import settings
from django.test import TestCase
from django.core.exceptions import ImproperlyConfigured
from blti.validators import BLTIOauth, Roles
from django.test import RequestFactory, TestCase
from blti.validators import BLTIRequestValidator, Roles
from blti.crypto import aes128cbc
from blti.models import BLTIData
from blti import BLTI, BLTIException
import time


class BLTIOAuthTest(TestCase):
def test_no_config(self):
self.assertRaises(ImproperlyConfigured, BLTIOauth)
class RequestValidatorTest(TestCase):
def setUp(self):
self.request = RequestFactory().post(
'/test', data=getattr(settings, 'CANVAS_LTI_V1_LAUNCH_PARAMS', {}),
secure=True)

def test_check_client_key(self):
self.assertTrue(BLTIRequestValidator().check_client_key('x' * 12))
self.assertTrue(BLTIRequestValidator().check_client_key('5' * 30))
self.assertTrue(BLTIRequestValidator().check_client_key('-' * 20))
self.assertTrue(BLTIRequestValidator().check_client_key('_' * 20))
self.assertFalse(BLTIRequestValidator().check_client_key('x' * 11))
self.assertFalse(BLTIRequestValidator().check_client_key('x' * 31))
self.assertFalse(BLTIRequestValidator().check_client_key('*' * 40))

def test_check_nonce(self):
self.assertTrue(BLTIRequestValidator().check_nonce('x' * 20))
self.assertTrue(BLTIRequestValidator().check_nonce('5' * 50))
self.assertTrue(BLTIRequestValidator().check_nonce('-' * 20))
self.assertTrue(BLTIRequestValidator().check_nonce('_' * 20))
self.assertFalse(BLTIRequestValidator().check_nonce('*' * 20))
self.assertFalse(BLTIRequestValidator().check_nonce('x' * 19))
self.assertFalse(BLTIRequestValidator().check_nonce('x' * 51))

def test_validate_client_key(self):
with self.settings(LTI_CONSUMERS={}):
self.assertFalse(
BLTIRequestValidator().validate_client_key('X', self.request))

def test_no_consumer(self):
with self.settings(LTI_CONSUMERS={'A': '12345'}):
self.assertTrue(
BLTIRequestValidator().validate_client_key('A', self.request))

def test_get_client_secret(self):
with self.settings(LTI_CONSUMERS={}):
self.assertRaises(BLTIException, BLTIOauth().get_consumer, 'XYZ')
self.assertEquals(
BLTIRequestValidator().get_client_secret('X', self.request),
'dummy')

with self.settings(LTI_CONSUMERS={'A': '12345'}):
self.assertEquals(
BLTIRequestValidator().get_client_secret('A', self.request),
'12345')

with self.settings(LTI_CONSUMERS={'ABC': '12345'}):
self.assertRaises(BLTIException, BLTIOauth().get_consumer, 'XYZ')
def test_validate_timestamp_and_nonce(self):
self.assertTrue(
BLTIRequestValidator().validate_timestamp_and_nonce(
'X', time.time(), '', self.request))

def test_get_consumer(self):
with self.settings(LTI_CONSUMERS={'ABC': '12345'}):
self.assertEquals(BLTIOauth().get_consumer('ABC').secret, '12345')
self.assertFalse(
BLTIRequestValidator().validate_timestamp_and_nonce(
'X', time.time() - 65, '', self.request))

self.assertFalse(
BLTIRequestValidator().validate_timestamp_and_nonce(
'X', time.time() + 65, '', self.request))


class BLTIDataTest(TestCase):
Expand Down Expand Up @@ -133,18 +175,27 @@ def test_authorize_specific(self):
BLTIException, Roles().authorize, self.blti, role='Manager')


class CryptoTest(TestCase):
test_key = 'DUMMY_KEY_FOR_TESTING_1234567890'
test_iv = 'DUMMY_IV_TESTING'
msgs = [
('LTI provides a framework through which an LMS can send some '
'verifiable information about a user to a third party.'),
"'abc': {'key': value}"
]
class BLTISessionTest(TestCase):
def test_encrypt_decrypt_session(self):
with self.settings(BLTI_AES_KEY='DUMMY_KEY_FOR_TESTING_1234567890',
BLTI_AES_IV='DUMMY_IV_TESTING'):

data = {'abc': {'key': 123},
'xyz': ('LTI provides a framework through which an LMS '
'can send some verifiable information about a '
'user to a third party.')}

enc = BLTI()._encrypt_session(data)
self.assertEquals(BLTI()._decrypt_session(enc), data)

def test_filter_oauth_params(self):
with self.settings(BLTI_AES_KEY='DUMMY_KEY_FOR_TESTING_1234567890',
BLTI_AES_IV='DUMMY_IV_TESTING'):
data = getattr(settings, 'CANVAS_LTI_V1_LAUNCH_PARAMS', {})

def test_encrypt_decrypt(self):
aes = aes128cbc(self.test_key, self.test_iv)
self.assertEquals(len(data), 43)
self.assertEquals(data['oauth_consumer_key'], 'XXXXXXXXXXXXXX')

for msg in self.msgs:
enc = aes.encrypt(msg)
self.assertEquals(aes.decrypt(enc), msg)
data = BLTI().filter_oauth_params(data)
self.assertEquals(len(data), 36)
self.assertRaises(KeyError, lambda: data['oauth_consumer_key'])
81 changes: 46 additions & 35 deletions blti/validators.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,61 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from oauthlib.oauth1.rfc5849.request_validator import RequestValidator
from oauthlib.oauth1.rfc5849.utils import UNICODE_ASCII_CHARACTER_SET
from blti.models import BLTIKeyStore
from blti import BLTIException
import oauth2 as oauth
import time
import re


class BLTIOauth(object):
class BLTIRequestValidator(RequestValidator):
def __init__(self):
if not hasattr(settings, 'LTI_CONSUMERS'):
raise ImproperlyConfigured('Missing setting LTI_CONSUMERS')

def validate(self, request, params={}):
oauth_server = oauth.Server()
oauth_server.add_signature_method(
oauth.SignatureMethod_HMAC_SHA1())

oauth_request = oauth.Request.from_request(
request.method,
request.build_absolute_uri(),
headers=request.META,
parameters=params
)

if oauth_request:
try:
key = oauth_request.get_parameter('oauth_consumer_key')
consumer = self.get_consumer(key)
oauth_server._check_signature(oauth_request, consumer, None)
return oauth_request.get_nonoauth_parameters()
except oauth.Error as err:
raise BLTIException(str(err))
self._client_secret = None

@property
def allowed_signature_methods(self):
return ['HMAC-SHA1']

@property
def dummy_client(self):
return 'dummy'

@property
def client_key_length(self):
return 12, 30

@property
def nonce_length(self):
return 20, 50

raise BLTIException('Invalid OAuth Request')
@property
def safe_characters(self):
return set(UNICODE_ASCII_CHARACTER_SET) | set('-_')

def get_consumer(self, key):
try:
model = BLTIKeyStore.objects.get(consumer_key=key)
return oauth.Consumer(key, str(model.shared_secret))
def validate_client_key(self, client_key, request):
client_secret = self.get_client_secret(client_key, request)
if client_secret == self.dummy_client:
return False
return True

except BLTIKeyStore.DoesNotExist:
def get_client_secret(self, client_key, request):
if self._client_secret is None:
try:
consumers = getattr(settings, 'LTI_CONSUMERS', {})
return oauth.Consumer(key, consumers[key])
except KeyError:
raise BLTIException('No Matching Consumer')
self._client_secret = BLTIKeyStore.objects.get(
consumer_key=client_key).shared_secret
except BLTIKeyStore.DoesNotExist:
try:
self._client_secret = getattr(
settings, 'LTI_CONSUMERS', {})[client_key]
except KeyError:
self._client_secret = self.dummy_client
return self._client_secret

def validate_timestamp_and_nonce(self, client_key, timestamp, nonce,
request, request_token=None,
access_token=None):
now = int(time.time())
return (now - 60) <= timestamp <= (now + 60)


class Roles(object):
Expand Down
35 changes: 19 additions & 16 deletions blti/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
from django.views.decorators.csrf import csrf_exempt
from blti import BLTI, BLTIException
from blti.models import BLTIData
from blti.validators import BLTIOauth, Roles
from blti.validators import BLTIRequestValidator, Roles
from blti.performance import log_response_time
from oauthlib.oauth1.rfc5849.endpoints.signature_only import (
SignatureOnlyEndpoint)


class BLTIView(TemplateView):
Expand Down Expand Up @@ -52,25 +54,26 @@ class BLTILaunchView(BLTIView):
def dispatch(self, request, *args, **kwargs):
return super(BLTILaunchView, self).dispatch(request, *args, **kwargs)

def validate(self, request):
params = {}
body = request.read()
try:
params = dict((k, v) for k, v in [tuple(
map(unquote_plus, kv.split('='))
) for kv in body.split('&')])
except Exception:
raise BLTIException('Missing or malformed parameter or value')

blti_params = BLTIOauth().validate(request, params=params)
self.blti = BLTIData(**blti_params)
self.authorize(self.authorized_role)
self.set_session(request, **blti_params)

def post(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
return self.render_to_response(context)

def validate(self, request):
request_validator = BLTIRequestValidator()
endpoint = SignatureOnlyEndpoint(request_validator)

valid, oauth_req = endpoint.validate_request(
request.build_absolute_uri(), request.method, body=request.read(),
headers={'Content-Type': 'application/x-www-form-urlencoded'})

if not valid:
raise BLTIException('Invalid OAuth Request')

blti_params = dict(oauth_req.params)
self.set_session(request, **blti_params)

super(BLTILaunchView, self).validate(request)


class RawBLTIView(BLTILaunchView):
template_name = 'blti/raw.html'
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
include_package_data=True,
install_requires=[
'Django>2.1,<3.0',
'oauth2',
'oauthlib',
'PyCrypto',
],
license='Apache License, Version 2.0',
Expand Down
42 changes: 1 addition & 41 deletions travis-ci/settings.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,14 @@
"""
Django settings for project project.
Generated by 'django-admin startproject' using Django 2.1.1.
For more information on this file, see
https://docs.djangoproject.com/en/2.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.1/ref/settings/
"""


import os

BASE_DIR = os.path.dirname(os.path.dirname(__file__))

# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

TEMPLATE_DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'blti',
]

Expand All @@ -47,34 +18,23 @@
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.RemoteUserMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.RemoteUserBackend',
# 'django.contrib.auth.backends.ModelBackend',
]

ROOT_URLCONF = 'travis-ci.urls'

WSGI_APPLICATION = 'travis-ci.wsgi.application'


# Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}

# Internationalization
# https://docs.djangoproject.com/en/1.7/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'America/Los_Angeles'
Expand All @@ -87,4 +47,4 @@

STATIC_URL = '/static/'

CANVAS_LTI_V1_LAUNCH_PARAMS = {'launch_presentation_height': '400', 'user_image': 'https://example.instructure.com/images/thumbnails/123456/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'context_id': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'tool_consumer_info_version': 'cloud', 'ext_roles': 'urn:lti:instrole:ims/lis/Administrator,urn:lti:instrole:ims/lis/Instructor,urn:lti:instrole:ims/lis/Student,urn:lti:role:ims/lis/Instructor,urn:lti:role:ims/lis/Learner/NonCreditLearner,urn:lti:role:ims/lis/Mentor,urn:lti:sysrole:ims/lis/User', 'tool_consumer_instance_guid': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.example.instructure.com', 'context_label': 'ABC 101 A', 'lti_message_type': 'basic-lti-launch-request', 'custom_canvas_workflow_state': 'claimed', 'lis_person_name_full': 'James Average', 'context_title': 'ABC 101 A: Example Course', 'user_id': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'custom_canvas_user_id': '123456', 'launch_presentation_locale': 'en', 'custom_canvas_api_domain': 'example.instructure.com', 'custom_canvas_enrollment_state': 'active', 'tool_consumer_instance_contact_email': '[email protected]', 'tool_consumer_info_product_family_code': 'canvas', 'custom_application_type': 'ExampleApp', 'lis_person_name_family': 'Average', 'lis_course_offering_sourcedid': '2018-spring-ABC-101-A', 'launch_presentation_width': '800', 'resource_link_title': 'Example App', 'custom_canvas_account_sis_id': 'example:account', 'lis_person_sourcedid': 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'tool_consumer_instance_name': 'Example University', 'resource_link_id': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'lis_person_contact_email_primary': '[email protected]', 'roles': 'Instructor,urn:lti:instrole:ims/lis/Administrator', 'custom_canvas_course_id': '123456', 'lti_version': 'LTI-1p0', 'lis_person_name_given': 'James', 'launch_presentation_return_url': 'https://example.instructure.com/courses/123456', 'launch_presentation_document_target': 'iframe', 'custom_canvas_account_id': '12345', 'custom_canvas_user_login_id': 'javerage'}
CANVAS_LTI_V1_LAUNCH_PARAMS = {'oauth_consumer_key': 'XXXXXXXXXXXXXX', 'oauth_signature_method': 'HMAC-SHA1', 'oauth_timestamp': '1234567890', 'oauth_nonce': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'oauth_signature': 'XXXXXXXXXXXXXXXXXXXXXXXXXXX=', 'oauth_callback': 'about:blank', 'oauth_version': '1.0', 'launch_presentation_height': '400', 'user_image': 'https://example.instructure.com/images/thumbnails/123456/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'context_id': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'tool_consumer_info_version': 'cloud', 'ext_roles': 'urn:lti:instrole:ims/lis/Administrator,urn:lti:instrole:ims/lis/Instructor,urn:lti:instrole:ims/lis/Student,urn:lti:role:ims/lis/Instructor,urn:lti:role:ims/lis/Learner/NonCreditLearner,urn:lti:role:ims/lis/Mentor,urn:lti:sysrole:ims/lis/User', 'tool_consumer_instance_guid': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.example.instructure.com', 'context_label': 'ABC 101 A', 'lti_message_type': 'basic-lti-launch-request', 'custom_canvas_workflow_state': 'claimed', 'lis_person_name_full': 'James Average', 'context_title': 'ABC 101 A: Example Course', 'user_id': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'custom_canvas_user_id': '123456', 'launch_presentation_locale': 'en', 'custom_canvas_api_domain': 'example.instructure.com', 'custom_canvas_enrollment_state': 'active', 'tool_consumer_instance_contact_email': '[email protected]', 'tool_consumer_info_product_family_code': 'canvas', 'custom_application_type': 'ExampleApp', 'lis_person_name_family': 'Average', 'lis_course_offering_sourcedid': '2018-spring-ABC-101-A', 'launch_presentation_width': '800', 'resource_link_title': 'Example App', 'custom_canvas_account_sis_id': 'example:account', 'lis_person_sourcedid': 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'tool_consumer_instance_name': 'Example University', 'resource_link_id': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'lis_person_contact_email_primary': '[email protected]', 'roles': 'Instructor,urn:lti:instrole:ims/lis/Administrator', 'custom_canvas_course_id': '123456', 'lti_version': 'LTI-1p0', 'lis_person_name_given': 'James', 'launch_presentation_return_url': 'https://example.instructure.com/courses/123456', 'launch_presentation_document_target': 'iframe', 'custom_canvas_account_id': '12345', 'custom_canvas_user_login_id': 'javerage'}

0 comments on commit 4df50a6

Please sign in to comment.