def post(self, request, pk): ''' The LTI tool provider uses POST to send a real, OAuth-signed, request for the LTI provider content. ''' logger.debug("Incoming POST request through LTI") tool_provider = DjangoToolProvider.from_django_request(request=request) validator = LtiRequestValidator(pk) if tool_provider.is_valid_request(validator): logger.debug("Valid OAuth request through LTI") if request.user.is_authenticated: logger.debug("LTI consumer is already authenticated") return redirect(reverse('lti_submission', args=[pk])) else: logger.debug( "LTI consumer needs OpenSubmit user, starting auth pipeline" ) # Store data being used by the Django Social Auth Provider for account creation data = request.POST.copy() data['assignment_pk'] = pk request.session[lti.SESSION_VAR] = data return redirect( reverse('social:begin', args=['lti']) + "?next=" + reverse('lti_submission', args=[pk])) else: logger.error("Invalid OAuth request through LTI") raise PermissionDenied
def _validate_request(self, request): """ Validates an LTI launch request. """ validator = LTIRequestValidator() tool_provider = DjangoToolProvider.from_django_request(request=request) postparams = request.POST.dict() self.logger.debug("request is secure: %s" % request.is_secure()) for key in postparams: self.logger.debug("POST %s: %s" % (key, postparams.get(key))) self.logger.debug("request abs url is %s" % request.build_absolute_uri()) for key in request.META: self.logger.debug("META %s: %s" % (key, request.META.get(key))) self.logger.debug("about to check the signature") # NOTE: before validating the request, temporarily remove the # QUERY_STRING to work around an issue with how Canvas signs requests # that contain GET parameters. Before Canvas launches the tool, it duplicates the GET # parameters as POST parameters, and signs the POST parameters (*not* the GET parameters). # However, the oauth2 library that validates the request generates # the oauth signature based on the combination of POST+GET parameters together, # resulting in a signature mismatch. By removing the QUERY_STRING before # validating the request, the library will generate the signature based only on # the POST parameters like Canvas. # # 03feb20 naomi: TODO check if removing query string still needed, # since change to pylti/lti which uses oauthlib. It looks like oauthlib # correctly disregards query string in # oauthlib/oauth1/rfc5849/signature:base_string_uri() # -- could not force a query string in unit tests using django.test.Client qs = request.META.pop("QUERY_STRING", "") self.logger.debug("removed query string temporarily: %s" % qs) request_is_valid = tool_provider.is_valid_request(validator) request.META["QUERY_STRING"] = qs # restore the query string self.logger.debug("restored query string: %s" % request.META["QUERY_STRING"]) if not request_is_valid: self.logger.error("signature check failed") raise PermissionDenied self.logger.info("signature verified") for required_param in ("resource_link_id", "context_id", "user_id"): if required_param not in request.POST: self.logger.error( "Required LTI param '%s' was not present in request" % required_param) raise LTILaunchError( "missing LTI param {}".format(required_param)) if ("lis_person_sourcedid" not in request.POST and "lis_person_name_full" not in request.POST and request.POST["user_id"] != "student"): self.logger.error( "person identifier (i.e. username) or full name was not present in request" ) raise LTILaunchError("missing LTI param: person identifier")
def lti_launch(request, qset_id=None): """ Endpoint for all requests to embed LMS content via the LTI protocol. An LTI launch is successful if: - The launch contains all the required parameters - The launch data is correctly signed using a known client key/secret pair """ try: tool_provider = DjangoToolProvider.from_django_request(request=request) validator = SignatureValidator() ok = tool_provider.is_valid_request(validator) except (oauth1.OAuth1Error, InvalidLTIRequestError, ValueError) as err: ok = False log.error('Error happened while LTI request: {}'.format(err.__str__())) if not ok: raise Http404('LTI request is not valid') anononcement_page = render(request, template_name="askup_lti/announcement.html", context={ 'title': 'announcement', 'message': 'coming soon!', 'tip': 'this subject is about to open.', }) qset = Qset.objects.filter(id=qset_id).first() if not qset_id or not qset: return anononcement_page return student_lti_flow(request, qset)
def test_lti_validation_default_key_unknown_consumer_fail(): target_path = "/some_path" launch_url = "http://testserver{}".format(target_path) resource_link_id = "some_string_to_be_the_fake_resource_link_id" consumer = ToolConsumer( consumer_key="unknown_consumer_key", consumer_secret=settings.LTI_SECRET, launch_url=launch_url, params={ "lti_message_type": "basic-lti-launch-request", "lti_version": "LTI-1p0", "resource_link_id": resource_link_id, "lis_person_sourcedid": "instructor_1", "lis_outcome_service_url": "fake_url", "user_id": "instructor_1-anon", "roles": ["Instructor", "Administrator"], "context_id": "fake_course", }, ) params = consumer.generate_launch_data() for key in params: print("****** LTI[{}]: {}".format(key, params[key])) factory = RequestFactory() request = factory.post(target_path, data=params) validator = LTIRequestValidator() tool_provider = DjangoToolProvider.from_django_request(request=request) request_is_valid = tool_provider.is_valid_request(validator) assert not request_is_valid
def test_lti_validation_from_ltidict_ok(): target_path = reverse("hx_lti_initializer:launch_lti") launch_url = "http://testserver{}".format(target_path) resource_link_id = "some_string_to_be_the_fake_resource_link_id" consumer = ToolConsumer( consumer_key=settings.CONSUMER_KEY, consumer_secret=settings.TEST_COURSE_LTI_SECRET, launch_url=launch_url, params={ "lti_message_type": "basic-lti-launch-request", "lti_version": "LTI-1p0", "resource_link_id": resource_link_id, "lis_person_sourcedid": "instructor_1", "lis_outcome_service_url": "fake_url", "user_id": "instructor_1-anon", "roles": ["Instructor", "Administrator"], "context_id": settings.TEST_COURSE, }, ) params = consumer.generate_launch_data() for key in params: print("****** LTI[{}]: {}".format(key, params[key])) factory = RequestFactory() request = factory.post(target_path, data=params) validator = LTIRequestValidator() tool_provider = DjangoToolProvider.from_django_request(request=request) request_is_valid = tool_provider.is_valid_request(validator) assert request_is_valid
def _decorator(request, *args, **kwargs): # is this an lti launch request? # https://www.imsglobal.org/recipe-making-lti-1-tool-providers if request.method == "POST": is_basic_lti_launch = (request.POST.get( "lti_message_type", "") == "basic-lti-launch-request") has_lti_version = request.POST.get("lti_version", "") == "LTI-1p0" oauth_consumer_key = request.POST.get("oauth_consumer_key", None) resource_link_id = request.POST.get("resource_link_id", None) else: return HttpResponseBadRequest() if not (is_basic_lti_launch and has_lti_version and oauth_consumer_key and resource_link_id): return HttpResponseBadRequest() # lti request authentication validator = LTIRequestValidator() tool_provider = DjangoToolProvider.from_django_request( secret=validator.get_client_secret(oauth_consumer_key, request), request=request, ) valid_lti_request = tool_provider.is_valid_request(validator) if valid_lti_request: response = view_func(request, *args, **kwargs) return response else: return HttpResponseForbidden()
def lti_launch(request, slug): app = get_object_or_404(LTIApp, slug=slug) tool_provider = DjangoToolProvider.from_django_request(request=request) validator = LTIRequestValidator() ok = tool_provider.is_valid_request(validator) if not ok: return HttpResponseForbidden( 'The launch request is considered invalid') client_key = tool_provider.consumer_key # request would not be ok if this is not set try: tenant = app.ltitenant_set.get(client_key=client_key) except LTITenant.DoesNotExist: return HttpResponseForbidden( '{} does not have access to app {}'.format(client_key, app)) # First thing is to create and login a user, because this influences the session if app.privacy_level != LTIPrivacyLevels.ANONYMOUS: user = authenticate( request, remote_user=request.POST.get("lis_person_contact_email_primary")) if user is not None: login(request, user) # After we have a user we're gonna set its session based on tenant settings # This authorizes a user to use tenant LMS API's tenant.start_session(request, request.POST.dict()) # Lookup the view and return its response # Redirect impossible because we need to set cookies for sessions url = reverse(app.view) view = resolve(url) return view.func(request)
def test_secret_not_required(self): from lti.contrib.django import DjangoToolProvider mock_req = Mock() mock_req.POST = {'oauth_consumer_key': 'foo'} mock_req.META = {'CONTENT_TYPE': 'bar'} mock_req.build_absolute_uri.return_value = 'http://example.edu/foo/bar' tp = DjangoToolProvider.from_django_request(request=mock_req) self.assertEqual(tp.consumer_key, 'foo') self.assertEqual(tp.launch_headers['CONTENT_TYPE'], 'bar') self.assertEqual(tp.launch_url, 'http://example.edu/foo/bar')
def _get_tool_provider(self): try: lti_secret = settings.LTI_SECRET_DICT[self.request.LTI.get( "hx_context_id")] except KeyError: lti_secret = settings.LTI_SECRET if "launch_params" in self.request.LTI: params = self.request.LTI["launch_params"] # the middleware includes an LTI dict with all lti params for # lti_grade_passback() -- an lti request that is not a lti-launch. # py-lti only understands lti params that come directly in the POST mutable_post = self.request.POST.copy() mutable_post.update(params) self.request.POST = mutable_post return DjangoToolProvider.from_django_request(lti_secret, request=self.request) return DjangoToolProvider.from_django_request(lti_secret, request=self.request)
def get_tool_provider_for_lti(request): """ Return tool provider for the given request. In case of invalid lti request return None. """ try: tool_provider = DjangoToolProvider.from_django_request(request=request) validator = SignatureValidator() if tool_provider.is_valid_request(validator): return tool_provider except (oauth1.OAuth1Error, InvalidLTIRequestError, ValueError) as err: log.error('Error happened while LTI request: {}'.format(err.__str__())) return None
def test_learner_flow_different_user_creation(self): mock_request = RequestFactory().post( '', data={ 'oauth_nonce': 'oauth_nonce', 'oauth_consumer_key': self.lti_provider.consumer_key, 'roles': 'Learner', 'user_id': 'user_id', 'context_id': 'some+course+id' }) middleware = SessionMiddleware() middleware.process_request(mock_request) mock_request.session.save() tool_provider = DjangoToolProvider.from_django_request( request=mock_request) count_of_the_sequence = Sequence.objects.all().count() count_of_lti_users = LtiUser.objects.all().count() # learner_flow is called 2 times (here and below) to ensure that implement logic works correctly learner_flow(mock_request, self.lti_provider, tool_provider, self.collection1.slug, self.test_cg.slug) learner_flow(mock_request, self.lti_provider, tool_provider, self.collection1.slug, self.test_cg.slug) self.assertEqual(Sequence.objects.all().count(), count_of_the_sequence + 1) count_of_the_sequence += 1 learner_flow(mock_request, self.lti_provider, tool_provider, self.collection1.slug, self.test_cg.slug, 'marker') learner_flow(mock_request, self.lti_provider, tool_provider, self.collection1.slug, self.test_cg.slug, 'marker') self.assertEqual(Sequence.objects.all().count(), count_of_the_sequence + 1) count_of_the_sequence += 1 learner_flow(mock_request, self.lti_provider, tool_provider, self.collection1.slug, self.test_cg.slug, 'marker1') learner_flow(mock_request, self.lti_provider, tool_provider, self.collection1.slug, self.test_cg.slug, 'marker2') self.assertEqual(Sequence.objects.all().count(), count_of_the_sequence + 2) # Ensure that only one LTI user was created. self.assertEqual(LtiUser.objects.all().count(), count_of_lti_users + 1)
def post(self, request): tool_provider = DjangoToolProvider.from_django_request(request=request) validator = utils.OutpostRequestValidator() ok = tool_provider.is_valid_request(validator) if not ok: return HttpResponseForbidden() try: consumer = Consumer.objects.get(key=tool_provider.consumer_key, enabled=True) except Consumer.DoesNotExist: return HttpResponseForbidden() params = tool_provider.to_params() username = params.get("ext_user_username") user = get_user_model().objects.get(username=username) login(request, user, backend="django.contrib.auth.backends.ModelBackend") # import pudb; pu.db language = params.get("launch_presentation_locale", settings.LANGUAGE_CODE) with translation.override(language): try: resource = Resource.objects.get( consumer=consumer, resource=params.get("resource_link_id")) return resource.render(request, consumer, user, tool_provider) except Resource.DoesNotExist: roles = params.get("roles") grs = GroupRole.objects.filter(role__in=roles) groups = user.groups.all() for gr in grs: if gr.group not in groups: user.groups.add(gr.group) if not user.has_perm("lti.add_resource"): return HttpResponseForbidden() return TemplateResponse( request, "lti/index.html", { "consumer": consumer, "user": user, "tool_provider": tool_provider, "resource_classes": Resource.__subclasses__(), }, )
def validate_request(request): """ Function to validate that the request is a valid LTI Launch Request. """ if request is None: raise ValueError("Request can't be none!") params = request.POST.copy() url = request.build_absolute_uri() consumers = settings.LTI_OAUTH_CREDENTIALS valid_lti = verify_request_common(consumers, url, request.method, request.META, params) # TODO: If possible, then implement the oauth verification # TODO: remove DjangoToolProvider thing from here! ''' print("validity of valid_lti = ", valid_lti) validator = ProxyValidator(LTIValidator()) endpoint = SignatureOnlyEndpoint(validator) # oauth validation for nonce, timestamp etc... headers = dict([(k, request.META[k]) for k in request.META if k.upper().startswith('HTTP_') or k.upper().startswith('CONTENT_')]) print(request.method) valid_req, request = endpoint.validate_request( url, request.method, to_params(params), headers ) print("validity of valid_req = ", request) ''' tool_provider = DjangoToolProvider.from_django_request(request=request) # the tool provider uses the 'oauthlib' library which requires an instance # of a validator class when doing the oauth request signature checking. # see https://oauthlib.readthedocs.org/en/latest/oauth1/validator.html for # info on how to create one validator = LTIValidator() # validate the oauth request signature ok = tool_provider.is_valid_request(validator) return valid_lti and ok
def post(self, request, pk): ''' The LTI tool provider uses POST to send a real, OAuth-signed, request for the LTI provider content. ''' logger.debug("Incoming POST request through LTI") from lti.contrib.django import DjangoToolProvider tool_provider = DjangoToolProvider.from_django_request(request=request) if not tool_provider.launch_url.startswith(settings.HOST): # When OpenSubmit runs behind an SSL-terminating proxy, # we run into the issue that the launch URL determined from the tool # provider is not the original one. Since OAuth requests are signed # with the called URL, this breaks the OAuth signature check # coming afterwards. # # The solution is to fix the URL manually in this special case. adjusted_url = settings.HOST + '/' + \ tool_provider.launch_url.split('/', 3)[3] logger.info( "Changing LTI launch URL from {0} to {1}, based on OpenSubmit configuration, before OAuth check." .format(tool_provider.launch_url, adjusted_url)) tool_provider.launch_url = adjusted_url validator = LtiRequestValidator(pk) if tool_provider.is_valid_request(validator): logger.debug("Valid OAuth request through LTI") if request.user.is_authenticated: logger.debug("LTI consumer is already authenticated") return redirect(reverse('lti_submission', args=[pk])) else: logger.debug( "LTI consumer needs OpenSubmit user, starting auth pipeline" ) # Store data being used by the Django Social Auth Provider for account creation data = request.POST.copy() data['assignment_pk'] = pk request.session[lti.SESSION_VAR] = data return redirect( reverse('social:begin', args=['lti']) + "?next=" + reverse('lti_submission', args=[pk])) else: logger.error("Invalid OAuth request through LTI") raise PermissionDenied
def login(request): context = {} student = '' teacher = '' request.session['user'] = False request.session['student'] = False user = request.user user_type = user.user_type #context['create_user_form'] = TempUserForm() if request.method == 'POST': request_key = request.POST.get('oauth_consumer_key', None) timestamp = request.POST.get('oauth_timestamp', None) nonce = request.POST.get('oauth_nonce', None) tool_provider = DjangoToolProvider.from_django_request(request=request) validator = SignatureValidator(tool_provider) check_key = validator.check_client_key(request_key) if not check_key: logger.error("Invalid request: key check failed.") raise PermissionDenied check_req = validator.verify(request) if not check_req: logger.error("Invalid request: signature check failed.") raise PermissionDenied check_timestamp = validator.validate_timestamp_and_nonce(request_key, timestamp, nonce, request) if not check_timestamp: logger.error("Invalid request: timestamp check failed") code = request.POST.get('code', None) if user_type == 't': request.session['user'] = user.id request.session.set_expiry(3000) return redirect('base:dashboard') else: request.session['student'] = user.id request.session.set_expiry(600) return render(request, 'base/login_form.html', context) else: return render(request, 'base/login_form.html', context)
def validate_lti_request(request): """ Check if LTI launch request is valid, and raise an exception if request is not valid An LTI launch is valid if: - The launch contains all the required parameters - The launch data is correctly signed using a known client key/secret pair :param request: :return: none """ try: tool_provider = DjangoToolProvider.from_django_request(request=request) # validate based on originating protocol if using reverse proxy if request.META.get('HTTP_X_FORWARDED_PROTO') == 'https': tool_provider.launch_url = tool_provider.launch_url.replace( 'http:', 'https:', 1) validator = SignatureValidator() is_valid_lti_request = tool_provider.is_valid_request(validator) except (oauth1.OAuth1Error, InvalidLTIRequestError, ValueError) as err: is_valid_lti_request = False log.error('Error happened while LTI request: {}'.format(err.__str__())) if not is_valid_lti_request: raise Http404('LTI request is not valid')
def login(request): '''View to check the provided LTI credentials. Getting in with a faked LTI consumer basically demands a staff email adress and a valid LTI key / secret pair. Which makes the latter really security sensitive. ''' post_params = request.POST tool_provider = DjangoToolProvider.from_django_request(request=request) validator = LtiRequestValidator() if tool_provider.is_valid_request(validator): data = {} data['ltikey'] = post_params.get('oauth_consumer_key') # None of them is mandatory data['id'] = post_params.get('user_id', None) data['username'] = post_params.get('custom_username', None) data['last_name'] = post_params.get('lis_person_name_family', None) data['email'] = post_params.get('lis_person_contact_email_primary', None) data['first_name'] = post_params.get('lis_person_name_given', None) request.session[passthrough.SESSION_VAR] = data # this enables the login return redirect(reverse('social:begin', args=['lti'])) else: raise PermissionDenied
def dispatch(self, request, *args, **kwargs): # flow for initial LTI launch if request.method == 'POST' and request.POST.get( 'lti_message_type') == 'basic-lti-launch-request': # path to redirect to as GET request redirect_path = request.path if not request.session.session_key: request.session.create() log.debug( "LTI Launch: Session key storage in cookie failed; created new session" ) # append session id to end of redirect path redirect_path = "{}?{}".format( redirect_path, urlencode({'session': request.session.session_key})) # store lti launch params in session before redirecting tool_provider = DjangoToolProvider.from_django_request( request=request) validate_lti_request(tool_provider) initialize_lti_session(request, tool_provider) # redirect to same view as get instead of post return redirect(redirect_path) # flow for all other LTI session activity else: # ensure session is set properly set_session(request) is_lti_session = check_if_lti_session(request) if not is_lti_session: log.error( 'LTI session is not found, Request cannot be processed') raise PermissionDenied( "Content is available only through LTI protocol.") return super(LtiMixin, self).dispatch(request, *args, **kwargs)
def is_valid_request(request: HttpRequest) -> bool: """Check whether the request is valid and is accepted by oauth2. Raises: - api.exceptions.BadRequestException if the request is invalid. - django.core.exceptions.PermissionDenied if signature check failed.""" parameters = parse_parameters(request.POST) if parameters['lti_message_type'] != 'basic-lti-launch-request': raise BadRequestException("LTI request is invalid, parameter 'lti_message_type' " "must be equal to 'basic-lti-launch-request'") try: tool_provider = DjangoToolProvider.from_django_request(request=request) request_is_valid = tool_provider.is_valid_request(RequestValidator()) except oauth2.Error as e: # pragma: no cover logger.warning("Oauth authentication failed : %s" % str(e)) request_is_valid = False if not request_is_valid: logger.debug("LTI Authentification aborted: signature check failed with parameters : %s", parameters) raise PermissionDenied("Invalid request: signature check failed.") return True
def authenticate(self, request): logger.info("about to begin authentication process") request_key = request.POST.get('oauth_consumer_key', None) if request_key is None: logger.error( "Request doesn't contain an oauth_consumer_key; can't continue." ) return None if not settings.LTI_OAUTH_CREDENTIALS: logger.error("Missing LTI_OAUTH_CREDENTIALS in settings") raise PermissionDenied secret = settings.LTI_OAUTH_CREDENTIALS.get(request_key) if secret is None: logger.error("Could not get a secret for key %s" % request_key) raise PermissionDenied logger.debug('using key/secret %s/%s' % (request_key, secret)) tool_provider = DjangoToolProvider.from_django_request(secret=secret, request=request) postparams = request.POST.dict() logger.debug('request is secure: %s' % request.is_secure()) for key in postparams: logger.debug('POST %s: %s' % (key, postparams.get(key))) logger.debug('request abs url is %s' % request.build_absolute_uri()) for key in request.META: logger.debug('META %s: %s' % (key, request.META.get(key))) logger.info("about to check the signature") try: validator = LTIRequestValidator() request_is_valid = tool_provider.is_valid_request(validator) except: logger.exception('error attempting to validate LTI launch %s', postparams) request_is_valid = False if not request_is_valid: logger.error("Invalid request: signature check failed.") #raise PermissionDenied logger.info("done checking the signature") logger.info("about to check the timestamp: %d" % int(tool_provider.oauth_timestamp)) if time() - int(tool_provider.oauth_timestamp) > 60 * 60: logger.error("OAuth timestamp is too old.") #raise PermissionDenied else: logger.info("timestamp looks good") logger.info("done checking the timestamp") # if we got this far, the user is good user = None # Retrieve username from LTI parameter or default to an overridable function return value username = tool_provider.lis_person_sourcedid or self.get_default_username( tool_provider, prefix=self.unknown_user_prefix) username = self.clean_username(username) # Clean it email = tool_provider.lis_person_contact_email_primary first_name = tool_provider.lis_person_name_given last_name = tool_provider.lis_person_name_family logger.info("We have a valid username: %s" % username) UserModel = get_user_model() # Note that this could be accomplished in one try-except clause, but # instead we use get_or_create when creating unknown users since it has # built-in safeguards for multiple threads. if self.create_unknown_user: user, created = UserModel.objects.get_or_create( **{ UserModel.USERNAME_FIELD: username, }) if created: logger.debug('authenticate created a new user for %s' % username) else: logger.debug('authenticate found an existing user for %s' % username) else: logger.debug( 'automatic new user creation is turned OFF! just try to find and existing record' ) try: user = UserModel.objects.get_by_natural_key(username) except UserModel.DoesNotExist: logger.debug('authenticate could not find user %s' % username) # should return some kind of error here? pass # update the user if email: user.email = email if first_name: user.first_name = first_name if last_name: user.last_name = last_name user.save() logger.debug("updated the user record in the database") return user
def test_request_required(self): from lti.contrib.django import DjangoToolProvider with self.assertRaises(ValueError): DjangoToolProvider.from_django_request()