def _verify_request(self, request): """ Verify LTI request :raises: LTIException is request validation failed """ if request.method == 'POST': params = dict(request.POST.iteritems()) else: params = dict(request.GET.iteritems()) try: verify_request_common(self._consumers(), request.build_absolute_uri(), request.method, request.META, params) self._validate_role() self.lti_params = params request.session[LTI_SESSION_KEY] = True return True except LTIException: self.lti_params = {} request.session[LTI_SESSION_KEY] = False raise
def test_verify_request_common_no_oauth_fields(self): """ verify_request_common fails on missing authentication """ headers = dict() consumers, method, url, _, params = (self.generate_oauth_request()) with self.assertRaises(LTIException): verify_request_common(consumers, url, method, headers, params)
def lti(request): if not get_timeslot(): return HttpResponse( 'Login is not available. The system is currently closed.', status=403) config = getattr(settings, 'PYLTI_CONFIG', dict()) consumers = config.get('consumers', dict()) params = dict(request.POST.items()) headers = request.META headers['X-Forwarded-Proto'] = headers['HTTP_X_FORWARDED_PROTO'] try: verify_request_common(consumers, request.build_absolute_uri(), request.method, headers, params) except LTIException as e: logger.error('LTI exception from canvas; {}'.format(e)) return HttpResponse("Signature Validation failed!", status=403) data = request.POST try: username = data['lis_person_sourcedid'] email = data['lis_person_contact_email_primary'] studentnumber = data['custom_canvas_user_login_id'] coursecode = data['context_label'] except KeyError as e: logger.error('Invalid post data from canvas; {}; {}'.format(data, e)) return HttpResponse("Missing data in POST", status=400) user = get_user(email, username) if user is None: user = User(email=email, username=username) user.save() try: meta = user.usermeta except UserMeta.DoesNotExist: meta = UserMeta(User=user) meta.Studentnumber = studentnumber if not meta.Overruled: if settings.COURSE_CODE_BEP in coursecode: meta.EnrolledBEP = True elif settings.COURSE_CODE_EXT in coursecode: meta.EnrolledBEP = True meta.EnrolledExt = True else: logger.warning( 'Course code not matched on BEP or EXT for user {}. Code was: {}' .format(user, coursecode)) meta.save() meta.TimeSlot.add(get_timeslot()) meta.save() user.save() log = CanvasLogin() log.Subject = user log.save() return redirect("{}/login/".format(settings.DOMAIN))
def test_verify_request_common_no_oauth_fields(self): """ verify_request_common fails on missing authentication """ headers = dict() consumers, method, url, _, params = ( self.generate_oauth_request() ) with self.assertRaises(LTIException): verify_request_common(consumers, url, method, headers, params)
def test_verify_request_common_no_params(self): """ verify_request_common fails on missing parameters """ consumers = {"__consumer_key__": {"secret": "__lti_secret__"}} url = 'http://localhost:5000/?' method = 'GET' headers = dict() params = dict() with self.assertRaises(LTIException): verify_request_common(consumers, url, method, headers, params)
def post(self, request, save_id): params = {key: request.data[key] for key in request.data} consumers_dict = consumers() url = request.build_absolute_uri() headers = request.META # Define the redirect url host = request.get_host() _ = headers.pop('HTTP_COOKIE', None) if 'HTTP_SEC_FETCH_DEST' not in headers: headers['HTTP_SEC_FETCH_DEST'] = 'iframe' if 'HTTP_SEC_FETCH_MODE' not in headers: headers['HTTP_SEC_FETCH_MODE'] = 'navigate' if 'HTTP_SEC_FETCH_SITE' not in headers: headers['HTTP_SEC_FETCH_SITE'] = 'cross-site' print("params:", params) print("headers:", headers) print("host:", host) print("url:", url) ltikeys = ['user_id', 'lis_result_sourcedid', 'lis_outcome_service_url', 'oauth_nonce', 'oauth_timestamp', 'oauth_consumer_key', 'oauth_signature_method', 'oauth_version', 'oauth_signature'] ltidata = {key: params.get(key) for key in ltikeys} lti_session = ltiSession.objects.create(**ltidata) print("Got POST for validating LTI consumer") try: i = lticonsumer.objects.get(consumer_key=request.data.get( 'oauth_consumer_key'), initial_schematic__save_id=save_id ) lti_session.lti_consumer = i lti_session.save() except lticonsumer.DoesNotExist: print("Consumer does not exist on backend") return HttpResponseRedirect(get_reverse('ltiAPI:denied')) print(i.initial_schematic.save_id) next_url = "http://" + host + "/eda/#editor?id=" + \ str(i.initial_schematic.save_id) + "&branch=" \ + str(i.initial_schematic.branch) + "&version=" \ + str(i.initial_schematic.version) \ + "<i_id=" + str(lti_session.id) + "<i_user_id=" + \ lti_session.user_id \ + "<i_nonce=" + lti_session.oauth_nonce try: print("Got verification request") verify_request_common(consumers_dict, url, request.method, headers, params) print("Verified consumer") # grade = LTIPostGrade(params, request) return HttpResponseRedirect(next_url) except LTIException: traceback.print_exc() return HttpResponseRedirect(get_reverse('ltiAPI:denied'))
def wrapper(request): consumer_key = request.params["oauth_consumer_key"] instance = request.db.query(ai.ApplicationInstance).filter( ai.ApplicationInstance.consumer_key == consumer_key).one() consumers = {} consumers[consumer_key] = {"secret": instance.shared_secret} # TODO rescue from an invalid lti launch verify_request_common(consumers, request.url, request.method, dict(request.headers), dict(request.params)) return view_function(request)
def test_verify_request_common_no_params(self): """ verify_request_common fails on missing parameters """ consumers = { "__consumer_key__": {"secret": "__lti_secret__"} } url = 'http://localhost:5000/?' method = 'GET' headers = dict() params = dict() with self.assertRaises(LTIException): verify_request_common(consumers, url, method, headers, params)
def lti(request): config = getattr(settings, 'PYLTI_CONFIG', dict()) consumers = config.get('consumers', dict()) params = dict(request.POST.items()) headers = request.META headers['X-Forwarded-Proto'] = headers['HTTP_X_FORWARDED_PROTO'] try: verify_request_common(consumers, request.build_absolute_uri(), request.method, headers, params) except LTIException: return HttpResponse("Signature Validation failed!", status=403) data = request.POST try: username = data['lis_person_sourcedid'] email = data['lis_person_contact_email_primary'] studentnumber = data['custom_canvas_user_login_id'] coursecode = data['context_label'] except KeyError: return HttpResponse("Missing data in POST", status=400) user = get_user(email, username) if user is None: user = User(email=email, username=username) user.save() try: meta = user.usermeta except UserMeta.DoesNotExist: meta = UserMeta(User=user) meta.Studentnumber = studentnumber if not meta.Overruled: if coursecode == '5XEC0': meta.EnrolledBEP = True elif coursecode == '5XED0': meta.EnrolledBEP = True meta.EnrolledExt = True meta.save() meta.TimeSlot.add(get_timeslot()) meta.save() user.save() log = CanvasLogin() log.Subject = user log.save() return redirect("https://bep.ele.tue.nl/login")
def _parse_lti_data(self, courseid, taskid): """ Verify and parse the data for the LTI basic launch """ post_input = web.webapi.rawinput("POST") try: verified = verify_request_common(self.consumers, web.ctx.home + web.ctx.fullpath, "POST", {}, post_input) except: raise Exception("Cannot authentify request (1)") if verified: user_id = post_input["user_id"] roles = post_input.get("roles", "Student").split(",") realname = self._find_realname(post_input) email = post_input.get("lis_person_contact_email_primary", "") lis_outcome_service_url = post_input.get("lis_outcome_service_url", None) outcome_result_id = post_input.get("lis_result_sourcedid", None) consumer_key = post_input["oauth_consumer_key"] if lis_outcome_service_url is None: raise Exception("INGInious needs the parameter lis_outcome_service_url in the LTI basic-launch-request") if outcome_result_id is None: raise Exception("INGInious needs the parameter lis_result_sourcedid in the LTI basic-launch-request") self.user_manager.lti_auth(user_id, roles, realname, email, courseid, taskid, consumer_key, lis_outcome_service_url, outcome_result_id) else: raise Exception("Cannot authentify request (2)")
def _parse_lti_data(self, courseid, taskid): """ Verify and parse the data for the LTI basic launch """ post_input = web.webapi.rawinput("POST") try: verified = verify_request_common(self.consumers, web.ctx.home + web.ctx.fullpath, "POST", {}, post_input) except: raise Exception("Cannot authentify request (1)") if verified: user_id = post_input["user_id"] roles = post_input.get("roles", "Student").split(",") realname = self._find_realname(post_input) email = post_input.get("lis_person_contact_email_primary", "") lis_outcome_service_url = post_input.get("lis_outcome_service_url", None) outcome_result_id = post_input.get("lis_result_sourcedid", None) consumer_key = post_input["oauth_consumer_key"] if lis_outcome_service_url is None: raise Exception( "INGInious needs the parameter lis_outcome_service_url in the LTI basic-launch-request" ) if outcome_result_id is None: raise Exception( "INGInious needs the parameter lis_result_sourcedid in the LTI basic-launch-request" ) self.user_manager.lti_auth(user_id, roles, realname, email, courseid, taskid, consumer_key, lis_outcome_service_url, outcome_result_id) else: raise Exception("Cannot authentify request (2)")
def auth(request): '''POST handler for the LTI login POST back call''' if request.method == 'POST': # Extracts the LTI payload information params = {key: request.POST[key] for key in request.POST} # Maps the settings defined for the LTI consumer consumers = settings.PYLTI_CONFIG['consumers'] # Builds the tool URL from the request url = request.build_absolute_uri() # Extracts the request headers from the request headers = request.META # Get the default next URL from the LTI config next_template = settings.PYLTI_CONFIG.get('next_url') try: # Validate the incoming LTI verify_request_common(consumers, url,\ request.method, headers,\ params) # Map and call the login method hook if defined in the settings login_method_hook = settings.PYLTI_CONFIG.get( 'method_hooks', {}).get('valid_lti_request', None) if (login_method_hook): # If there is a return URL from the configured call the redirect URL # is updated with the one that is returned. This is to enable redirecting to # constructed URLs ############ K3ru: updated lines ################################ update_template = import_string(login_method_hook)(params, request) print("Update URL: ", update_template) if update_template: next_template = update_template print("USER post_update: ", request.user.username) return render(request, next_template) ################### End updated lines ############################### except LTIException: # Map and call the invalid login method hook if defined in the settings invalid_login_method_hook = settings.PYLTI_CONFIG.get( 'method_hooks', {}).get('invalid_lti_request', None) if (invalid_login_method_hook): import_string(invalid_login_method_hook)(params) return HttpResponseRedirect(get_reverse('django_lti_auth:denied')) else: return HttpResponseRedirect(get_reverse('django_lti_auth:denied'))
def test_verify_request_common(self): """ verify_request_common succeeds on valid request """ headers = dict() consumers, method, url, verify_params, params = \ self.generate_oauth_request() ret = verify_request_common(consumers, url, method, headers, verify_params) self.assertTrue(ret)
def test_verify_request_common_via_proxy(self): """ verify_request_common succeeds on valid request via proxy """ headers = dict() headers['X-Forwarded-Proto'] = 'https' orig_url = 'https://localhost:5000/?' consumers, method, url, verify_params, params = \ self.generate_oauth_request(url_to_sign=orig_url) ret = verify_request_common(consumers, url, method, headers, verify_params) self.assertTrue(ret)
def verify_request(self): """ Verify LTI request :raises: LTIException is request validation failed """ if flask_request.method == 'POST': params = flask_request.form.to_dict() else: params = flask_request.args.to_dict() log.debug(params) self.lti_session.delete() self.lti_session = self.get_ltisession(params=params) log.debug('verify_request?') try: verify_request_common(self._consumers(), flask_request.url, flask_request.method, flask_request.headers, params) log.debug('verify_request success') # All good to go, store all of the LTI params into a # session dict for use in views for prop in LTI_PROPERTIES: if params.get(prop, None): log.debug("params %s=%s", prop, params[prop]) setattr(self.lti_session, prop, params[prop]) session['user_id'] = params['user_id'] # Set logged in session key setattr(self.lti_session, LTI_SESSION_KEY, True) self.lti_session.save() return True except LTIException: log.debug('verify_request failed') self.close_session() raise
def _verify_request(self, request): """ Verify LTI request :raises: LTIException is request validation failed """ try: params = self._params(request) verify_request_common(self.consumers(), request.build_absolute_uri(), request.method, request.META, params) self._validate_role() self.clear_session(request) self.initialize_session(request, params) request.session[LTI_SESSION_KEY] = True return True except LTIException: self.clear_session(request) request.session[LTI_SESSION_KEY] = False raise
def test_verify_request_common_via_proxy_wsgi_syntax(self): """ verify_request_common succeeds on valid request via proxy with wsgi syntax for headers """ headers = dict() headers['HTTP_X_FORWARDED_PROTO'] = 'https' orig_url = 'https://localhost:5000/?' consumers, method, url, verify_params, _ = ( self.generate_oauth_request(url_to_sign=orig_url)) ret = verify_request_common(consumers, url, method, headers, verify_params) self.assertTrue(ret)
def test_verify_request_common_no_auth_fields(self): """ verify_request_common fails on missing authentication """ headers = dict() consumers, method, url, verify_params, params = \ self.generate_oauth_request() ret = False try: ret = verify_request_common(consumers, url, method, headers, params) except LTIException: self.assertTrue(True) self.assertFalse(ret)
def is_valid_request(consumer_key: str, consumer_secret: str, request: Request): consumers = { consumer_key: { "secret": consumer_secret, } } try: return verify_request_common(consumers, request.build_absolute_uri(), request.method, request.META, request.POST.dict()) except Exception as e: logger.error(e) return False
def test_verify_request_common_no_params(self): """ verify_request_common fails on missing parameters """ consumers = {"__consumer_key__": {"secret": "__lti_secret__"}} url = 'http://localhost:5000/?' method = 'GET' headers = dict() params = dict() ret = False try: ret = verify_request_common(consumers, url, method, headers, params) except LTIException: self.assertTrue(True) self.assertFalse(ret)
def test_verify_request_common_no_params(self): """ verify_request_common fails on missing parameters """ consumers = { "__consumer_key__": {"secret": "__lti_secret__"} } url = 'http://localhost:5000/?' method = 'GET' headers = dict() params = dict() ret = False try: ret = verify_request_common(consumers, url, method, headers, params) except LTIException: self.assertTrue(True) self.assertFalse(ret)
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 post(self, request, format=None): url = f"{BACKEND_DOMAIN}{reverse('lti')}" consumers = LTI_CONFIG["consumers"] method = request.method headers = request.META payload = request.POST.dict() # Verify that the payload received from LTI is valid try: verify_request_common(consumers, url, method, headers, payload) # If not, redirect to the error page # This page would be displayed in an iframe on Moodle except LTIException: # TODO: Implement logging of this error return redirect(FRONTEND_DOMAIN + "/forbidden") current_user_email = payload["lis_person_contact_email_primary"] data = { "email": current_user_email, "first_name": payload["lis_person_name_given"], "last_name": payload["lis_person_name_family"], } user = get_or_create_user(data) # Elevate the user to instructor group if they have a staff role in LTI # If they are already instructor or admin, then do nothing user_groups = [group.name for group in user.groups.all()] is_lti_instructor = (LTI_CONFIG["staff_role"] in payload["roles"].split(",") if LTI_CONFIG.get("staff_role") else False) if "user" in user_groups and is_lti_instructor: user.groups.set([Group.objects.get(name="instructor")]) token = generate_one_time_token(user) user.last_login = dt.now(timezone.utc) user.save() logger.info("authentication.login", extra={ "user": user.email, "type": "LTI" }) # Store the important LTI fields for this user # These fields be used to grant permissions in containers lti_payload = { "lti_id": payload["user_id"], "lti_email": current_user_email, "user_id": payload.get(LTI_CONFIG.get("username_field")), } lti.objects(user=user.id).update_one(payload=lti_payload, upsert=True) # If any containers have been bound to this LTI resource, then # redirect to that container # Otherwise, prompt the user to choose a container for binding lti_resource_id = payload["resource_link_id"] try: container = Container.objects.get(lti_resource=lti_resource_id) if LTI_CONFIG.get("auto_create_share_containers"): if container.owner != current_user_email and current_user_email not in container.sharing: container.sharing.append(current_user_email) container.save() return redirect(FRONTEND_DOMAIN + "?tkn=" + token + "&container=" + str(container.id)) except: if LTI_CONFIG.get("auto_create_share_containers"): container = Container(owner=current_user_email, code=payload["context_title"], lti_resource=payload["resource_link_id"], lti_context=payload["context_id"]) container.save() return redirect(FRONTEND_DOMAIN + "?tkn=" + token + "<i=" + lti_resource_id)
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
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