def get_passport(self): """Find and return the passport targeted by the LTI request or raise an LTIException.""" consumer_key = self.request.POST.get("oauth_consumer_key", None) if not consumer_key: raise LTIException("An oauth consumer key is required.") # find a passport related to the oauth consumer key try: return LTIPassport.objects.get(oauth_consumer_key=consumer_key, is_enabled=True) except LTIPassport.DoesNotExist as err: raise LTIException( "Could not find a valid passport for this oauth consumer key: {:s}." .format(consumer_key)) from err
def valid_lti_request(user_payload, request): username = user_payload.get(settings.LTI_PERSON_SOURCED_ID_FIELD, None) email = user_payload.get(settings.LTI_EMAIL_FIELD, None) canvas_course_id = user_payload.get(settings.LTI_CANVAS_COURSE_ID_FIELD, None) first_name = user_payload.get(settings.LTI_FIRST_NAME, None) last_name = user_payload.get(settings.LTI_LAST_NAME, None) if username: try: user_obj = User.objects.get(username=username) except User.DoesNotExist: password = ''.join( random.sample(string.ascii_letters, RANDOM_PASSWORD_DEFAULT_LENGTH)) user_obj = User.objects.create_user(username=username, email=email, password=password, first_name=first_name, last_name=last_name) user_obj.backend = 'django.contrib.auth.backends.ModelBackend' login(request, user_obj) else: #handle no username from LTI launch raise LTIException( "No username supplied in the launch, you should check your provider and/or settings." ) url = reverse('home') if canvas_course_id: url = reverse('courses', kwargs={'course_id': canvas_course_id}) return url
def valid_lti_request(user_payload, request): logger.info(valid_lti_request.__name__) username = user_payload.get("custom_canvas_user_login_id", None) email = user_payload.get("lis_person_contact_email_primary", None) canvas_course_id = user_payload.get("custom_canvas_course_id", None) first_name = user_payload.get("lis_person_name_given", None) last_name = user_payload.get("lis_person_name_family", None) if username: try: user_obj = User.objects.get(username=username) except User.DoesNotExist: password = ''.join( random.sample(string.ascii_letters, RANDOM_PASSWORD_DEFAULT_LENGTH)) user_obj = User.objects.create_user(username=username, email=email, password=password, first_name=first_name, last_name=last_name) user_obj.backend = 'django.contrib.auth.backends.ModelBackend' login(request, user_obj) request.session['course_id'] = canvas_course_id else: # handle no username from LTI launch raise LTIException( "No username supplied in the launch, you should check your provider and/or settings." ) url = reverse('home') return url
def test_dispatch_lti_failure(self, mock_validate_request): """ Test LTI verification failure """ mock_validate_request.side_effect = LTIException("Unknown request type") response = self.client.post( "/lti/lti_initializer/", { "lis_person_contact_email_primary": "*****@*****.**", "custom_canvas_course_id": "327", "context_title": "test title", "ext_roles": "urn:something/something-else/Instructor,urn:something/something-else/Student" } ) self.assertContains(response, "Your session has expired. Please, relaunch the tool via your canvas course.")
def test_it_caches_a_failed_verification_result(self, launch_verifier, pylti): pylti.common.verify_request_common.side_effect = LTIException() # Even if verify_lti_launch_request() is called multiple times, the # actual verification is done only once per request. with pytest.raises(LTIOAuthError): launch_verifier.verify() with pytest.raises(LTIOAuthError): launch_verifier.verify() with pytest.raises(LTIOAuthError): launch_verifier.verify() assert pylti.common.verify_request_common.call_count == 1
def verify(self, request): """ Verify if LTI request is valid, validation depends on arguments :raises: LTIException """ if self.request_type == 'session': self._verify_session(request) elif self.request_type == 'initial': self._verify_request(request) elif self.request_type == 'any': self._verify_any(request) else: raise LTIException('Unknown request type') return True
def _validate_role(self): """ Check that user is in accepted/specified role :exception: LTIException if user is not in roles """ if self.role_type != u'any': if self.role_type in LTI_ROLES: role_list = LTI_ROLES[self.role_type] # find the intersection of the roles roles = set(role_list) & set(self.user_roles()) if len(roles) < 1: raise LTIRoleException('Not authorized.') else: raise LTIException("Unknown role {}.".format(self.role_type)) return True
def verify(self): """Verify the LTI request. Raises ------ LTIException Raised if request validation fails ImproperlyConfigured Raised if BYPASS_LTI_VERIFICATION is True but we are not in DEBUG mode Returns ------- string It returns the consumer site related to the passport used in the LTI launch request if it is valid. If the BYPASS_LTI_VERIFICATION and DEBUG settings are True, it creates and return a consumer site with the consumer site domain passed in the LTI request. """ if self._consumer_site: return True if not self.context_id: raise LTIException("A context ID is required.") request_domain = self.request_domain if settings.BYPASS_LTI_VERIFICATION: if not settings.DEBUG: raise ImproperlyConfigured( "Bypassing LTI verification only works in DEBUG mode." ) if not request_domain: raise LTIException( "You must provide an http referer in your LTI launch request." ) self._consumer_site, _created = ConsumerSite.objects.get_or_create( domain=request_domain, defaults={"name": request_domain} ) return True passport = self.get_passport() consumers = { str(passport.oauth_consumer_key): {"secret": str(passport.shared_secret)} } # The LTI signature is computed using the url of the LTI launch request. But when Marsha # is behind a TLS termination proxy, the url as seen by Django is changed and starts with # "http". We need to revert this so that the signature we calculate matches the one # calculated by our LTI consumer. # Note that this is normally done in pylti's "verify_request_common" method but it does # not support WSGI normalized headers so let's do it ourselves. url = build_absolute_uri_behind_proxy(self.request) # A call to the verification function should raise an LTIException but # we can further check that it returns True. if ( verify_request_common( consumers, url, self.request.method, self.request.META, dict(self.request.POST.items()), ) is not True ): raise LTIException("LTI verification failed.") consumer_site = passport.consumer_site or passport.playlist.consumer_site # Make sure we only accept requests from domains in which the "top parts" match # the URL for the consumer_site associated with the passport. # eg. sub.example.com & example.com for an example.com consumer site. # Also referer matching ALLOWED_HOSTS are accepted if ( request_domain and request_domain != consumer_site.domain and not ( request_domain.endswith(f".{consumer_site.domain}") or validate_host(request_domain, settings.ALLOWED_HOSTS) ) ): raise LTIException( ( f"Host domain ({request_domain}) does not match registered passport " f"({consumer_site.domain})." ) ) self._consumer_site = consumer_site return True
class DocumentLTIViewTestCase(TestCase): """Test case for the file LTI view.""" @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_document_instructor_same_playlist( self, mock_get_consumer_site, mock_verify ): """Validate the format of the response returned by the view for an instructor request.""" passport = ConsumerSiteLTIPassportFactory() document = DocumentFactory( playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, upload_state=random.choice([s[0] for s in STATE_CHOICES]), uploaded_on="2019-09-24 07:24:40+00", ) data = { "resource_link_id": document.lti_id, "context_id": document.playlist.lti_id, "roles": random.choice(["instructor", "administrator"]), "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "lis_person_sourcedid": "jane_doe", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post(f"/lti/documents/{document.pk}", data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(html.unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(document.id)) self.assertEqual( jwt_token.payload["user"], { "email": None, "id": "56255f3807599c377bf0e5bf072359fd", "username": "******", "user_fullname": None, }, ) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "fr_FR") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": True, "can_update": True}, ) self.assertEqual(context.get("state"), "success") self.assertIsNotNone(context.get("resource")) self.assertEqual(context.get("modelName"), "documents") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_document_instructor_other_playlist( self, mock_get_consumer_site, mock_verify ): """Validate the response returned by the view for an instructor from another playlist.""" passport = ConsumerSiteLTIPassportFactory() document = DocumentFactory( playlist__consumer_site=passport.consumer_site, playlist__is_portable_to_playlist=True, playlist__lti_id="course-v1:ufr+mathematics+00001", upload_state=random.choice([s[0] for s in STATE_CHOICES]), uploaded_on="2019-09-24 07:24:40+00", ) other_playlist = PlaylistFactory( consumer_site=passport.consumer_site, lti_id="course-v1:amd+litterature+00003", ) data = { "resource_link_id": document.lti_id, "context_id": other_playlist.lti_id, "roles": random.choice(["instructor", "administrator"]), "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "lis_person_sourcedid": "jane_doe", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post(f"/lti/documents/{document.pk}", data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(html.unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(document.id)) self.assertEqual( jwt_token.payload["user"], { "email": None, "id": "56255f3807599c377bf0e5bf072359fd", "username": "******", "user_fullname": None, }, ) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "fr_FR") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": True, "can_update": False}, ) self.assertEqual(context.get("state"), "success") self.assertIsNotNone(context.get("resource")) self.assertEqual(context.get("modelName"), "documents") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_document_student_with_video( self, mock_get_consumer_site, mock_verify ): """Validate the format of the response returned by the view for a student request.""" passport = ConsumerSiteLTIPassportFactory() document = DocumentFactory( playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, upload_state=random.choice([s[0] for s in STATE_CHOICES]), uploaded_on="2019-09-24 07:24:40+00", ) data = { "resource_link_id": document.lti_id, "context_id": document.playlist.lti_id, "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "lis_person_sourcedid": "jane_doe", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post(f"/lti/documents/{document.pk}", data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(html.unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(document.id)) self.assertEqual( jwt_token.payload["user"], { "email": None, "id": "56255f3807599c377bf0e5bf072359fd", "username": "******", "user_fullname": None, }, ) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "fr_FR") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": False, "can_update": False}, ) self.assertEqual(context.get("state"), "success") self.assertIsNotNone(context.get("resource")) self.assertEqual(context.get("modelName"), "documents") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_document_student_no_video( self, mock_get_consumer_site, mock_verify ): """Validate the response returned for a student request when there is no file.""" passport = ConsumerSiteLTIPassportFactory() data = { "resource_link_id": "example.com-123", "context_id": "course-v1:ufr+mathematics+00001", "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "lis_person_sourcedid": "jane_doe", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post(f"/lti/documents/{uuid.uuid4()}", data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(html.unescape(match.group(1))) self.assertEqual(context.get("state"), "success") self.assertIsNone(context.get("resource")) self.assertEqual(context.get("modelName"), "documents") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_document_instructor_no_video( self, mock_get_consumer_site, mock_verify ): """Validate the response returned for an instructor request when there is no file.""" passport = ConsumerSiteLTIPassportFactory() data = { "resource_link_id": "example.com-123", "context_id": "course-v1:ufr+mathematics+00001", "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "lis_person_sourcedid": "jane_doe", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post(f"/lti/documents/{uuid.uuid4()}", data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(html.unescape(match.group(1))) self.assertIsNotNone(context.get("jwt")) self.assertEqual(context.get("state"), "success") self.assertIsNotNone(context.get("resource")) self.assertEqual(context.get("modelName"), "documents") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(Logger, "warning") @mock.patch.object(LTI, "verify", side_effect=LTIException("lti error")) def test_views_lti_document_post_error(self, mock_verify, mock_logger): """Validate the response returned in case of an LTI exception.""" role = random.choice(["instructor", "student"]) data = {"resource_link_id": "123", "roles": role, "context_id": "abc"} response = self.client.post(f"/lti/documents/{uuid.uuid4()}", data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") mock_logger.assert_called_once_with("lti error") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(html.unescape(match.group(1))) self.assertEqual(context.get("state"), "error") self.assertIsNone(context.get("resource"))
class VideoLTIViewTestCase(TestCase): """Test the video view in the ``core`` app of the Marsha project.""" @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_post_instructor(self, mock_get_consumer_site, mock_verify): """Validate the format of the response returned by the view for an instructor request.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["user_id"], data["user_id"]) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "fr_FR") self.assertEqual( jwt_token.payload["permissions"], { "can_access_dashboard": True, "can_update": True }, ) self.assertDictEqual( jwt_token.payload["course"], { "school_name": "ufr", "course_name": "mathematics", "course_run": "00001" }, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("resource"), { "active_stamp": None, "is_ready_to_show": False, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": "pending", "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": None, }, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_post_administrator(self, mock_get_consumer_site, mock_verify): """Validate the format of the response returned by the view for an admin request.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "administrator", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["user_id"], data["user_id"]) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "fr_FR") self.assertEqual( jwt_token.payload["permissions"], { "can_access_dashboard": True, "can_update": True }, ) self.assertDictEqual( jwt_token.payload["course"], { "school_name": "ufr", "course_name": "mathematics", "course_run": "00001" }, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("resource"), { "active_stamp": None, "is_ready_to_show": False, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": "pending", "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": None, }, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_read_other_playlist(self, mock_get_consumer_site, mock_verify): """A video from another portable playlist should have "can_update" set to False.""" passport = ConsumerSiteLTIPassportFactory( consumer_site__domain="example.com") video = VideoFactory( playlist__is_portable_to_playlist=True, playlist__is_portable_to_consumer_site=True, upload_state="ready", ) data = { "resource_link_id": video.lti_id, "context_id": "another-playlist", "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual( jwt_token.payload["permissions"], { "can_access_dashboard": True, "can_update": False }, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("resource"), { "active_stamp": None, "is_ready_to_show": False, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": "ready", "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": None, }, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_post_student_with_video(self, mock_get_consumer_site, mock_verify): """Validate the format of the response returned by the view for a student request.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, upload_state="ready", ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["user_id"], data["user_id"]) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "en_US") self.assertEqual( jwt_token.payload["permissions"], { "can_access_dashboard": False, "can_update": False }, ) self.assertDictEqual( jwt_token.payload["course"], { "school_name": "ufr", "course_name": "mathematics", "course_run": "00001" }, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("resource"), { "active_stamp": None, "is_ready_to_show": False, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": "ready", "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": None, }, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_without_user_id_parameter(self, mock_get_consumer_site, mock_verify): """Ensure JWT is created if user_id is missing in the LTI request.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory(playlist__consumer_site=passport.consumer_site, upload_state="ready") data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "context_title": "mathematics", "tool_consumer_instance_name": "ufr", "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "en_US") self.assertEqual( jwt_token.payload["permissions"], { "can_access_dashboard": False, "can_update": False }, ) self.assertDictEqual( jwt_token.payload["course"], { "school_name": "ufr", "course_name": "mathematics", "course_run": None }, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_post_student_no_video(self, mock_get_consumer_site, mock_verify): """Validate the response returned for a student request when there is no video.""" passport = ConsumerSiteLTIPassportFactory() data = { "resource_link_id": "example.com-123", "context_id": "course-v1:ufr+mathematics+00001", "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(uuid.uuid4()), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content) context = json.loads(unescape(match.group(1))) self.assertEqual(context.get("state"), "success") self.assertIsNone(context.get("resource")) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(Logger, "warning") @mock.patch.object(LTI, "verify", side_effect=LTIException("lti error")) def test_views_lti_video_post_error(self, mock_verify, mock_logger): """Validate the response returned in case of an LTI exception.""" role = random.choice(["instructor", "student"]) data = {"resource_link_id": "123", "roles": role, "context_id": "abc"} response = self.client.post("/lti/videos/{!s}".format(uuid.uuid4()), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") mock_logger.assert_called_once_with("lti error") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content) context = json.loads(unescape(match.group(1))) self.assertEqual(context.get("state"), "error") self.assertIsNone(context.get("resource")) self.assertEqual(context.get("modelName"), "videos") @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_with_timed_text(self, mock_get_consumer_site, mock_verify): """Make sure the LTI Video view functions when the Video has associated TimedTextTracks. NB: This is a bug-reproducing test case. The comprehensive test suite in test_api_video does not cover this case as it uses a JWT and therefore falls in another case when it comes to handling of video ids. """ passport = ConsumerSiteLTIPassportFactory() video = VideoFactory(playlist__consumer_site=passport.consumer_site) # Create a TimedTextTrack associated with the video to trigger the error TimedTextTrackFactory(video=video) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "en_US") self.assertEqual( jwt_token.payload["permissions"], { "can_access_dashboard": True, "can_update": True }, ) self.assertEqual(context.get("modelName"), "videos") @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") @mock.patch.object(staticfiles_storage, "url") def test_views_lti_video_static_base_url_with_trailing_slash( self, mock_staticfiles_storage_url, mock_get_consumer_site, mock_verify): """Trailing slash is kept on static base url when present.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory(playlist__consumer_site=passport.consumer_site) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site mock_staticfiles_storage_url.return_value = "/static/" response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") self.assertContains(response, '<meta name="public-path" value="/static/" />') @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") @mock.patch.object(staticfiles_storage, "url") def test_views_lti_video_static_base_url_without_trailing_slash( self, mock_staticfiles_storage_url, mock_get_consumer_site, mock_verify): """Trailing slash is added on static base url when missing.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory(playlist__consumer_site=passport.consumer_site) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site mock_staticfiles_storage_url.return_value = "/static" response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") self.assertContains(response, '<meta name="public-path" value="/static/" />')
def verify(self): """Verify the LTI request. Raises ------ LTIException Exception raised if request validation fails Returns ------- boolean True if the request is a valid LTI launch request """ consumer_key = self.request.POST.get("oauth_consumer_key", None) consumer_site_name = self.consumer_site_name try: assert consumer_key except AssertionError: raise LTIException("An oauth consumer key is required.") try: assert self.context_id except AssertionError: raise LTIException("A context ID is required.") try: assert consumer_site_name except AssertionError: raise LTIException("A consumer site name is required.") # find a passport related to either the consumer site or the playlist try: lti_passport = LTIPassport.objects.get( Q( oauth_consumer_key=consumer_key, is_enabled=True, consumer_site__name=consumer_site_name, ) | Q( oauth_consumer_key=consumer_key, is_enabled=True, playlist__consumer_site__name=consumer_site_name, )) except LTIPassport.DoesNotExist: raise LTIException( "Could not find a valid passport for this consumer site and this " "oauth consumer key: {:s}/{:s}.".format( consumer_site_name, consumer_key)) consumers = { str(lti_passport.oauth_consumer_key): { "secret": str(lti_passport.shared_secret) } } # A call to the verification function should raise an LTIException but # we can further check that it returns True. if (verify_request_common( consumers, self.request.build_absolute_uri(), self.request.method, self.request.META, dict(self.request.POST.items()), ) is not True): raise LTIException() self._is_verified = True return True
class VideoLTIViewTestCase(TestCase): """Test the video view in the ``core`` app of the Marsha project.""" maxDiff = None @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") @override_settings(SENTRY_DSN="https://sentry.dsn") @override_settings(RELEASE="1.2.3") def test_views_lti_video_post_instructor(self, mock_get_consumer_site, mock_verify): """Validate the format of the response returned by the view for an instructor request.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["user_id"], data["user_id"]) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "fr_FR") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": True, "can_update": True}, ) self.assertDictEqual( jwt_token.payload["course"], {"school_name": "ufr", "course_name": "mathematics", "course_run": "00001"}, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("static"), {"svg": {"plyr": "/static/svg/plyr.svg"}} ) self.assertEqual( context.get("resource"), { "active_stamp": None, "is_ready_to_show": False, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": "pending", "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": None, "should_use_subtitle_as_transcript": False, "has_transcript": False, }, ) self.assertEqual(context.get("modelName"), "videos") self.assertEqual(context.get("sentry_dsn"), "https://sentry.dsn") self.assertEqual(context.get("environment"), "test") self.assertEqual(context.get("release"), "1.2.3") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") @override_settings(EXTERNAL_JAVASCRIPT_SCRIPTS=["https://example.com/test.js"]) def test_views_lti_video_with_external_js_sources( self, mock_get_consumer_site, mock_verify ): """Validate external js sources are added in the lti template.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "administrator", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") self.assertContains(response, '<script src="https://example.com/test.js" >') @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_post_administrator( self, mock_get_consumer_site, mock_verify ): """Validate the format of the response returned by the view for an admin request.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "administrator", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["user_id"], data["user_id"]) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "fr_FR") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": True, "can_update": True}, ) self.assertDictEqual( jwt_token.payload["course"], {"school_name": "ufr", "course_name": "mathematics", "course_run": "00001"}, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("resource"), { "active_stamp": None, "is_ready_to_show": False, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": "pending", "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": None, "should_use_subtitle_as_transcript": False, "has_transcript": False, }, ) self.assertEqual(context.get("modelName"), "videos") self.assertEqual(context.get("sentry_dsn"), None) # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_read_other_playlist( self, mock_get_consumer_site, mock_verify ): """A video from another portable playlist should have "can_update" set to False.""" passport = ConsumerSiteLTIPassportFactory(consumer_site__domain="example.com") video = VideoFactory( id="301b5f4f-b9f1-4a5f-897d-f8f1bf22c396", playlist__is_portable_to_playlist=True, playlist__is_portable_to_consumer_site=True, playlist__title="playlist-003", upload_state=random.choice([s[0] for s in STATE_CHOICES]), uploaded_on="2019-09-24 07:24:40+00", resolutions=[144, 240, 480, 720, 1080], ) data = { "resource_link_id": video.lti_id, "context_id": "another-playlist", "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": True, "can_update": False}, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("resource"), { "active_stamp": "1569309880", "is_ready_to_show": True, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": video.upload_state, "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": { "mp4": { "144": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "mp4/1569309880_144.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-003_1569309880.mp4", "240": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "mp4/1569309880_240.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-003_1569309880.mp4", "480": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "mp4/1569309880_480.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-003_1569309880.mp4", "720": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "mp4/1569309880_720.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-003_1569309880.mp4", "1080": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "mp4/1569309880_1080.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-003_1569309880.mp4", }, "thumbnails": { "144": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "thumbnails/1569309880_144.0000000.jpg", "240": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "thumbnails/1569309880_240.0000000.jpg", "480": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "thumbnails/1569309880_480.0000000.jpg", "720": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "thumbnails/1569309880_720.0000000.jpg", "1080": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "thumbnails/1569309880_1080.0000000.jpg", }, "manifests": { "dash": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "cmaf/1569309880.mpd", "hls": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "cmaf/1569309880.m3u8", }, "previews": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/" "previews/1569309880_100.jpg", }, "should_use_subtitle_as_transcript": False, "has_transcript": False, }, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_restristed_resolutions_list( self, mock_get_consumer_site, mock_verify ): """Urls list should contains resolutions from resolutions field.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( id="59c0fc7a-0f64-46c0-993f-bdf47ecd837f", playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, playlist__title="playlist-002", upload_state=random.choice([s[0] for s in STATE_CHOICES]), uploaded_on="2019-09-24 07:24:40+00", resolutions=[144, 240, 480], ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["user_id"], data["user_id"]) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "en_US") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": False, "can_update": False}, ) self.assertDictEqual( jwt_token.payload["course"], {"school_name": "ufr", "course_name": "mathematics", "course_run": "00001"}, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("resource"), { "active_stamp": "1569309880", "is_ready_to_show": True, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": video.upload_state, "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": { "mp4": { "144": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "mp4/1569309880_144.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-002_1569309880.mp4", "240": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "mp4/1569309880_240.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-002_1569309880.mp4", "480": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "mp4/1569309880_480.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-002_1569309880.mp4", }, "thumbnails": { "144": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "thumbnails/1569309880_144.0000000.jpg", "240": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "thumbnails/1569309880_240.0000000.jpg", "480": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "thumbnails/1569309880_480.0000000.jpg", }, "manifests": { "dash": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "cmaf/1569309880.mpd", "hls": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "cmaf/1569309880.m3u8", }, "previews": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "previews/1569309880_100.jpg", }, "should_use_subtitle_as_transcript": False, "has_transcript": False, }, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_post_student_with_video( self, mock_get_consumer_site, mock_verify ): """Validate the format of the response returned by the view for a student request.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( id="59c0fc7a-0f64-46c0-993f-bdf47ecd837f", playlist__lti_id="course-v1:ufr+mathematics+00001", playlist__consumer_site=passport.consumer_site, playlist__title="playlist-002", upload_state=random.choice([s[0] for s in STATE_CHOICES]), uploaded_on="2019-09-24 07:24:40+00", resolutions=[144, 240, 480, 720, 1080], ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["user_id"], data["user_id"]) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "en_US") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": False, "can_update": False}, ) self.assertDictEqual( jwt_token.payload["course"], {"school_name": "ufr", "course_name": "mathematics", "course_run": "00001"}, ) self.assertEqual(context.get("state"), "success") self.assertEqual( context.get("resource"), { "active_stamp": "1569309880", "is_ready_to_show": True, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": video.upload_state, "timed_text_tracks": [], "thumbnail": None, "title": video.title, "urls": { "mp4": { "144": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "mp4/1569309880_144.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-002_1569309880.mp4", "240": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "mp4/1569309880_240.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-002_1569309880.mp4", "480": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "mp4/1569309880_480.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-002_1569309880.mp4", "720": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "mp4/1569309880_720.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-002_1569309880.mp4", "1080": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "mp4/1569309880_1080.mp4?response-content-disposition=attachment%3B+" "filename%3Dplaylist-002_1569309880.mp4", }, "thumbnails": { "144": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "thumbnails/1569309880_144.0000000.jpg", "240": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "thumbnails/1569309880_240.0000000.jpg", "480": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "thumbnails/1569309880_480.0000000.jpg", "720": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "thumbnails/1569309880_720.0000000.jpg", "1080": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "thumbnails/1569309880_1080.0000000.jpg", }, "manifests": { "dash": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "cmaf/1569309880.mpd", "hls": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "cmaf/1569309880.m3u8", }, "previews": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/" "previews/1569309880_100.jpg", }, "should_use_subtitle_as_transcript": False, "has_transcript": False, }, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_without_user_id_parameter( self, mock_get_consumer_site, mock_verify ): """Ensure JWT is created if user_id is missing in the LTI request.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory( playlist__consumer_site=passport.consumer_site, upload_state=random.choice([s[0] for s in STATE_CHOICES]), uploaded_on="2019-09-24 07:24:40+00", resolutions=[144, 240, 480, 720, 1080], ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "context_title": "mathematics", "tool_consumer_instance_name": "ufr", "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "en_US") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": False, "can_update": False}, ) self.assertDictEqual( jwt_token.payload["course"], {"school_name": "ufr", "course_name": "mathematics", "course_run": None}, ) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_post_student_no_video( self, mock_get_consumer_site, mock_verify ): """Validate the response returned for a student request when there is no video.""" passport = ConsumerSiteLTIPassportFactory() data = { "resource_link_id": "example.com-123", "context_id": "course-v1:ufr+mathematics+00001", "roles": "student", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(uuid.uuid4()), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) self.assertEqual(context.get("state"), "success") self.assertIsNone(context.get("resource")) self.assertEqual(context.get("modelName"), "videos") # Make sure we only go through LTI verification once as it is costly (getting passport + # signature) self.assertEqual(mock_verify.call_count, 1) @mock.patch.object(Logger, "warning") @mock.patch.object(LTI, "verify", side_effect=LTIException("lti error")) def test_views_lti_video_post_error(self, mock_verify, mock_logger): """Validate the response returned in case of an LTI exception.""" role = random.choice(["instructor", "student"]) data = {"resource_link_id": "123", "roles": role, "context_id": "abc"} response = self.client.post("/lti/videos/{!s}".format(uuid.uuid4()), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") mock_logger.assert_called_once_with("lti error") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) self.assertEqual(context.get("state"), "error") self.assertIsNone(context.get("resource")) self.assertEqual(context.get("modelName"), "videos") @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_with_timed_text(self, mock_get_consumer_site, mock_verify): """Make sure the LTI Video view functions when the Video has associated TimedTextTracks. NB: This is a bug-reproducing test case. The comprehensive test suite in test_api_video does not cover this case as it uses a JWT and therefore falls in another case when it comes to handling of video ids. """ passport = ConsumerSiteLTIPassportFactory() video = VideoFactory(playlist__consumer_site=passport.consumer_site) # Create a TimedTextTrack associated with the video to trigger the error TimedTextTrackFactory(video=video) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) jwt_token = AccessToken(context.get("jwt")) self.assertEqual(jwt_token.payload["resource_id"], str(video.id)) self.assertEqual(jwt_token.payload["context_id"], data["context_id"]) self.assertEqual(jwt_token.payload["roles"], [data["roles"]]) self.assertEqual(jwt_token.payload["locale"], "en_US") self.assertEqual( jwt_token.payload["permissions"], {"can_access_dashboard": True, "can_update": True}, ) self.assertEqual(context.get("modelName"), "videos") @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") @override_settings(ABSOLUTE_STATIC_URL="/static/") def test_views_lti_video_static_base_url(self, mock_get_consumer_site, mock_verify): """Meta tag public-path should be the ABSOLUTE_STATIC_URL settings with js/ at the end.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory(playlist__consumer_site=passport.consumer_site) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") self.assertContains(response, '<meta name="public-path" value="/static/js/" />') @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_has_transcript(self, mock_get_consumer_site, mock_verify): """Compute has_transcript when a transcript is uploaded.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory(playlist__consumer_site=passport.consumer_site) # Create a TimedTextTrack associated with the video to trigger the error transcript = TimedTextTrackFactory( video=video, mode="ts", upload_state=READY, uploaded_on="2019-09-24 07:24:40+00", ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) self.assertEqual( context.get("resource"), { "active_stamp": None, "is_ready_to_show": False, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": "pending", "timed_text_tracks": [ { "active_stamp": "1569309880", "id": str(transcript.id), "is_ready_to_show": True, "mode": "ts", "language": transcript.language, "upload_state": "ready", "url": "https://abc.cloudfront.net/{!s}/timedtext/" "1569309880_{!s}_ts.vtt".format(video.id, transcript.language), "video": str(video.id), } ], "thumbnail": None, "title": video.title, "urls": None, "should_use_subtitle_as_transcript": False, "has_transcript": True, }, ) @mock.patch.object(LTI, "verify") @mock.patch.object(LTI, "get_consumer_site") def test_views_lti_video_has_transcript_false( self, mock_get_consumer_site, mock_verify ): """Compute has_transcript when a transcript is uploaded and not ready to be shown.""" passport = ConsumerSiteLTIPassportFactory() video = VideoFactory(playlist__consumer_site=passport.consumer_site) # Create a TimedTextTrack associated with the video to trigger the error transcript = TimedTextTrackFactory( video=video, mode="ts", upload_state=PENDING, ) data = { "resource_link_id": video.lti_id, "context_id": video.playlist.lti_id, "roles": "instructor", "oauth_consumer_key": passport.oauth_consumer_key, "user_id": "56255f3807599c377bf0e5bf072359fd", "launch_presentation_locale": "fr", } mock_get_consumer_site.return_value = passport.consumer_site response = self.client.post("/lti/videos/{!s}".format(video.pk), data) self.assertEqual(response.status_code, 200) self.assertContains(response, "<html>") content = response.content.decode("utf-8") match = re.search( '<div id="marsha-frontend-data" data-context="(.*)">', content ) context = json.loads(unescape(match.group(1))) self.assertEqual( context.get("resource"), { "active_stamp": None, "is_ready_to_show": False, "show_download": True, "description": video.description, "id": str(video.id), "upload_state": "pending", "timed_text_tracks": [ { "active_stamp": None, "id": str(transcript.id), "is_ready_to_show": False, "mode": "ts", "language": transcript.language, "upload_state": "pending", "url": None, "video": str(video.id), } ], "thumbnail": None, "title": video.title, "urls": None, "should_use_subtitle_as_transcript": False, "has_transcript": False, }, )
def verify(self): """Verify the LTI request. Raises ------ LTIException Raised if request validation fails ImproperlyConfigured Raised if BYPASS_LTI_VERIFICATION is True but we are not in DEBUG mode Returns ------- string It returns the consumer site related to the passport used in the LTI launch request if it is valid. If the BYPASS_LTI_VERIFICATION and DEBUG settings are True, it creates and return a consumer site with the consumer site domain passed in the LTI request. """ if settings.BYPASS_LTI_VERIFICATION: if not settings.DEBUG: raise ImproperlyConfigured( "Bypassing LTI verification only works in DEBUG mode.") return ConsumerSite.objects.get_or_create( domain=self.consumer_site_domain, defaults={"name": self.consumer_site_domain}, )[0] passport = self.get_passport() consumers = { str(passport.oauth_consumer_key): { "secret": str(passport.shared_secret) } } # The LTI signature is computed using the url of the LTI launch request. But when Marsha # is behind a TLS termination proxy, the url as seen by Django is changed and starts with # "http". We need to revert this so that the signature we calculate matches the one # calculated by our LTI consumer. # Note that this is normally done in pylti's "verify_request_common" method but it does # not support WSGI normalized headers so let's do it ourselves. url = self.request.build_absolute_uri() if self.request.META.get("HTTP_X_FORWARDED_PROTO", "http") == "https": url = url.replace("http:", "https:", 1) # A call to the verification function should raise an LTIException but # we can further check that it returns True. if (verify_request_common( consumers, url, self.request.method, self.request.META, dict(self.request.POST.items()), ) is not True): raise LTIException() consumer_site = passport.consumer_site or passport.playlist.consumer_site # Make sure we only accept requests from domains in which the "top parts" match # the URL for the consumer_site associated with the passport. # eg. sub.example.com & example.com for an example.com consumer site. domain_check = urlparse(self.request.META.get("HTTP_REFERER")).hostname if domain_check != consumer_site.domain and not domain_check.endswith( ".{:s}".format(consumer_site.domain)): raise LTIException( "Host domain does not match registered passport.") return consumer_site
def get_or_create_video(self): """Get or create the video targeted by the LTI launch request. Create the playlist if it does not pre-exist (it can only happen with consumer site scope passports). Raises ------ LTIException Exception raised if the request does not contain a context id (playlist). Returns ------- core.models.video.Video The video instance targeted by the `resource_link_id` or None. """ # Make sure LTI verification has run successfully. It raises an LTIException otherwise. consumer_site = self.verify() try: assert self.context_id except AssertionError: raise LTIException("A context ID is required.") # If the video already exist, retrieve it from database try: return Video.objects.get( lti_id=self.resource_link_id, playlist__lti_id=self.context_id, playlist__consumer_site=consumer_site, ) except Video.DoesNotExist: # Look for a video with the same lti id from another playlist on the same # consumer site origin_video = (Video.objects.filter( lti_id=self.resource_link_id, playlist__consumer_site=consumer_site, playlist__is_portable_to_playlist=True, upload_state=READY, ).order_by("-uploaded_on").first()) if origin_video is None: # Look for a video with the same lti id from the same playlist on another # consumer site that is portable to the current consumer site of the LTI request origin_video = (Video.objects.filter( Q(playlist__is_portable_to_consumer_site=True) | Q(playlist__consumer_site__in=consumer_site. reachable_from.all()), playlist__lti_id=self.context_id, lti_id=self.resource_link_id, upload_state=READY, ).order_by("-uploaded_on").first()) if origin_video is None: # Look for a video with the same lti id from another playlist on another consumer # site that is portable to the current consumer site of the LTI request origin_video = (Video.objects.filter( Q(playlist__is_portable_to_consumer_site=True) | Q(playlist__consumer_site__in=consumer_site. reachable_from.all()), playlist__is_portable_to_playlist=True, lti_id=self.resource_link_id, upload_state=READY, ).order_by("-uploaded_on").first()) # If we still didn't find any existing video, we will only create a new video if the # request comes from an instructor if origin_video is None and not self.is_instructor: return None # Creating the video... # - Get the playlist if it exists or create it playlist, _ = Playlist.objects.get_or_create( lti_id=self.context_id, consumer_site=consumer_site, defaults={ "title": self.context_title, "is_portable_to_playlist": (origin_video.playlist.is_portable_to_playlist if origin_video else True), "is_portable_to_consumer_site": (origin_video.playlist.is_portable_to_consumer_site if origin_video else False), }, ) # Create the video, pointing to the file from the origin video if any if origin_video: return origin_video.duplicate(playlist) return Video.objects.create( lti_id=self.resource_link_id, playlist=playlist, upload_state=PENDING, title=self.resource_link_title, )