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