diff --git a/StarCellBio/settings.py b/StarCellBio/settings.py index 9586775e..69cd829a 100644 --- a/StarCellBio/settings.py +++ b/StarCellBio/settings.py @@ -140,7 +140,8 @@ 'django.contrib.admindocs', 'backend', 'instructor', - 'storages' + 'storages', + 'lti_provider', ) + auth.settings.INSTALLED_APPS # django all-auth config @@ -210,6 +211,7 @@ ADMINS = tuple(tuple(admin) for admin in ADMINS) HOSTNAME = platform.node().split('.')[0] + LOGGING = { 'version': 1, 'disable_existing_loggers': True, @@ -267,6 +269,18 @@ }, 'urllib3': { 'level': 'INFO', - } + }, + 'lti_provider.views': { + 'level': LOG_LEVEL, + 'handlers': ['console', 'syslog'], + }, + 'lti_provider.validator': { + 'level': 'DEBUG', + 'handlers': ['console', 'syslog'], + }, + 'lti_provider.models': { + 'level': 'DEBUG', + 'handlers': ['console', 'syslog'], + }, }, } diff --git a/StarCellBio/urls.py b/StarCellBio/urls.py index ff70c21a..2152e0df 100644 --- a/StarCellBio/urls.py +++ b/StarCellBio/urls.py @@ -96,6 +96,9 @@ 'document_root': 'html_app/js/' } ), + + # Uncomment the lti_provider line below to enable LTI provider: + url(r'^lti/', include('lti_provider.urls', namespace='lti')), ) # add authentication URL patterns urlpatterns += auth.urls.urlpatterns diff --git a/frontend_tests/tests.py b/frontend_tests/tests.py index 6124a9c9..9fc3b983 100644 --- a/frontend_tests/tests.py +++ b/frontend_tests/tests.py @@ -61,7 +61,8 @@ def tearDown(self): super(SimpleTest, self).tearDown() def setUp(self): - User.objects.create_user(username='test', password='test') + # NOTE(idegtiarov) Add User id which is used as default for new assignment created + User.objects.create_user(username='test', password='test', id=1) def _setup_experiment(self, number=3, samples=tuple()): """ diff --git a/lti_provider/__init__.py b/lti_provider/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lti_provider/admin.py b/lti_provider/admin.py new file mode 100644 index 00000000..4bd41694 --- /dev/null +++ b/lti_provider/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from lti_provider.models import LTIUser, Consumer + + +class ConsumerAdmin(admin.ModelAdmin): + search_fields = ('consumer_name', 'consumer_key', 'expiration_date') + + +admin.site.register(Consumer, ConsumerAdmin) +admin.site.register(LTIUser) diff --git a/lti_provider/lti_settings.py b/lti_provider/lti_settings.py new file mode 100644 index 00000000..006b7d23 --- /dev/null +++ b/lti_provider/lti_settings.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +# Switch on debug mode for LTI requests +DEBUG_LTI = getattr(settings, 'DEBUG_LTI', True) + +# Uncomment to switch off LTI ssl requirements (only for development mode) +#LTI_SSL = getattr(settings, 'LTI_SSL', False) diff --git a/lti_provider/models.py b/lti_provider/models.py new file mode 100644 index 00000000..d40d2395 --- /dev/null +++ b/lti_provider/models.py @@ -0,0 +1,69 @@ +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.db import models + +from auth import forms +from auth.course import create_course_records +from lti_provider.utils import key_secret_generator, hash_lti_username + +import logging + +logger = logging.getLogger(__name__) + + +class Consumer(models.Model): + """ + Model to manage LTI consumers + """ + consumer_name = models.CharField(max_length=255, unique=True) + consumer_key = models.CharField(max_length=30, unique=True, default=key_secret_generator) + consumer_secret = models.CharField(max_length=30, unique=True, default=key_secret_generator) + expiration_date = models.DateField(verbose_name='Consumer key expiration date', null=True, blank=True) + + class Meta: + verbose_name = "LTI Consumer" + verbose_name_plural = "LTI Consumers" + + def __unicode__(self): + return self.consumer_name + + +class LTIUser(models.Model): + """ + Model to manage LTI users + """ + user_id = models.CharField(max_length=255, blank=False) + consumer = models.ForeignKey(Consumer, null=True) + scb_user = models.ForeignKey(User, null=True, related_name='lti_user', on_delete=models.CASCADE) + + class Meta: # pragma: no cover + verbose_name = "LTI User" + verbose_name_plural = "LTI Users" + unique_together = ('user_id', 'consumer') + + @property + def is_scb_user(self): + return bool(self.scb_user) + + def lti_to_scb_user(self, roles, course_id): + """ + Connecting LTI user with the StarCellBio user account + :param roles: list of the strings describing assigned roles + """ + username = hash_lti_username(self.user_id) + scb_user, _ = User.objects.get_or_create(username=username) + self.scb_user = scb_user + for role in roles: + forms.add_to_group(scb_user, role) + self.save() + create_course_records(scb_user, course_id) + + def login(self, request): + """ + Login connected SCB user + """ + if self.scb_user: + self.scb_user.backend = 'allauth.account.auth_backends.AuthenticationBackend' + logger.debug("Start User {} login process...".format(self.scb_user.username)) + login(request, self.scb_user) + logger.debug("Check User is authenticated: {}".format(request.user.is_authenticated())) diff --git a/lti_provider/unit_tests/__init__.py b/lti_provider/unit_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lti_provider/unit_tests/test_models.py b/lti_provider/unit_tests/test_models.py new file mode 100644 index 00000000..36a640a3 --- /dev/null +++ b/lti_provider/unit_tests/test_models.py @@ -0,0 +1,84 @@ +from django.contrib.auth.models import User, Group +from django.core.urlresolvers import reverse +from django.test import TestCase, RequestFactory + +from backend.models import Course, UserCourse +from lti_provider.models import Consumer, LTIUser + + +class TestConsumer(TestCase): + """ + Testing LTI Consumer model + """ + + def test_new_consumer_key_and_secret(self): + """ + Tests new consumer creates with autocomplete key and secret + """ + new_consumer = Consumer.objects.create(consumer_name='TestKeySecret') + self.assertTrue(new_consumer.consumer_key, msg='Consumer key was not autocompleted.') + self.assertTrue(new_consumer.consumer_secret, msg='Consumer secret was not autocompleted.') + + +class TestLTIUser(TestCase): + """ + Testing LTI User model + """ + @classmethod + def setUpClass(cls): + cls.consumer = Consumer.objects.create(consumer_name='TestLTIUser') + cls.course_code = 'fake_course' + Group.objects.get_or_create(name='student') + Group.objects.get_or_create(name='instructor') + cls.user = User.objects.create() + cls.course = Course.objects.create(code=cls.course_code, ownerID=cls.user) + + @classmethod + def tearDownClass(cls): + cls.user.delete() + cls.course.delete() + cls.consumer.delete() + + def setUp(self): + self.user = LTIUser.objects.create(user_id='test_user', consumer=self.consumer) + + def tearDown(self): + self.user.delete() + + def test_lti_to_scb_user_role_student(self): + """ + Tests SCB User is created with student role in the group and creating UserCourse record + """ + roles = ['Student'] # LTI sends list with roles + self.assertFalse(self.user.is_scb_user) + self.user.lti_to_scb_user(roles, self.course_code) + self.assertTrue(self.user.is_scb_user) + self.assertTrue(self.user.scb_user.groups.filter(name='student').exists()) + self.assertTrue(UserCourse.objects.filter(course_name=self.course_code, user=self.user.scb_user).exists()) + + def test_lti_to_scb_user_role_instructor(self): + """ + Tests SCB User is created with instructor role in the group and creating UserCourse record + """ + roles = ['Instructor'] # LTI sends list with roles + self.assertFalse(self.user.is_scb_user) + self.user.lti_to_scb_user(roles, self.course_code) + self.assertTrue(self.user.is_scb_user) + self.assertTrue(self.user.scb_user.groups.filter(name='instructor').exists()) + self.assertTrue(UserCourse.objects.filter(course_name=self.course_code, user=self.user.scb_user).exists()) + + def test_login(self): + """ + Tests login process for SCB User + """ + factory = RequestFactory() + roles = ['Student'] # LTI sends list with roles + self.assertFalse(self.user.is_scb_user) + self.user.lti_to_scb_user(roles, self.course_code) + self.assertTrue(self.user.is_scb_user) + request = factory.post(reverse('home'), content_type='application/json') + request.user = self.user.scb_user + from django.contrib.sessions.middleware import SessionMiddleware + SessionMiddleware().process_request(request) + self.user.login(request) + self.assertTrue(self.user.scb_user.is_authenticated()) diff --git a/lti_provider/unit_tests/test_validator.py b/lti_provider/unit_tests/test_validator.py new file mode 100644 index 00000000..cfc910fd --- /dev/null +++ b/lti_provider/unit_tests/test_validator.py @@ -0,0 +1,58 @@ +import datetime + +from django.test import TestCase +from oauthlib import oauth1 + +from lti_provider.models import Consumer +from lti_provider.validator import RequestValidator + + +class TestRequestValidator(TestCase): + @classmethod + def setUpClass(cls): + cls.key = 'starcellbio201707181230key' + cls.secret = 'verysctonumk201783test_key' + cls.exp_date = datetime.date.today() + datetime.timedelta(days=1) + cls.lti_consumer = Consumer.objects.create( + consumer_name='testLTI', + consumer_key=cls.key, + consumer_secret=cls.secret, + expiration_date=cls.exp_date + ) + cls.validator = RequestValidator() + + @classmethod + def tearDownClass(cls): + cls.lti_consumer.delete() + + def test_check_client_key_valid(self): + is_valid = self.validator.check_client_key(self.key) + self.assertTrue(is_valid, msg='Consumer key is not valid') + + def test_check_client_key_invalid(self): + key = 'fake_key' + msg = 'Consumer with the key {} is not found.'.format(key) + try: + self.validator.check_client_key(key) + except oauth1.OAuth1Error as err: + self.assertEqual(err.description, msg) + + def test_validate_timestamp_valid(self): + is_valid = self.validator.validate_timestamp_and_nonce(self.key, 'fake_timestamp', 'fake_nonce', 'fake_request') + self.assertTrue(is_valid, msg='Consumer key is expired.') + + def test_validate_timestamp_invalid(self): + exp_date = datetime.date.today() - datetime.timedelta(days=1) + self.lti_consumer.expiration_date = exp_date + self.lti_consumer.save() + msg = 'Consumer key {} is expired, expiration date is {}.'.format(self.key, exp_date) + try: + self.validator.validate_timestamp_and_nonce(self.key, 'fake_timestamp', 'fake_nonce', 'fake_request') + except oauth1.OAuth1Error as err: + self.assertEqual(err.description, msg) + self.lti_consumer.expiration_date = self.exp_date + self.lti_consumer.save() + + def test_get_client_secret(self): + client_secret = self.validator.get_client_secret(self.key, 'fake_request') + self.assertEqual(client_secret, self.secret) diff --git a/lti_provider/unit_tests/test_views.py b/lti_provider/unit_tests/test_views.py new file mode 100644 index 00000000..832492a9 --- /dev/null +++ b/lti_provider/unit_tests/test_views.py @@ -0,0 +1,92 @@ +from django.contrib.sessions.middleware import SessionMiddleware +from mock import patch, Mock + +from django.contrib.auth.models import User, Group, AnonymousUser +from django.core.urlresolvers import reverse +from django.test import TestCase, RequestFactory + +from backend.models import Course, Assignment +from lti_provider.models import Consumer, LTIUser +from lti_provider.validator import RequestValidator +from lti_provider.views import lti_launch + + +def mock_tool_provider(): + m = Mock() + m.is_valid_request.return_value = True + return m + + +class TestLTI_Launch(TestCase): + @classmethod + def setUpClass(cls): + cls.course_code = 'fake_course' + cls.user_id = 'fake_user_id' + cls.consumer_key = 'fake_consumer_key' + cls.assignment_id = 'fake_assignment_id' + cls.user = User.objects.create() # Course creation requires at least one User exists + cls.course = Course.objects.create(code=cls.course_code, ownerID=cls.user) + cls.consumer = Consumer.objects.create(consumer_name='TestLTI_LaunchView', consumer_key=cls.consumer_key) + cls.assignment = Assignment.objects.create( + courseID=cls.course, + assignmentID=cls.assignment_id, + assignmentName='fake_assignment', + ownerID=cls.user, + ) + cls.post_data = { + 'user_id': cls.user_id, + 'oauth_consumer_key': cls.consumer_key, + } + Group.objects.get_or_create(name='student') + Group.objects.get_or_create(name='instructor') + + @classmethod + def tearDownClass(cls): + cls.user.delete() + cls.course.delete() + cls.consumer.delete() + cls.assignment.delete() + + def setUp(self): + self.factory = RequestFactory() + self.request = self.factory.post( + reverse('lti:launch_course', args=[self.course_code]), + data=self.post_data + ) + self.request.user = AnonymousUser() + middleware = SessionMiddleware() + middleware.process_request(self.request) + + def tearDown(self): + user = LTIUser.objects.get(user_id=self.user_id) + user.delete() + + @patch('lti.contrib.django.DjangoToolProvider.from_django_request', return_value=mock_tool_provider()) + def test_lti_launch_course_id(self, mock_tool_provider): + response = lti_launch(self.request, self.course_code) + response.client = self.client + self.assertEqual(response.url, '/') + self.assertEqual(response.status_code, 302) + mock_tool_provider.asser_called_once_with(RequestValidator) + + @patch('lti.contrib.django.DjangoToolProvider.from_django_request', return_value=mock_tool_provider()) + def test_lti_launch_course_id_assignment_id(self, mock_tool_provider): + response = lti_launch(self.request, self.course_code, self.assignment_id) + response.client = self.client + self.assertEqual(response.url, '/#view=assignments&assignment_id={}'.format(self.assignment_id)) + self.assertEqual(response.status_code, 302) + mock_tool_provider.asser_called_once_with(RequestValidator) + + @patch('lti.contrib.django.DjangoToolProvider.from_django_request', return_value=mock_tool_provider()) + def test_lti_launch_course_id_assignment_id_experiment_id(self, mock_tool_provider): + experiment = 'fake_experiment_id' + response = lti_launch(self.request, self.course_code, self.assignment_id, experiment) + response.client = self.client + self.assertEqual( + response.url, + '/#view=experiment_design&assignment_id={}'.format( + self.assignment_id, + ) + ) + self.assertEqual(response.status_code, 302) + mock_tool_provider.asser_called_once_with(RequestValidator) diff --git a/lti_provider/urls.py b/lti_provider/urls.py new file mode 100644 index 00000000..004d699d --- /dev/null +++ b/lti_provider/urls.py @@ -0,0 +1,19 @@ +from django.conf.urls import patterns, url + +import views + + +urlpatterns = [ + url(r'^config$', views.config, name='config'), + url(r'^launch/course/(?P\w*)$', views.lti_launch, name='launch_course'), + url( + r'^launch/course/(?P\w+)/assignment/(?P\w+)$', + views.lti_launch, + name='launch_assignment' + ), + url( + r'^launch/course/(?P\w+)/assignment/(?P\w+)/(?Pexperiment)$', + views.lti_launch, + name='launch_experiment' + ), +] diff --git a/lti_provider/utils.py b/lti_provider/utils.py new file mode 100644 index 00000000..362c9492 --- /dev/null +++ b/lti_provider/utils.py @@ -0,0 +1,23 @@ +import hashlib +from uuid import uuid4 + +from django.conf import settings + + +def key_secret_generator(): + """ + Generate a key/secret for LTIConsumer. + """ + return unicode(hashlib.sha1(uuid4().hex).hexdigest())[:30] + + +def hash_lti_username(user_id): + """ + Hash LTI username + + :param user_id: user's id + :return: hash string + """ + user_hash = hashlib.new('ripemd160') + user_hash.update(user_id) + return user_hash.hexdigest()[:30] diff --git a/lti_provider/validator.py b/lti_provider/validator.py new file mode 100644 index 00000000..b35c3b8e --- /dev/null +++ b/lti_provider/validator.py @@ -0,0 +1,77 @@ +import logging + +import datetime +from oauthlib import oauth1 + +from lti_provider.models import Consumer +from lti_provider import utils, lti_settings + +logger = logging.getLogger(__name__) + + +class RequestValidator(oauth1.RequestValidator): + + def __init__(self): + super(RequestValidator, self).__init__() + self.consumer = None + + @property + def enforce_ssl(self): + try: + ssl = lti_settings.LTI_SSL + except AttributeError: + ssl = True + return ssl + + def check_client_key(self, client_key): + """ + Check client key is provided correctly and LII Consumer with that key exists + + :param client_key: client key from LTI request + :return: boolean flag + """ + logger.debug('Client key is checking') + try: + self.consumer = Consumer.objects.get(consumer_key=client_key) + except Consumer.DoesNotExist: + raise oauth1.OAuth1Error('Consumer with the key {} is not found.'.format(client_key)) + return super(RequestValidator, self).check_client_key(client_key) + + def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, request): + """ + Validate LTI Consumer has not expired key + + :param client_key: client key from LTI request + :param timestamp: timestamp from LTI request + :param nonce: nonce from LTI request + :param request: LTI request + :return: boolean flag + """ + logger.debug('Timestamp validating is started.') + today = datetime.date.today() + consumer_expired_date = self.consumer.expiration_date + if consumer_expired_date and consumer_expired_date < today: + raise oauth1.OAuth1Error('Consumer Key is expired.') + return True + + def validate_client_key(self, client_key, request): + """ + Validate client key ... + + :param client_key: client key from LTI request + :param request: LTI request + :return: boolean flag + """ + logger.debug('Client key validating is started.') + return True + + def get_client_secret(self, client_key, request): + """ + Retrieve secret key storing in the LTI Consumer + + :param client_key: client key from LTI request + :param request: LTI request + :return: secret key + """ + logger.debug('Client secret getting is started.') + return self.consumer.consumer_secret diff --git a/lti_provider/views.py b/lti_provider/views.py new file mode 100644 index 00000000..a362c477 --- /dev/null +++ b/lti_provider/views.py @@ -0,0 +1,102 @@ +import logging + +from django.core.urlresolvers import reverse +from django.http import HttpResponse, Http404 +from django.shortcuts import redirect +from django.views.decorators.csrf import csrf_exempt +from lti.contrib.django import DjangoToolProvider +from lti import ToolConfig, InvalidLTIRequestError +from oauthlib import oauth1 + +from backend.models import Assignment, Course +from lti_provider import lti_settings as settings +from lti_provider.models import Consumer, LTIUser +from validator import RequestValidator + + +logger = logging.getLogger(__name__) + + +ROLES = { + 'Instructor': 'instructor', + 'Student': 'student', +} + + +def config(request): + # Code from lti_django example + # basic stuff + app_title = 'StarCellBio' + app_description = 'Star Cell Bio LTI Application' + launch_view_name = 'lti:launch_experiment' + launch_url = request.build_absolute_uri(reverse(launch_view_name, args=[ + 'course_id', + 'assignment_id', + 'experiment' + ])) + + lti_tool_config = ToolConfig( + title=app_title, + launch_url=launch_url, + secure_launch_url=launch_url, + description=app_description + ) + + return HttpResponse(lti_tool_config.to_xml(), content_type='text/xml') + + +@csrf_exempt +def lti_launch(request, course_id=None, assignment=None, experiment=None): + """ + LTI main view + + Analyze LTI POST request to launch LTI session + + :param request: LTI request + :param course_id: course id from the launch URL + :param assignment: assingment id from the launch URL + :param experiment: string 'experiment' from the launch URL is a flag for switch on experiment_design page + """ + request_post = request.POST + + if settings.DEBUG_LTI: + logger.debug(request.META) + logger.debug(request_post) + + try: + tool_provider = DjangoToolProvider.from_django_request(request=request) + validator = RequestValidator() + ok = tool_provider.is_valid_request(validator) + except (oauth1.OAuth1Error, InvalidLTIRequestError, ValueError) as err: + ok = False + logger.error('Error happened while LTI request: {}'.format(err.__str__())) + if settings.DEBUG_LTI: + logger.debug("LTI request is {}valid".format('' if ok else 'not ')) + if not ok: + raise Http404('LTI request is not valid') + + user_id = request_post.get('user_id') + if not user_id: + raise Http404('Required LTI param "user_id" is missed in the request.') + roles_from_request = request_post.get('roles', '').split(',') + roles = list({ROLES.get(role, 'student') for role in roles_from_request}) + consumer = Consumer.objects.get(consumer_key=request_post['oauth_consumer_key']) + + user, created = LTIUser.objects.get_or_create(user_id=user_id, consumer=consumer) + + if not user.is_scb_user: + # NOTE(idegtiarov) connect user with the SCB user account + user.lti_to_scb_user(roles, course_id) + logger.debug('SCB user was successfully created: {}'.format(user.is_scb_user)) + user.login(request) + url = reverse('home') + if not course_id or not Course.objects.filter(code=course_id).exists(): + raise Http404('Course with the code {}, does not exist.'.format(course_id)) + if assignment: + if not Assignment.objects.filter(assignmentID=assignment, courseID__code=course_id).exists(): + raise Http404('Assignment with the assignment_id: {}, does not exist.'.format(assignment)) + url += '#view={0}&assignment_id={1}'.format( + 'experiment_design' if experiment else 'assignments', + assignment, + ) + return redirect(url) diff --git a/requirements.dev.txt b/requirements.dev.txt index e0559f81..74758ad5 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -6,5 +6,5 @@ pudb yapf selenium==2.35.0 bok-choy==0.4.10 -mock +mock==2.0.0 -r requirements.txt diff --git a/requirements.txt b/requirements.txt index 44ae2140..cf5a8eed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,12 +11,13 @@ django-storages==1.5.0 django-tastypie==0.12.1 django-uni-form==0.9.0 httplib2==0.8 +lti==0.9.2 mimeparse==0.1.3 oauth2==1.5.211 python-dateutil==2.1 python-openid==2.2.5 readline==6.2.4.1 -requests==1.1.0 +requests==2.18.1 six==1.10.0 urwid==1.1.1 wsgiref==0.1.2