From 14e2f295166294d3593fad092e677e46a01e6d02 Mon Sep 17 00:00:00 2001 From: "J. Victor Martins" Date: Thu, 24 Jun 2021 17:34:48 -0300 Subject: [PATCH] feat: Make content libraries an LTI 1.3 tool Offers blockstore-based content through content libraries acting as an LTI 1.3 tool: - Content Library support LTI 1.3 launches offering blockstore-based content through resource links. - Content Library support LTI 1.3. AGS, allowing gradebook updates from graded assignments. --- cms/envs/common.py | 14 + .../content_libraries/xblock_iframe.html | 295 ++++++++++++++++ lms/envs/common.py | 5 +- .../djangoapps/content_libraries/admin.py | 11 +- .../core/djangoapps/content_libraries/api.py | 2 + .../core/djangoapps/content_libraries/apps.py | 6 + .../core/djangoapps/content_libraries/auth.py | 43 +++ .../0005_ltigradedresource_ltiprofile.py | 44 +++ .../migrations/0006_auto_20210615_1916.py | 25 ++ .../migrations/0007_merge_20210818_0614.py | 14 + .../migrations/0008_auto_20210818_2148.py | 55 +++ .../djangoapps/content_libraries/models.py | 318 ++++++++++++++++- .../content_libraries/serializers.py | 1 + .../content_libraries/signal_handlers.py | 57 ++++ .../content_libraries/tests/base.py | 4 + .../content_libraries/tests/test_auth.py | 35 ++ .../content_libraries/tests/test_models.py | 306 +++++++++++++++++ .../content_libraries/tests/test_views_lti.py | 87 +++++ .../core/djangoapps/content_libraries/urls.py | 7 + .../djangoapps/content_libraries/views.py | 321 +++++++++++++++++- requirements/edx/base.in | 1 + requirements/edx/base.txt | 10 + requirements/edx/development.txt | 14 + requirements/edx/testing.txt | 14 + 24 files changed, 1684 insertions(+), 5 deletions(-) create mode 100644 cms/templates/content_libraries/xblock_iframe.html create mode 100644 openedx/core/djangoapps/content_libraries/auth.py create mode 100644 openedx/core/djangoapps/content_libraries/migrations/0005_ltigradedresource_ltiprofile.py create mode 100644 openedx/core/djangoapps/content_libraries/migrations/0006_auto_20210615_1916.py create mode 100644 openedx/core/djangoapps/content_libraries/migrations/0007_merge_20210818_0614.py create mode 100644 openedx/core/djangoapps/content_libraries/migrations/0008_auto_20210818_2148.py create mode 100644 openedx/core/djangoapps/content_libraries/signal_handlers.py create mode 100644 openedx/core/djangoapps/content_libraries/tests/test_auth.py create mode 100644 openedx/core/djangoapps/content_libraries/tests/test_models.py create mode 100644 openedx/core/djangoapps/content_libraries/tests/test_views_lti.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 9542ba9c6d..8ff24cbdf7 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -258,6 +258,16 @@ FEATURES = { # only supported in courses using split mongo. 'ENABLE_CONTENT_LIBRARIES': True, + # .. toggle_name: FEATURES['ENABLE_CONTENT_LIBRARIES_LTI_TOOL'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: When set to True, Content Libraries in + # Studio can be used as an LTI 1.3 tool by external LTI platforms. + # .. toggle_use_cases: open_edx + # .. toggle_creation_date: 2021-08-17 + # .. toggle_tickets: https://github.com/edx/edx-platform/pull/27411 + 'ENABLE_CONTENT_LIBRARIES_LTI_TOOL': False, + # Milestones application flag 'MILESTONES_APP': False, @@ -617,6 +627,7 @@ EDX_ROOT_URL = '' AUTHENTICATION_BACKENDS = [ 'auth_backends.backends.EdXOAuth2', 'rules.permissions.ObjectPermissionBackend', + 'openedx.core.djangoapps.content_libraries.auth.LtiAuthenticationBackend', 'openedx.core.djangoapps.oauth_dispatch.dot_overrides.backends.EdxRateLimitedAllowAllUsersModelBackend', 'bridgekeeper.backends.RulePermissionBackend', ] @@ -1629,6 +1640,9 @@ INSTALLED_APPS = [ # Allow Studio to use LMS for SSO 'social_django', + + # Content Library LTI 1.3 Support. + 'pylti1p3.contrib.django.lti1p3_tool_config', ] diff --git a/cms/templates/content_libraries/xblock_iframe.html b/cms/templates/content_libraries/xblock_iframe.html new file mode 100644 index 0000000000..876eb51bf8 --- /dev/null +++ b/cms/templates/content_libraries/xblock_iframe.html @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ fragment.head_html | safe }} + + + + {{ fragment.body_html | safe }} + + {{ fragment.foot_html | safe }} + + + diff --git a/lms/envs/common.py b/lms/envs/common.py index 4cb0cefdb9..71cfd7cea7 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3150,7 +3150,10 @@ INSTALLED_APPS = [ 'openedx.core.djangoapps.agreements', # User and group management via edx-django-utils - 'edx_django_utils.user' + 'edx_django_utils.user', + + # Content Library LTI 1.3 Support. + 'pylti1p3.contrib.django.lti1p3_tool_config', ] ######################### CSRF ######################################### diff --git a/openedx/core/djangoapps/content_libraries/admin.py b/openedx/core/djangoapps/content_libraries/admin.py index 165a88a330..559d2471ce 100644 --- a/openedx/core/djangoapps/content_libraries/admin.py +++ b/openedx/core/djangoapps/content_libraries/admin.py @@ -19,7 +19,16 @@ class ContentLibraryAdmin(admin.ModelAdmin): """ Definition of django admin UI for Content Libraries """ - fields = ("library_key", "org", "slug", "bundle_uuid", "allow_public_learning", "allow_public_read") + + fields = ( + "library_key", + "org", + "slug", + "bundle_uuid", + "allow_public_learning", + "allow_public_read", + "authorized_lti_configs", + ) list_display = ("slug", "org", "bundle_uuid") inlines = (ContentLibraryPermissionInline, ) diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index aeb2b50d1b..b1df97ed3d 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -176,6 +176,7 @@ class ContentLibraryMetadata: # has_unpublished_deletes will be true when the draft version of the library's bundle # contains deletes of any XBlocks that were in the most recently published version has_unpublished_deletes = attr.ib(False) + allow_lti = attr.ib(False) # Allow any user (even unregistered users) to view and interact directly # with this library's content in the LMS allow_public_learning = attr.ib(False) @@ -392,6 +393,7 @@ def get_library(library_key): num_blocks=num_blocks, version=bundle_metadata.latest_version, last_published=last_published, + allow_lti=ref.allow_lti, allow_public_learning=ref.allow_public_learning, allow_public_read=ref.allow_public_read, has_unpublished_changes=has_unpublished_changes, diff --git a/openedx/core/djangoapps/content_libraries/apps.py b/openedx/core/djangoapps/content_libraries/apps.py index bece3dc641..685e9259b6 100644 --- a/openedx/core/djangoapps/content_libraries/apps.py +++ b/openedx/core/djangoapps/content_libraries/apps.py @@ -31,3 +31,9 @@ class ContentLibrariesConfig(AppConfig): }, }, } + + def ready(self): + """ + Import signal handler's module to ensure they are registered. + """ + from . import signal_handlers # pylint: disable=unused-import diff --git a/openedx/core/djangoapps/content_libraries/auth.py b/openedx/core/djangoapps/content_libraries/auth.py new file mode 100644 index 0000000000..1b35350cb3 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/auth.py @@ -0,0 +1,43 @@ +""" +Content Library LTI authentication. + +This module offers an authentication backend to support LTI launches within +content libraries. +""" + + +import logging + +from django.contrib.auth.backends import ModelBackend + +from .models import LtiProfile + + +log = logging.getLogger(__name__) + + +class LtiAuthenticationBackend(ModelBackend): + """ + Authenticate based on content library LTI profile. + + The backend assumes the profile was previously created and its presence is + enough to assume the launch claims are valid. + """ + + # pylint: disable=arguments-differ + def authenticate(self, request, iss=None, aud=None, sub=None, **kwargs): + """ + Authenticate if the user in the request has an LTI profile. + """ + log.info('LTI 1.3 authentication: iss=%s, sub=%s', iss, sub) + try: + lti_profile = LtiProfile.objects.get_from_claims( + iss=iss, aud=aud, sub=sub) + except LtiProfile.DoesNotExist: + return None + user = lti_profile.user + log.info('LTI 1.3 authentication profile: profile=%s user=%s', + lti_profile, user) + if user and self.user_can_authenticate(user): + return user + return None diff --git a/openedx/core/djangoapps/content_libraries/migrations/0005_ltigradedresource_ltiprofile.py b/openedx/core/djangoapps/content_libraries/migrations/0005_ltigradedresource_ltiprofile.py new file mode 100644 index 0000000000..71aa56683e --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/migrations/0005_ltigradedresource_ltiprofile.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.20 on 2021-05-11 15:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('content_libraries', '0004_contentlibrary_license'), + ] + + operations = [ + migrations.CreateModel( + name='LtiProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform_id', models.CharField(help_text='The LTI platform identifier to which this profile belongs to.', max_length=255, verbose_name='platform identifier')), + ('client_id', models.CharField(help_text='The LTI client identifier generated by the platform.', max_length=255, verbose_name='client identifier')), + ('subject_id', models.CharField(help_text='Identifies the entity that initiated the deep linking request, commonly a user. If set to ``None`` the profile belongs to the Anonymous entity.', max_length=255, verbose_name='subject identifier')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contentlibraries_lti_profile', to=settings.AUTH_USER_MODEL, verbose_name='open edx user')), + ], + options={ + 'unique_together': {('platform_id', 'client_id', 'subject_id')}, + }, + ), + migrations.CreateModel( + name='LtiGradedResource', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('usage_key', models.CharField(help_text='The usage key string of the blockstore resource serving the content of this launch.', max_length=255)), + ('resource_id', models.CharField(help_text='The platform unique identifier of this resource in the platform, also known as "resource link id".', max_length=255)), + ('resource_title', models.CharField(help_text='The platform descriptive title for this resource placed in the platform.', max_length=255, null=True)), + ('ags_lineitem', models.CharField(help_text='If AGS was enabled during launch, this should hold the lineitem ID.', max_length=255)), + ('profile', models.ForeignKey(help_text='The authorized LTI profile that launched the resource.', on_delete=django.db.models.deletion.CASCADE, related_name='lti_resources', to='content_libraries.LtiProfile')), + ], + options={ + 'unique_together': {('usage_key', 'profile')}, + }, + ), + ] diff --git a/openedx/core/djangoapps/content_libraries/migrations/0006_auto_20210615_1916.py b/openedx/core/djangoapps/content_libraries/migrations/0006_auto_20210615_1916.py new file mode 100644 index 0000000000..672be651d8 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/migrations/0006_auto_20210615_1916.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.20 on 2021-06-15 19:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('lti1p3_tool_config', '0001_initial'), + ('content_libraries', '0005_ltigradedresource_ltiprofile'), + ] + + operations = [ + migrations.AddField( + model_name='contentlibrary', + name='lti_tool', + field=models.ForeignKey(default=None, help_text="Authorize the LTI tool selected to expose this library's content through LTI launches, leave unselected to disable LTI launches.", null=True, on_delete=django.db.models.deletion.SET_NULL, to='lti1p3_tool_config.LtiTool'), + ), + migrations.AlterField( + model_name='ltiprofile', + name='subject_id', + field=models.CharField(help_text='Identifies the entity that initiated the launch request, commonly a user.', max_length=255, verbose_name='subject identifier'), + ), + ] diff --git a/openedx/core/djangoapps/content_libraries/migrations/0007_merge_20210818_0614.py b/openedx/core/djangoapps/content_libraries/migrations/0007_merge_20210818_0614.py new file mode 100644 index 0000000000..f2a5796e19 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/migrations/0007_merge_20210818_0614.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.24 on 2021-08-18 06:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_libraries', '0005_contentlibraryblockimporttask'), + ('content_libraries', '0006_auto_20210615_1916'), + ] + + operations = [ + ] diff --git a/openedx/core/djangoapps/content_libraries/migrations/0008_auto_20210818_2148.py b/openedx/core/djangoapps/content_libraries/migrations/0008_auto_20210818_2148.py new file mode 100644 index 0000000000..fb15e4a0fa --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/migrations/0008_auto_20210818_2148.py @@ -0,0 +1,55 @@ +# Generated by Django 2.2.24 on 2021-08-18 21:48 + +from django.db import migrations, models +import django.db.models.deletion +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lti1p3_tool_config', '0001_initial'), + ('content_libraries', '0007_merge_20210818_0614'), + ] + + operations = [ + migrations.RemoveField( + model_name='contentlibrary', + name='lti_tool', + ), + migrations.AddField( + model_name='contentlibrary', + name='authorized_lti_configs', + field=models.ManyToManyField(help_text="List of authorized LTI tool configurations that can access this library's content through LTI launches, if empty no LTI launch is allowed.", related_name='content_libraries', to='lti1p3_tool_config.LtiTool'), + ), + migrations.AlterField( + model_name='ltigradedresource', + name='profile', + field=models.ForeignKey(help_text='The authorized LTI profile that launched the resource (identifies the user).', on_delete=django.db.models.deletion.CASCADE, related_name='lti_resources', to='content_libraries.LtiProfile'), + ), + migrations.AlterField( + model_name='ltigradedresource', + name='resource_id', + field=models.CharField(help_text='The LTI platform unique identifier of this resource, also known as the "resource link id".', max_length=255), + ), + migrations.AlterField( + model_name='ltigradedresource', + name='resource_title', + field=models.CharField(help_text='The LTI platform descriptive title for this resource.', max_length=255, null=True), + ), + migrations.AlterField( + model_name='ltigradedresource', + name='usage_key', + field=opaque_keys.edx.django.models.UsageKeyField(help_text='The usage key string of the blockstore resource serving the content of this launch.', max_length=255), + ), + migrations.AlterField( + model_name='ltiprofile', + name='client_id', + field=models.CharField(help_text='The LTI client identifier generated by the LTI platform.', max_length=255, verbose_name='client identifier'), + ), + migrations.AlterField( + model_name='ltiprofile', + name='platform_id', + field=models.CharField(help_text='The LTI platform identifier to which this profile belongs to.', max_length=255, verbose_name='lti platform identifier'), + ), + ] diff --git a/openedx/core/djangoapps/content_libraries/models.py b/openedx/core/djangoapps/content_libraries/models.py index 709aa8d99b..d5b6e520aa 100644 --- a/openedx/core/djangoapps/content_libraries/models.py +++ b/openedx/core/djangoapps/content_libraries/models.py @@ -1,23 +1,68 @@ """ -Models for new Content Libraries. +======================== +Content Libraries Models +======================== + +This module contains the models for new Content Libraries. + +LTI 1.3 Models +============== + +Content Libraries serves blockstore-based content through LTI 1.3 launches. +The interface supports resource link launches and grading services. Two use +cases justify the current data model to support LTI launches. They are: + +1. Authentication and authorization. This use case demands management of user + lifecycle to authorize access to content and grade submission, and it + introduces a model to own the authentication business logic related to LTI. + +2. Grade and assignments. When AGS is supported, content libraries store + additional information concerning the launched resource so that, once the + grading sub-system submits the score, it can retrieve them to propagate the + score update into the LTI platform's grade book. + +Relationship with LMS's ``lti_provider``` models +------------------------------------------------ + +The data model above is similar to the one provided by the current LTI 1.1 +implementation for modulestore and courseware content. But, Content Libraries +is orthogonal. Its use-case is to offer standalone, embedded content from a +specific backend (blockstore). As such, it decouples from LTI 1.1. and the +logic assume no relationship or impact across the two applications. The same +reasoning applies to steps beyond the data model, such as at the XBlock +runtime, authentication, and score handling, etc. """ import contextlib +import logging +import uuid from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.db import models +from django.db import transaction from django.utils.translation import ugettext_lazy as _ from opaque_keys.edx.django.models import CourseKeyField from opaque_keys.edx.locator import LibraryLocatorV2 +from pylti1p3.contrib.django import DjangoDbToolConf +from pylti1p3.contrib.django import DjangoMessageLaunch +from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool +from pylti1p3.grade import Grade + +from opaque_keys.edx.django.models import UsageKeyField from openedx.core.djangoapps.content_libraries.constants import ( LIBRARY_TYPES, COMPLEX, LICENSE_OPTIONS, ALL_RIGHTS_RESERVED, ) from organizations.models import Organization # lint-amnesty, pylint: disable=wrong-import-order +from .apps import ContentLibrariesConfig + + +log = logging.getLogger(__name__) + User = get_user_model() @@ -40,7 +85,7 @@ class ContentLibrary(models.Model): All actual content is stored in Blockstore, and any data that we'd want to transfer to another instance if this library were exported and then re-imported on another Open edX instance should be kept in Blockstore. This - model in the LMS should only be used to track settings specific to this Open + model in Studio should only be used to track settings specific to this Open edX instance, like who has permission to edit this content library. """ objects = ContentLibraryManager() @@ -76,6 +121,14 @@ class ContentLibrary(models.Model): """), ) + authorized_lti_configs = models.ManyToManyField( + LtiTool, + related_name='content_libraries', + help_text=("List of authorized LTI tool configurations that can access " + "this library's content through LTI launches, if empty no LTI " + "launch is allowed.") + ) + class Meta: verbose_name_plural = "Content Libraries" unique_together = ("org", "slug") @@ -87,6 +140,29 @@ class ContentLibrary(models.Model): """ return LibraryLocatorV2(org=self.org.short_name, slug=self.slug) + @property + def allow_lti(self): + """ + True if there is at least one LTI tool configuration associated if this + library. + """ + return self.authorized_lti_configs.exists() + + @classmethod + def authorize_lti_launch(cls, library_key, *, issuer, client_id=None): + """ + Check if the given Issuer and Client ID are authorized to launch content + from this library. + """ + return (ContentLibrary + .objects + .filter(authorized_lti_configs__issuer=issuer, + authorized_lti_configs__client_id=client_id, + authorized_lti_configs__is_active=True, + org__short_name=library_key.org, + slug=library_key.slug) + .exists()) + def __str__(self): return f"ContentLibrary ({str(self.library_key)})" @@ -211,3 +287,241 @@ class ContentLibraryBlockImportTask(models.Model): def __str__(self): return f'{self.course_id} to {self.library} #{self.pk}' + + +class LtiProfileManager(models.Manager): + """ + Custom manager of LtiProfile mode. + """ + + def get_from_claims(self, *, iss, aud, sub): + """ + Get the an instance from a LTI launch claims. + """ + return self.get(platform_id=iss, client_id=aud, subject_id=sub) + + def get_or_create_from_claims(self, *, iss, aud, sub): + """ + Get or create an instance from a LTI launch claims. + """ + try: + return self.get_from_claims(iss=iss, aud=aud, sub=sub) + except self.model.DoesNotExist: + # User will be created on ``save()``. + return self.create(platform_id=iss, client_id=aud, subject_id=sub) + + +class LtiProfile(models.Model): + """ + Content Libraries LTI's profile for Open edX users. + + Unless Anonymous, this should be a unique representation of the LTI subject + (as per the client token ``sub`` identify claim) that initiated an LTI + launch through Content Libraries. + """ + + objects = LtiProfileManager() + + user = models.OneToOneField( + get_user_model(), + null=True, + on_delete=models.CASCADE, + related_name='contentlibraries_lti_profile', + verbose_name=_('open edx user'), + ) + + platform_id = models.CharField( + max_length=255, + verbose_name=_('lti platform identifier'), + help_text=_("The LTI platform identifier to which this profile belongs " + "to.") + ) + + client_id = models.CharField( + max_length=255, + verbose_name=_('client identifier'), + help_text=_("The LTI client identifier generated by the LTI platform.") + ) + + subject_id = models.CharField( + max_length=255, + verbose_name=_('subject identifier'), + help_text=_('Identifies the entity that initiated the launch request, ' + 'commonly a user.') + ) + + created_at = models.DateTimeField( + auto_now_add=True + ) + + class Meta: + unique_together = ['platform_id', 'client_id', 'subject_id'] + + @property + def subject_url(self): + """ + An local URL that is known to uniquely identify this profile. + + We take advantage of the fact that platform id is required to be an URL + and append paths with the reamaining keys to it. + """ + return '/'.join([ + self.platform_id.rstrip('/'), + self.client_id, + self.subject_id + ]) + + def save(self, *args, **kwds): + """ + Get or create an edx user on save. + """ + if not self.user: + uid = uuid.uuid5(uuid.NAMESPACE_URL, self.subject_url) + username = f'urn:openedx:content_libraries:username:{uid}' + email = f'{uid}@{ContentLibrariesConfig.name}' + with transaction.atomic(): + if self.user is None: + self.user, created = User.objects.get_or_create( + username=username, + defaults={'email': email}) + if created: + # LTI users can only auth throught LTI launches. + self.user.set_unusable_password() + self.user.save() + super().save(*args, **kwds) + + def __str__(self): + return self.subject_id + + +class LtiGradedResourceManager(models.Manager): + """ + A custom manager for the graded resources model. + """ + + def upsert_from_ags_launch(self, user, block, resource_endpoint, resource_link): + """ + Update or create a graded resource at AGS launch. + """ + resource_id = resource_link['id'] + resource_title = resource_link.get('title') or None + lineitem = resource_endpoint['lineitem'] + lti_profile = user.contentlibraries_lti_profile + resource, _ = self.update_or_create( + profile=lti_profile, + usage_key=block.scope_ids.usage_id, + defaults={'resource_title': resource_title, + 'resource_id': resource_id, + 'ags_lineitem': lineitem} + ) + return resource + + def get_from_user_id(self, user_id, **kwds): + """ + Retrieve a resource for a given user id holding an lti profile. + """ + try: + user = get_user_model().objects.get(pk=user_id) + except get_user_model().DoesNotExist as exc: + raise self.model.DoesNotExist('User specified was not found.') from exc + profile = getattr(user, 'contentlibraries_lti_profile', None) + if not profile: + raise self.model.DoesNotExist('User does not have a LTI profile.') + kwds['profile'] = profile + return self.get(**kwds) + + +class LtiGradedResource(models.Model): + """ + A content libraries resource launched through LTI with AGS enabled. + + Essentially, an instance of this model represents a successful LTI AGS + launch. This model links the profile that launched the resource with the + resource itself, allowing identifcation of the link through its usage key + string and user id. + """ + + objects = LtiGradedResourceManager() + + profile = models.ForeignKey( + LtiProfile, + on_delete=models.CASCADE, + related_name='lti_resources', + help_text=_('The authorized LTI profile that launched the resource ' + '(identifies the user).')) + + usage_key = UsageKeyField( + max_length=255, + help_text=_('The usage key string of the blockstore resource serving the ' + 'content of this launch.'), + ) + + resource_id = models.CharField( + max_length=255, + help_text=_('The LTI platform unique identifier of this resource, also ' + 'known as the "resource link id".'), + ) + + resource_title = models.CharField( + max_length=255, + null=True, + help_text=_('The LTI platform descriptive title for this resource.'), + ) + + ags_lineitem = models.CharField( + max_length=255, + null=False, + help_text=_('If AGS was enabled during launch, this should hold the ' + 'lineitem ID.')) + + class Meta: + unique_together = (['usage_key', 'profile']) + + def update_score(self, weighted_earned, weighted_possible, timestamp): + """ + Use LTI's score service to update the LTI platform's gradebook. + + This method synchronously send a request to the LTI platform to update + the assignment score. + """ + + launch_data = { + 'iss': self.profile.platform_id, + 'aud': self.profile.client_id, + 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': { + 'lineitem': self.ags_lineitem, + 'scope': { + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score', + } + } + } + + tool_config = DjangoDbToolConf() + + ags = ( + DjangoMessageLaunch(request=None, tool_config=tool_config) + .set_auto_validation(enable=False) + .set_jwt({'body': launch_data}) + .set_restored() + .validate_registration() + .get_ags() + ) + + if weighted_possible == 0: + weighted_score = 0 + else: + weighted_score = float(weighted_earned) / float(weighted_possible) + + ags.put_grade( + Grade() + .set_score_given(weighted_score) + .set_score_maximum(1) + .set_timestamp(timestamp.isoformat()) + .set_activity_progress('Submitted') + .set_grading_progress('FullyGraded') + .set_user_id(self.profile.subject_id) + ) + + def __str__(self): + return str(self.usage_key) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 96ee8dc59c..65df9174cb 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -41,6 +41,7 @@ class ContentLibraryMetadataSerializer(serializers.Serializer): num_blocks = serializers.IntegerField(read_only=True) version = serializers.IntegerField(read_only=True) last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + allow_lti = serializers.BooleanField(default=False, read_only=True) allow_public_learning = serializers.BooleanField(default=False) allow_public_read = serializers.BooleanField(default=False) has_unpublished_changes = serializers.BooleanField(read_only=True) diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py new file mode 100644 index 0000000000..e6827e878d --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py @@ -0,0 +1,57 @@ +""" +Content library signal handlers. +""" + +import logging + +from django.conf import settings +from django.dispatch import receiver + +from lms.djangoapps.grades.api import signals as grades_signals +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import LibraryUsageLocatorV2 + +from .models import LtiGradedResource + + +log = logging.getLogger(__name__) + + +@receiver(grades_signals.PROBLEM_WEIGHTED_SCORE_CHANGED) +def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument + """ + Match the score event to an LTI resource and update. + """ + + lti_enabled = (settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES') + and settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES_LTI_TOOL')) + if not lti_enabled: + return + + modified = kwargs.get('modified') + usage_id = kwargs.get('usage_id') + user_id = kwargs.get('user_id') + weighted_earned = kwargs.get('weighted_earned') + weighted_possible = kwargs.get('weighted_possible') + + if None in (modified, usage_id, user_id, weighted_earned, weighted_possible): + log.debug("LTI 1.3: Score Signal: Missing a required parameters, " + "ignoring: kwargs=%s", kwargs) + return + try: + usage_key = LibraryUsageLocatorV2.from_string(usage_id) + except InvalidKeyError: + log.debug("LTI 1.3: Score Signal: Not a content libraries v2 usage key, " + "ignoring: usage_id=%s", usage_id) + return + try: + resource = LtiGradedResource.objects.get_from_user_id( + user_id, usage_key=usage_key + ) + except LtiGradedResource.DoesNotExist: + log.debug("LTI 1.3: Score Signal: Unknown resource, ignoring: kwargs=%s", + kwargs) + else: + resource.update_score(weighted_earned, weighted_possible, modified) + log.info("LTI 1.3: Score Signal: Grade upgraded: resource; %s", + resource) diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 465134e8d2..38138bac2e 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -37,6 +37,10 @@ URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specifie URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file +URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/' +URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/' +URL_LIB_LTI_LAUNCH = URL_LIB_LTI_PREFIX + 'launch/' + URL_BLOCK_RENDER_VIEW = '/api/xblock/v2/xblocks/{block_key}/view/{view_name}/' URL_BLOCK_GET_HANDLER_URL = '/api/xblock/v2/xblocks/{block_key}/handler_url/{handler_name}/' URL_BLOCK_METADATA_URL = '/api/xblock/v2/xblocks/{block_key}/' diff --git a/openedx/core/djangoapps/content_libraries/tests/test_auth.py b/openedx/core/djangoapps/content_libraries/tests/test_auth.py new file mode 100644 index 0000000000..99d84b7fd7 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_auth.py @@ -0,0 +1,35 @@ +""" +Unit tests for Content Libraries authentication module. +""" + + +from django.test import TestCase + + +from ..models import LtiProfile +from ..models import get_user_model +from ..auth import LtiAuthenticationBackend + + +class LtiAuthenticationBackendTest(TestCase): + """ + AuthenticationBackend tests. + """ + + iss = 'http://foo.bar' + aud = 'a-random-test-aud' + sub = 'a-random-test-sub' + + def test_without_profile(self): + get_user_model().objects.create(username='foobar') + backend = LtiAuthenticationBackend() + user = backend.authenticate(None, iss=self.iss, aud=self.aud, sub=self.sub) + self.assertIsNone(user) + + def test_with_profile(self): + profile = LtiProfile.objects.create( + platform_id=self.iss, client_id=self.aud, subject_id=self.sub) + backend = LtiAuthenticationBackend() + user = backend.authenticate(None, iss=self.iss, aud=self.aud, sub=self.sub) + self.assertIsNotNone(user) + self.assertEqual(user.contentlibraries_lti_profile, profile) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_models.py b/openedx/core/djangoapps/content_libraries/tests/test_models.py new file mode 100644 index 0000000000..3f8b51b6ad --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_models.py @@ -0,0 +1,306 @@ +""" +Unit tests for Content Libraries models. +""" + + +from unittest import mock +import uuid + +from django.test import TestCase +from django.test import RequestFactory +from django.contrib.auth import get_user_model + +from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiToolKey + +from organizations.models import Organization +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 + +from ..models import ALL_RIGHTS_RESERVED +from ..models import COMPLEX +from ..models import ContentLibrary +from ..models import LtiGradedResource +from ..models import LtiProfile +from ..models import LtiTool + + +class ContentLibraryTest(TestCase): + """ + Tests for ContentLibrary model. + """ + + def _create_library(self, **kwds): + """ + Create a library model, without a blockstore bundle attached to it. + """ + org = Organization.objects.create(name='foo', short_name='foo') + return ContentLibrary.objects.create( + org=org, + slug='foobar', + type=COMPLEX, + bundle_uuid=uuid.uuid4(), + allow_public_learning=False, + allow_public_read=False, + license=ALL_RIGHTS_RESERVED, + **kwds, + ) + + def test_authorize_lti_launch_when_no_library(self): + """ + Given no library + When authorize_lti_launch is called + Then return False + """ + self.assertFalse(ContentLibrary.objects.exists()) + authorized = ContentLibrary.authorize_lti_launch( + LibraryLocatorV2(org='foo', slug='foobar'), + issuer='http://a-fake-issuer', + client_id='a-fake-client-id') + self.assertFalse(authorized) + + def test_authorize_lti_launch_when_null(self): + """ + Given a library WITHOUT an lti tool set + When authorize_lti_launch is called + Then return False + """ + library = self._create_library() + authorized = ContentLibrary.authorize_lti_launch( + library.library_key, + issuer='http://a-fake-issuer', + client_id='a-fake-client-id') + self.assertFalse(authorized) + + def test_authorize_lti_launch_when_not_null(self): + """ + Given a library WITH an lti tool set + When authorize_lti_launch is called with different issuers + Then return False + """ + issuer = 'http://a-fake-issuer' + client_id = 'a-fake-client-id' + library = self._create_library() + library.authorized_lti_configs.add(LtiTool.objects.create( + issuer=issuer, + client_id=client_id, + tool_key=LtiToolKey.objects.create() + )) + authorized = ContentLibrary.authorize_lti_launch( + library.library_key, + issuer='http://another-fake-issuer', + client_id='another-fake-client-id') + self.assertFalse(authorized) + + def test_authorize_lti_launch_when_not_null_and_inactive(self): + """ + Given a library WITH an lti tool set + When authorize_lti_launch is called with the same issuers + And lti tool is inactive + Then return False + """ + issuer = 'http://a-fake-issuer' + client_id = 'a-fake-client-id' + library = self._create_library() + library.authorized_lti_configs.add(LtiTool.objects.create( + issuer=issuer, + client_id=client_id, + is_active=False, + tool_key=LtiToolKey.objects.create() + )) + authorized = ContentLibrary.authorize_lti_launch( + library.library_key, + issuer='http://another-fake-issuer', + client_id='another-fake-client-id') + self.assertFalse(authorized) + + def test_authorize_lti_launch_when_not_null_and_active(self): + """ + Given a library WITH an lti tool set + When authorize_lti_launch is called with the same issuers + And lti tool is active + Then return True + """ + issuer = 'http://a-fake-issuer' + client_id = 'a-fake-client-id' + library = self._create_library() + library.authorized_lti_configs.add(LtiTool.objects.create( + issuer=issuer, + client_id=client_id, + is_active=True, # redudant since it defaults to True + tool_key=LtiToolKey.objects.create() + )) + authorized = ContentLibrary.authorize_lti_launch( + library.library_key, + issuer=issuer, + client_id=client_id) + self.assertTrue(authorized) + + +class LtiProfileTest(TestCase): + """ + LtiProfile model tests. + """ + + def test_get_from_claims_doesnotexists(self): + with self.assertRaises(LtiProfile.DoesNotExist): + LtiProfile.objects.get_from_claims(iss='iss', aud='aud', sub='sub') + + def test_get_from_claims_exists(self): + """ + Given a LtiProfile with iss and sub, + When get_from_claims() + Then return the same object. + """ + + iss = 'http://foo.example.com/' + sub = 'randomly-selected-sub-for-testing' + aud = 'randomly-selected-aud-for-testing' + profile = LtiProfile.objects.create( + platform_id=iss, + client_id=aud, + subject_id=sub) + + queried_profile = LtiProfile.objects.get_from_claims( + iss=iss, aud=aud, sub=sub) + + self.assertEqual( + queried_profile, + profile, + 'The queried profile is equal to the profile created.') + + def test_subject_url(self): + """ + Given a profile + Then has a valid subject_url. + """ + iss = 'http://foo.example.com' + sub = 'randomly-selected-sub-for-testing' + aud = 'randomly-selected-aud-for-testing' + expected_url = 'http://foo.example.com/randomly-selected-aud-for-testing/randomly-selected-sub-for-testing' + profile = LtiProfile.objects.create( + platform_id=iss, + client_id=aud, + subject_id=sub) + self.assertEqual(expected_url, profile.subject_url) + + def test_create_with_user(self): + """ + Given a profile without a user + When save is called + Then a user is created. + """ + + iss = 'http://foo.example.com/' + sub = 'randomly-selected-sub-for-testing' + aud = 'randomly-selected-aud-for-testing' + profile = LtiProfile.objects.create( + platform_id=iss, + client_id=aud, + subject_id=sub) + self.assertIsNotNone(profile.user) + self.assertTrue( + profile.user.username.startswith('urn:openedx:content_libraries:username:')) + + def test_get_or_create_from_claims(self): + """ + Given a profile does not exist + When get or create + And get or create again + Then the same profile is returned. + """ + iss = 'http://foo.example.com/' + sub = 'randomly-selected-sub-for-testing' + aud = 'randomly-selected-aud-for-testing' + self.assertFalse(LtiProfile.objects.exists()) + profile = LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub) + self.assertIsNotNone(profile.user) + self.assertEqual(iss, profile.platform_id) + self.assertEqual(sub, profile.subject_id) + + profile_two = LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub) + self.assertEqual(profile_two, profile) + + def test_get_or_create_from_claims_twice(self): + """ + Given a profile + When another profile is created + Then success + """ + iss = 'http://foo.example.com/' + aud = 'randomly-selected-aud-for-testing' + sub_one = 'randomly-selected-sub-for-testing' + sub_two = 'another-randomly-sub-for-testing' + self.assertFalse(LtiProfile.objects.exists()) + LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub_one) + LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub_two) + + +class LtiResourceTest(TestCase): + """ + LtiGradedResource model tests. + """ + + iss = 'fake-iss-for-test' + + sub = 'fake-sub-for-test' + + aud = 'fake-aud-for-test' + + def setUp(self): + super().setUp() + self.request_factory = RequestFactory() + + def test_get_from_user_id_when_no_user_then_not_found(self): + user_id = 0 + with self.assertRaises(LtiGradedResource.DoesNotExist): + LtiGradedResource.objects.get_from_user_id(user_id) + + def test_get_from_user_id_when_no_profile_then_not_found(self): + user = get_user_model().objects.create(username='foobar') + with self.assertRaises(LtiGradedResource.DoesNotExist): + LtiGradedResource.objects.get_from_user_id(user.pk) + + def test_get_from_user_id_when_profile_then_found(self): + profile = LtiProfile.objects.get_or_create_from_claims( + iss=self.iss, aud=self.aud, sub=self.sub) + LtiGradedResource.objects.create(profile=profile) + resource = LtiGradedResource.objects.get_from_user_id(profile.user.pk) + self.assertEqual(profile, resource.profile) + + def test_upsert_from_ags_launch(self): + """ + Give no graded resource + When get_or_create_from_launch twice + Then created at first, retrieved at second. + """ + + resource_id = 'resource-foobar' + usage_key = 'lb:foo:bar:fooz:barz' + lineitem = 'http://canvas.docker/api/lti/courses/1/line_items/7' + resource_endpoint = { + "lineitem": lineitem, + "scope": [ + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + "https://purl.imsglobal.org/spec/lti-ags/scope/score" + ], + } + resource_link = { + "id": resource_id, + "title": "A custom title", + } + + profile = LtiProfile.objects.get_or_create_from_claims( + iss=self.iss, aud=self.aud, sub=self.sub) + block_mock = mock.Mock() + block_mock.scope_ids.usage_id = LibraryUsageLocatorV2.from_string(usage_key) + res = LtiGradedResource.objects.upsert_from_ags_launch( + profile.user, block_mock, resource_endpoint, resource_link) + + self.assertEqual(resource_id, res.resource_id) + self.assertEqual(lineitem, res.ags_lineitem) + self.assertEqual(usage_key, str(res.usage_key)) + self.assertEqual(profile, res.profile) + + res2 = LtiGradedResource.objects.upsert_from_ags_launch( + profile.user, block_mock, resource_endpoint, resource_link) + + self.assertEqual(res, res2) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_lti.py b/openedx/core/djangoapps/content_libraries/tests/test_views_lti.py new file mode 100644 index 0000000000..965a922998 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_views_lti.py @@ -0,0 +1,87 @@ +""" +Tests for LTI views. +""" + +from django.conf import settings +from django.test import TestCase, override_settings + +from openedx.core.djangoapps.content_libraries.constants import PROBLEM + +from .base import ( + ContentLibrariesRestApiTest, + URL_LIB_LTI_JWKS, + skip_unless_cms, +) + + +def override_features(**kwargs): + """ + Wrapps ``override_settings`` to override ``settings.FEATURES``. + """ + return override_settings(FEATURES={**settings.FEATURES, **kwargs}) + + +@skip_unless_cms +class LtiToolJwksViewTest(TestCase): + """ + Test JWKS view. + """ + + def test_when_lti_disabled_return_404(self): + """ + Given LTI toggle is disabled + When JWKS requested + Then return 404 + """ + response = self.client.get(URL_LIB_LTI_JWKS) + self.assertEqual(response.status_code, 404) + + @override_features(ENABLE_CONTENT_LIBRARIES=True, + ENABLE_CONTENT_LIBRARIES_LTI_TOOL=True) + def test_when_no_keys_then_return_empty(self): + """ + Given no LTI tool in the database. + When JWKS requested. + Then return empty + """ + response = self.client.get(URL_LIB_LTI_JWKS) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(response.content, '{"keys": []}') + + +@override_features(ENABLE_CONTENT_LIBRARIES=True, + ENABLE_CONTENT_LIBRARIES_LTI_TOOL=True) +class LibraryBlockLtiUrlViewTest(ContentLibrariesRestApiTest): + """ + Test generating LTI URL for a block in a library. + """ + + def test_lti_url_generation(self): + """ + Test the LTI URL generated from the block ID. + """ + + library = self._create_library( + slug="libgg", title="A Test Library", description="Testing library", library_type=PROBLEM, + ) + + block = self._add_block_to_library(library['id'], PROBLEM, PROBLEM) + usage_key = str(block.usage_key) + + url = f'/api/libraries/v2/blocks/{usage_key}/lti/' + expected_lti_url = f"/api/libraries/v2/lti/1.3/launch/?id={usage_key}" + + response = self._api("GET", url, None, expect_response=200) + + self.assertDictEqual(response, {"lti_url": expected_lti_url}) + + def test_block_not_found(self): + """ + Test the LTI URL cannot be generated as the block not found. + """ + + self._create_library( + slug="libgg", title="A Test Library", description="Testing library", library_type=PROBLEM, + ) + + self._api("GET", '/api/libraries/v2/blocks/not-existing-key/lti/', None, expect_response=404) diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 3cd02a9ac5..8cc9ce9d3a 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -51,6 +51,8 @@ urlpatterns = [ url(r'^blocks/(?P[^/]+)/', include([ # Get metadata about a specific XBlock in this library, or delete the block: url(r'^$', views.LibraryBlockView.as_view()), + # Get the LTI URL of a specific XBlock + url(r'^lti/$', views.LibraryBlockLtiUrlView.as_view(), name='lti-url'), # Get the OLX source code of the specified block: url(r'^olx/$', views.LibraryBlockOlxView.as_view()), # CRUD for static asset files associated with a block in the library: @@ -59,5 +61,10 @@ urlpatterns = [ # Future: publish/discard changes for just this one block # Future: set a block's tags (tags are stored in a Tag bundle and linked in) ])), + url(r'^lti/1.3/', include([ + url(r'^login/$', views.LtiToolLoginView.as_view(), name='lti-login'), + url(r'^launch/$', views.LtiToolLaunchView.as_view(), name='lti-launch'), + url(r'^pub/jwks/$', views.LtiToolJwksView.as_view(), name='lti-pub-jwks'), + ])), ])), ] diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index 2b9598d434..e5638f02bc 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -1,13 +1,41 @@ """ -REST API for Blockstore-based content libraries +======================= +Content Libraries Views +======================= + +This module contains the REST APIs for blockstore-based content libraries, and +LTI 1.3 views. """ + + from functools import wraps +import itertools +import json import logging +from django.conf import settings +from django.contrib.auth import authenticate from django.contrib.auth import get_user_model +from django.contrib.auth import login from django.contrib.auth.models import Group +from django.http import Http404 +from django.http import HttpResponseBadRequest +from django.http import JsonResponse from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils.decorators import method_decorator from django.utils.translation import ugettext as _ +from django.views.decorators.clickjacking import xframe_options_exempt +from django.views.decorators.csrf import csrf_exempt +from django.views.generic.base import TemplateResponseMixin +from django.views.generic.base import View +from pylti1p3.contrib.django import DjangoCacheDataStorage +from pylti1p3.contrib.django import DjangoDbToolConf +from pylti1p3.contrib.django import DjangoMessageLaunch +from pylti1p3.contrib.django import DjangoOIDCLogin +from pylti1p3.exception import LtiException +from pylti1p3.exception import OIDCException + import edx_api_doc_tools as apidocs from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from organizations.api import ensure_organization @@ -40,7 +68,13 @@ from openedx.core.djangoapps.content_libraries.serializers import ( LibraryXBlockStaticFilesSerializer, ContentLibraryAddPermissionByEmailSerializer, ) +import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from openedx.core.lib.api.view_utils import view_auth_classes +from openedx.core.djangoapps.xblock import api as xblock_api + +from .models import ContentLibrary +from .models import LtiGradedResource +from .models import LtiProfile User = get_user_model() @@ -586,6 +620,27 @@ class LibraryBlockView(APIView): return Response({}) +@view_auth_classes() +class LibraryBlockLtiUrlView(APIView): + """ + Views to generate LTI URL for existing XBlocks in a content library. + + Returns 404 in case the block not found by the given key. + """ + @convert_exceptions + def get(self, request, usage_key_str): + """ + Get the LTI launch URL for the XBlock. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + + # Get the block to validate its existence + api.get_library_block(key) + lti_login_url = f"{reverse('content_libraries:lti-launch')}?id={key}" + return Response({"lti_url": lti_login_url}) + + @view_auth_classes() class LibraryBlockOlxView(APIView): """ @@ -754,3 +809,267 @@ class LibraryImportTaskViewSet(ViewSet): import_task = api.ContentLibraryBlockImportTask.objects.get(pk=pk) return Response(ContentLibraryBlockImportTaskSerializer(import_task).data) + + +# LTI 1.3 Views +# ============= + + +def requires_lti_enabled(view_func): + """ + Modify the view function to raise 404 if content librarie LTI tool was not + enabled. + """ + def wrapped_view(*args, **kwargs): + lti_enabled = (settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES') + and settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES_LTI_TOOL')) + if not lti_enabled: + raise Http404() + return view_func(*args, **kwargs) + return wrapped_view + + +@method_decorator(requires_lti_enabled, name='dispatch') +class LtiToolView(View): + """ + Base LTI View initializing common attributes. + """ + + # pylint: disable=attribute-defined-outside-init + def setup(self, request, *args, **kwds): + """ + Initialize attributes shared by all LTI views. + """ + super().setup(request, *args, **kwds) + self.lti_tool_config = DjangoDbToolConf() + self.lti_tool_storage = DjangoCacheDataStorage(cache_name='default') + + +@method_decorator(csrf_exempt, name='dispatch') +class LtiToolLoginView(LtiToolView): + """ + Third-party Initiated Login view. + + The LTI platform will start the OpenID Connect flow by redirecting the User + Agent (UA) to this view. The redirect may be a form POST or a GET. On + success the view should redirect the UA to the LTI platform's authentication + URL. + """ + + LAUNCH_URI_PARAMETER = 'target_link_uri' + + def get(self, request): + return self.post(request) + + def post(self, request): + """Initialize 3rd-party login requests to redirect.""" + oidc_login = DjangoOIDCLogin( + self.request, + self.lti_tool_config, + launch_data_storage=self.lti_tool_storage) + launch_url = (self.request.POST.get(self.LAUNCH_URI_PARAMETER) + or self.request.GET.get(self.LAUNCH_URI_PARAMETER)) + try: + return oidc_login.redirect(launch_url) + except OIDCException as exc: + # Relying on downstream error messages, attempt to sanitize it up + # for customer facing errors. + log.error('LTI OIDC login failed: %s', exc) + return HttpResponseBadRequest('Invalid LTI login request.') + + +@method_decorator(csrf_exempt, name='dispatch') +@method_decorator(xframe_options_exempt, name='dispatch') +class LtiToolLaunchView(TemplateResponseMixin, LtiToolView): + """ + LTI platform tool launch view. + + The launch view supports resource link launches and AGS, when enabled by the + LTI platform. Other features and resouces are ignored. + """ + + template_name = 'content_libraries/xblock_iframe.html' + + @property + def launch_data(self): + return self.launch_message.get_launch_data() + + def _authenticate_and_login(self, usage_key): + """ + Authenticate and authorize the user for this LTI message launch. + + We automatically create LTI profile for every valid launch, and + authenticate the LTI user associated with it. + """ + + # Check library authorization. + + if not ContentLibrary.authorize_lti_launch( + usage_key.lib_key, + issuer=self.launch_data['iss'], + client_id=self.launch_data['aud'] + ): + return None + + # Check LTI profile. + + LtiProfile.objects.get_or_create_from_claims( + iss=self.launch_data['iss'], + aud=self.launch_data['aud'], + sub=self.launch_data['sub']) + edx_user = authenticate( + self.request, + iss=self.launch_data['iss'], + aud=self.launch_data['aud'], + sub=self.launch_data['sub']) + + if edx_user is not None: + + login(self.request, edx_user) + perms = api.get_library_user_permissions( + usage_key.lib_key, + self.request.user) + if not perms: + api.set_library_user_permissions( + usage_key.lib_key, + self.request.user, + api.AccessLevel.ADMIN_LEVEL) + + return edx_user + + def _bad_request_response(self): + """ + A default response for bad requests. + """ + return HttpResponseBadRequest('Invalid LTI tool launch.') + + def get_context_data(self): + """ + Setup the template context data. + """ + + handler_urls = { + str(key): xblock_api.get_handler_url(key, 'handler_name', self.request.user) + for key + in itertools.chain([self.block.scope_ids.usage_id], + getattr(self.block, 'children', [])) + } + + # We are defaulting to student view due to current use case (resource + # link launches). Launches within other views are not currently + # supported. + fragment = self.block.render('student_view') + lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL) + return { + 'fragment': fragment, + 'handler_urls_json': json.dumps(handler_urls), + 'lms_root_url': lms_root_url, + } + + def get_launch_message(self): + """ + Return the LTI 1.3 launch message object for the current request. + """ + launch_message = DjangoMessageLaunch( + self.request, + self.lti_tool_config, + launch_data_storage=self.lti_tool_storage) + # This will force the LTI launch validation steps. + launch_message.get_launch_data() + return launch_message + + # pylint: disable=attribute-defined-outside-init + def post(self, request): + """ + Process LTI platform launch requests. + """ + + # Parse LTI launch message. + + try: + self.launch_message = self.get_launch_message() + except LtiException as exc: + log.exception('LTI 1.3: Tool launch failed: %s', exc) + return self._bad_request_response() + + log.info("LTI 1.3: Launch message body: %s", + json.dumps(self.launch_data)) + + # Parse content key. + + usage_key_str = request.GET.get('id') + if not usage_key_str: + return self._bad_request_response() + usage_key = LibraryUsageLocatorV2.from_string(usage_key_str) + log.info('LTI 1.3: Launch block: id=%s', usage_key) + + # Authenticate the launch and setup LTI profiles. + + if not self._authenticate_and_login(usage_key): + return self._bad_request_response() + + # Get the block. + + self.block = xblock_api.load_block( + usage_key, + user=self.request.user) + + # Handle Assignment and Grade Service request. + + self.handle_ags() + + # Render context and response. + context = self.get_context_data() + return self.render_to_response(context) + + def handle_ags(self): + """ + Handle AGS-enabled launches for block in the request. + """ + + # Validate AGS. + + if not self.launch_message.has_ags(): + return + + endpoint_claim = 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint' + endpoint = self.launch_data[endpoint_claim] + required_scopes = [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score' + ] + + for scope in required_scopes: + if scope not in endpoint['scope']: + log.info('LTI 1.3: AGS: LTI platform does not support a required ' + 'scope: %s', scope) + return + lineitem = endpoint.get('lineitem') + if not lineitem: + log.info("LTI 1.3: AGS: LTI platform didn't pass lineitem, ignoring " + "request: %s", endpoint) + return + + # Create graded resource in the database for the current launch. + + resource_claim = 'https://purl.imsglobal.org/spec/lti/claim/resource_link' + resource_link = self.launch_data.get(resource_claim) + + resource = LtiGradedResource.objects.upsert_from_ags_launch( + self.request.user, self.block, endpoint, resource_link + ) + + log.info("LTI 1.3: AGS: Upserted LTI graded resource from launch: %s", + resource) + + +class LtiToolJwksView(LtiToolView): + """ + JSON Web Key Sets view. + """ + + def get(self, request): + """ + Return the JWKS. + """ + return JsonResponse(self.lti_tool_config.get_jwks(), safe=False) diff --git a/requirements/edx/base.in b/requirements/edx/base.in index b21b7092f2..1c3d9af99f 100644 --- a/requirements/edx/base.in +++ b/requirements/edx/base.in @@ -133,6 +133,7 @@ pyjwkest # TODO Replace PyJWT usage with pyjwkest # PyJWT 1.6.3 contains PyJWTError, which is required by Apple auth in social-auth-core PyJWT>=1.6.3 +pylti1p3 # Required by content_libraries core library to suport LTI 1.3 launches pymongo # MongoDB driver pynliner # Inlines CSS styles into HTML for email notifications python-dateutil diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 75f749618f..805d9e484e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -142,6 +142,7 @@ cryptography==3.4.8 # -r requirements/edx/base.in # django-fernet-fields # edx-enterprise + # jwcrypto # pyjwt # social-auth-core cssutils==2.3.0 @@ -159,6 +160,8 @@ defusedxml==0.7.1 # python3-saml # safe-lxml # social-auth-core +deprecated==1.2.12 + # via jwcrypto django==2.2.24 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt @@ -595,6 +598,8 @@ jsonfield2==3.0.3 # edx-submissions # lti-consumer-xblock # ora2 +jwcrypto==0.9.1 + # via pylti1p3 kombu==4.6.11 # via celery laboratory==1.0.2 @@ -768,9 +773,12 @@ pyjwt[crypto]==1.7.1 # edx-auth-backends # edx-proctoring # edx-rest-api-client + # pylti1p3 # social-auth-core pylatexenc==2.10 # via olxcleaner +pylti1p3==1.9.1 + # via -r requirements/edx/base.in pymongo==3.10.1 # via # -c requirements/edx/../constraints.txt @@ -878,6 +886,7 @@ requests==2.26.0 # geoip2 # mailsnake # pyjwkest + # pylti1p3 # python-swiftclient # requests-oauthlib # sailthru-client @@ -944,6 +953,7 @@ six==1.16.0 # html5lib # interchange # isodate + # jwcrypto # libsass # pansi # paver diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index d951112ebb..c445690dca 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -201,6 +201,7 @@ cryptography==3.4.8 # -r requirements/edx/testing.txt # django-fernet-fields # edx-enterprise + # jwcrypto # pyjwt # social-auth-core cssselect==1.1.0 @@ -225,6 +226,10 @@ defusedxml==0.7.1 # python3-saml # safe-lxml # social-auth-core +deprecated==1.2.12 + # via + # -r requirements/edx/testing.txt + # jwcrypto diff-cover==4.0.0 # via # -c requirements/edx/../constraints.txt @@ -787,6 +792,10 @@ jsonfield2==3.0.3 # ora2 jsonschema==3.2.0 # via sphinxcontrib-openapi +jwcrypto==0.9.1 + # via + # -r requirements/edx/testing.txt + # pylti1p3 kombu==4.6.11 # via # -r requirements/edx/testing.txt @@ -1037,6 +1046,7 @@ pyjwt[crypto]==1.7.1 # edx-auth-backends # edx-proctoring # edx-rest-api-client + # pylti1p3 # social-auth-core pylatexenc==2.10 # via @@ -1066,6 +1076,8 @@ pylint-plugin-utils==0.6 # pylint-django pylint-pytest==0.3.0 # via -r requirements/edx/testing.txt +pylti1p3==1.9.1 + # via -r requirements/edx/testing.txt pymongo==3.10.1 # via # -c requirements/edx/../constraints.txt @@ -1221,6 +1233,7 @@ requests==2.26.0 # mailsnake # pact-python # pyjwkest + # pylti1p3 # python-swiftclient # requests-oauthlib # sailthru-client @@ -1311,6 +1324,7 @@ six==1.16.0 # interchange # isodate # jsonschema + # jwcrypto # libsass # pact-python # pansi diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index c46b480ecd..84bb24d41a 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -189,6 +189,7 @@ cryptography==3.4.8 # -r requirements/edx/base.txt # django-fernet-fields # edx-enterprise + # jwcrypto # pyjwt # social-auth-core cssselect==1.1.0 @@ -214,6 +215,10 @@ defusedxml==0.7.1 # python3-saml # safe-lxml # social-auth-core +deprecated==1.2.12 + # via + # -r requirements/edx/base.txt + # jwcrypto diff-cover==4.0.0 # via # -c requirements/edx/../constraints.txt @@ -746,6 +751,10 @@ jsonfield2==3.0.3 # edx-submissions # lti-consumer-xblock # ora2 +jwcrypto==0.9.1 + # via + # -r requirements/edx/base.txt + # pylti1p3 kombu==4.6.11 # via # -r requirements/edx/base.txt @@ -974,6 +983,7 @@ pyjwt[crypto]==1.7.1 # edx-auth-backends # edx-proctoring # edx-rest-api-client + # pylti1p3 # social-auth-core pylatexenc==2.10 # via @@ -997,6 +1007,8 @@ pylint-plugin-utils==0.6 # pylint-django pylint-pytest==0.3.0 # via -r requirements/edx/testing.in +pylti1p3==1.9.1 + # via -r requirements/edx/base.txt pymongo==3.10.1 # via # -c requirements/edx/../constraints.txt @@ -1145,6 +1157,7 @@ requests==2.26.0 # mailsnake # pact-python # pyjwkest + # pylti1p3 # python-swiftclient # requests-oauthlib # sailthru-client @@ -1232,6 +1245,7 @@ six==1.16.0 # httpretty # interchange # isodate + # jwcrypto # libsass # pact-python # pansi