def test_base_string_uri(self): """ Example text to be turned into a normalized base string uri:: GET /?q=1 HTTP/1.1 Host: www.example.net:8080 Sample string generated:: https://www.example.net:8080/ """ # test first example from RFC 5849 section 3.4.1.2. # Note: there is a space between "r" and "v" uri = 'http://EXAMPLE.COM:80/r v/X?id=123' self.assertEqual(base_string_uri(uri), 'http://example.com/r%20v/X') # test second example from RFC 5849 section 3.4.1.2. uri = 'https://www.example.net:8080/?q=1' self.assertEqual(base_string_uri(uri), 'https://www.example.net:8080/') # test for unicode failure uri = b"www.example.com:8080" self.assertRaises(ValueError, base_string_uri, uri) # test for missing scheme uri = "www.example.com:8080" self.assertRaises(ValueError, base_string_uri, uri) # test a URI with the default port uri = "http://www.example.com:80/" self.assertEqual(base_string_uri(uri), "http://www.example.com/") # test a URI missing a path uri = "http://www.example.com" self.assertEqual(base_string_uri(uri), "http://www.example.com/") # test a relative URI uri = "/a-host-relative-uri" host = "www.example.com" self.assertRaises(ValueError, base_string_uri, (uri, host)) # test overriding the URI's netloc with a host argument uri = "http://www.example.com/a-path" host = "alternatehost.example.com" self.assertEqual(base_string_uri(uri, host), "http://alternatehost.example.com/a-path")
def test_base_string_uri(self): """ Example text to be turned into a normalized base string uri:: GET /?q=1 HTTP/1.1 Host: www.example.net:8080 Sample string generated:: https://www.example.net:8080/ """ # test first example from RFC 5849 section 3.4.1.2. # Note: there is a space between "r" and "v" uri = 'http://EXAMPLE.COM:80/r v/X?id=123' self.assertEqual(base_string_uri(uri), 'http://example.com/r%20v/X') # test second example from RFC 5849 section 3.4.1.2. uri = 'https://www.example.net:8080/?q=1' self.assertEqual(base_string_uri(uri), 'https://www.example.net:8080/') # test for unicode failure uri = b"www.example.com:8080" self.assertRaises(ValueError, base_string_uri, uri) # test for missing scheme uri = "www.example.com:8080" self.assertRaises(ValueError, base_string_uri, uri) # test a URI with the default port uri = "http://www.example.com:80/" self.assertEqual(base_string_uri(uri), "http://www.example.com/") # test a URI missing a path uri = "http://www.example.com" self.assertEqual(base_string_uri(uri), "http://www.example.com/") # test a relative URI uri = "/a-host-relative-uri" host = "www.example.com" self.assertRaises(ValueError, base_string_uri, (uri, host)) # test overriding the URI's netloc with a host argument uri = "http://www.example.com/a-path" host = "alternatehost.example.com" self.assertEqual(base_string_uri(uri, host), "http://alternatehost.example.com/a-path")
def _make_lti11_basic_launch_args( oauth_consumer_key: str = "my_consumer_key", oauth_consumer_secret: str = "my_shared_secret", ): oauth_timestamp = str(int(time.time())) oauth_nonce = secrets.token_urlsafe(32) args = { "lti_message_type": "basic-lti-launch-request", "lti_version": "LTI-1p0".encode(), "resource_link_id": "88391-e1919-bb3456", "oauth_consumer_key": oauth_consumer_key, "oauth_timestamp": str(int(oauth_timestamp)), "oauth_nonce": str(oauth_nonce), "oauth_signature_method": "HMAC-SHA1", "oauth_callback": "about:blank", "oauth_version": "1.0", "user_id": "123123123", } extra_args = {"my_key": "this_value"} headers = {"Content-Type": "application/x-www-form-urlencoded"} launch_url = "http://jupyterhub/hub/lti/launch" args.update(extra_args) base_string = signature.signature_base_string( "POST", signature.base_string_uri(launch_url), signature.normalize_parameters( signature.collect_parameters(body=args, headers=headers)), ) args["oauth_signature"] = signature.sign_hmac_sha1( base_string, oauth_consumer_secret, None) return args
def get_context_data(self, **kwargs): role = self.request.GET.get('role', '') campus = self.request.GET.get('campus', '') lti_parameters = [ ("roles", self._lti_role[role]), ("ext_roles", self._lti_ext_role[role]), ("custom_canvas_account_sis_id", 'uwcourse:{}:arts-&-sciences:psych:psych'.format(campus)), ("oauth_timestamp", generate_timestamp()), ("oauth_nonce", generate_nonce()), ("resource_link_title", "UW LTI Development ({})".format(self.lti_app())), ] lti_parameters += self._static_lti_parameters # sign payload lti_app_uri = self.lti_app_uri() sbs = signature_base_string('POST', base_string_uri(lti_app_uri + '/'), normalize_parameters(lti_parameters)) client_key = self._client_key client = Client(client_key, client_secret=self._client_secrets[client_key]) signature = sign_hmac_sha1_with_client(sbs, client) lti_parameters.append(("oauth_signature", signature)) context = super().get_context_data(**kwargs) context['uri'] = lti_app_uri context['campus'] = campus context['role_name'] = role context['lti_parameters'] = lti_parameters return context
def _make_lti11_basic_launch_args( oauth_consumer_key: str = 'my_consumer_key', oauth_consumer_secret: str = 'my_shared_secret', ): oauth_timestamp = str(int(time.time())) oauth_nonce = secrets.token_urlsafe(32) args = { 'lti_message_type': 'basic-lti-launch-request', 'lti_version': 'LTI-1p0'.encode(), 'resource_link_id': '88391-e1919-bb3456', 'oauth_consumer_key': oauth_consumer_key, 'oauth_timestamp': str(int(oauth_timestamp)), 'oauth_nonce': str(oauth_nonce), 'oauth_signature_method': 'HMAC-SHA1', 'oauth_callback': 'about:blank', 'oauth_version': '1.0', 'user_id': '123123123', } extra_args = {'my_key': 'this_value'} headers = {'Content-Type': 'application/x-www-form-urlencoded'} launch_url = 'http://jupyterhub/hub/lti/launch' args.update(extra_args) base_string = signature.signature_base_string( 'POST', signature.base_string_uri(launch_url), signature.normalize_parameters( signature.collect_parameters(body=args, headers=headers)), ) args['oauth_signature'] = signature.sign_hmac_sha1( base_string, oauth_consumer_secret, None) return args
def test_signature_base_string(self): """ Example text to be turned into a base string:: POST /request?b5=%3D%253D&a3=a&c%40=&a2=r%20b HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded Authorization: OAuth realm="Example", oauth_consumer_key="9djdj82h48djs9d2", oauth_token="kkk9d7dh3k39sjv7", oauth_signature_method="HMAC-SHA1", oauth_timestamp="137131201", oauth_nonce="7d8f3e4a", oauth_signature="bYT5CMsGcbgUdFHObYMEfcx6bsw%3D" c2&a3=2+q Sample Base string generated and tested against:: POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q %26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_ key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk 9d7dh3k39sjv7 """ self.assertRaises(ValueError, base_string_uri, self.base_string_url) base_string_url = base_string_uri(self.base_string_url.decode('utf-8')) base_string_url = base_string_url.encode('utf-8') querystring = self.base_string_url.split(b'?', 1)[1] query_params = collect_parameters(querystring.decode('utf-8'), body=self.body) normalized_encoded_query_params = sorted( [(quote(k), quote(v)) for k, v in query_params]) normalized_request_string = "&".join(sorted( ['='.join((k, v)) for k, v in ( self.normalized_encoded_request_params + normalized_encoded_query_params) if k.lower() != 'oauth_signature'])) self.assertRaises(ValueError, signature_base_string, self.http_method, base_string_url, normalized_request_string) self.assertRaises(ValueError, signature_base_string, self.http_method.decode('utf-8'), base_string_url, normalized_request_string) base_string = signature_base_string( self.http_method.decode('utf-8'), base_string_url.decode('utf-8'), normalized_request_string ) self.assertEqual(self.control_base_string, base_string)
def make_args(consumer_key, consumer_secret, launch_url, oauth_timestamp, oauth_nonce, extra_args): args = { "oauth_consumer_key": consumer_key, "oauth_timestamp": str(oauth_timestamp), "oauth_nonce": oauth_nonce, } args.update(extra_args) base_string = signature.signature_base_string( "POST", signature.base_string_uri(launch_url), signature.normalize_parameters( signature.collect_parameters(body=args, headers={})), ) args["oauth_signature"] = signature.sign_hmac_sha1(base_string, consumer_secret, None) return args
def make_args( consumer_key, consumer_secret, launch_url, oauth_timestamp, oauth_nonce, extra_args ): args = { 'oauth_consumer_key': consumer_key, 'oauth_timestamp': str(oauth_timestamp), 'oauth_nonce': oauth_nonce } args.update(extra_args) base_string = signature.signature_base_string( 'POST', signature.base_string_uri(launch_url), signature.normalize_parameters( signature.collect_parameters(body=args, headers={}) ) ) args['oauth_signature'] = signature.sign_hmac_sha1(base_string, consumer_secret, None) return args
def validate_launch_request(self, launch_url, headers, args): """ Validate a given launch request launch_url: Full URL that the launch request was POSTed to headers: k/v pair of HTTP headers coming in with the POST args: dictionary of body arguments passed to the launch_url Must have the following keys to be valid: oauth_consumer_key, oauth_timestamp, oauth_nonce, oauth_signature """ # Validate args! if 'oauth_consumer_key' not in args: raise web.HTTPError(401, "oauth_consumer_key missing") if args['oauth_consumer_key'] not in self.consumers: raise web.HTTPError(401, "oauth_consumer_key not known") if 'oauth_signature' not in args: raise web.HTTPError(401, "oauth_signature missing") if 'oauth_timestamp' not in args: raise web.HTTPError(401, 'oauth_timestamp missing') # Allow 30s clock skew between LTI Consumer and Provider # Also don't accept timestamps from before our process started, since that could be # a replay attack - we won't have nonce lists from back then. This would allow users # who can control / know when our process restarts to trivially do replay attacks. oauth_timestamp = int(float(args['oauth_timestamp'])) if (int(time.time()) - oauth_timestamp > 30 or oauth_timestamp < LTILaunchValidator.PROCESS_START_TIME): raise web.HTTPError(401, "oauth_timestamp too old") if 'oauth_nonce' not in args: raise web.HTTPError(401, 'oauth_nonce missing') if (oauth_timestamp in LTILaunchValidator.nonces and args['oauth_nonce'] in LTILaunchValidator.nonces[oauth_timestamp]): raise web.HTTPError(401, "oauth_nonce + oauth_timestamp already used") LTILaunchValidator.nonces.setdefault(oauth_timestamp, set()).add(args['oauth_nonce']) args_list = [] for key, values in args.items(): if type(values) is list: args_list += [(key, value) for value in values] else: args_list.append((key, values)) base_string = signature.signature_base_string( 'POST', signature.base_string_uri(launch_url), signature.normalize_parameters( signature.collect_parameters(body=args_list, headers=headers))) consumer_secret = self.consumers[args['oauth_consumer_key']] sign = signature.sign_hmac_sha1(base_string, consumer_secret, None) is_valid = signature.safe_string_equals(sign, args['oauth_signature']) if not is_valid: raise web.HTTPError(401, "Invalid oauth_signature") return True
def validate_launch_request( self, launch_url: str, headers: Dict[str, Any], args: Dict[str, Any], ) -> bool: """ Validate a given LTI 1.1 launch request. The arguments' k/v's are either required, recommended, or optional. The required/recommended/optional keys are defined as constants. Args: launch_url: URL (base_url + path) that receives the launch request, usually from a tool consumer. headers: HTTP headers included with the POST request args: the body sent to the launch url. Returns: True if the validation passes, False otherwise. Raises: HTTPError if a required argument is not inclued in the POST request. """ # Ensure that required oauth_* body arguments are included in the request for param in LTI11_OAUTH_ARGS: if param not in args.keys(): raise HTTPError( 400, f"Required oauth arg {param} not included in request") if not args.get(param): raise HTTPError( 400, f"Required oauth arg {param} does not have a value") # Ensure that consumer key is registered in in jupyterhub_config.py # LTI11Authenticator.consumers defined in parent class if args["oauth_consumer_key"] not in self.consumers: raise HTTPError(401, "unknown oauth_consumer_key") # Ensure that required LTI 1.1 body arguments are included in the request for param in LTI11_LAUNCH_PARAMS_REQUIRED: if param not in args.keys(): raise HTTPError( 400, f"Required LTI 1.1 arg arg {param} not included in request" ) if not args.get(param): raise HTTPError( 400, f"Required LTI 1.1 arg {param} does not have a value") # Inspiration to validate nonces/timestamps from OAuthlib # https://github.com/oauthlib/oauthlib/blob/HEAD/oauthlib/oauth1/rfc5849/endpoints/base.py#L147 if len(str(int(args["oauth_timestamp"]))) != 10: raise HTTPError(401, "Invalid timestamp format.") try: ts = int(args["oauth_timestamp"]) except ValueError: raise HTTPError(401, "Timestamp must be an integer.") else: # Reject timestamps that are older than 30 seconds if abs(time.time() - ts) > 30: raise HTTPError( 401, "Timestamp given is invalid, differ from " f"allowed by over {int(time.time() - ts)} seconds.", ) if (ts in LTI11LaunchValidator.nonces and args["oauth_nonce"] in LTI11LaunchValidator.nonces[ts]): raise HTTPError(401, "oauth_nonce + oauth_timestamp already used") LTI11LaunchValidator.nonces.setdefault(ts, set()).add( args["oauth_nonce"]) # convert arguments dict back to a list of tuples for signature args_list = [(k, v) for k, v in args.items()] base_string = signature.signature_base_string( "POST", signature.base_string_uri(launch_url), signature.normalize_parameters( signature.collect_parameters(body=args_list, headers=headers)), ) consumer_secret = self.consumers[args["oauth_consumer_key"]] sign = signature.sign_hmac_sha1(base_string, consumer_secret, None) is_valid = signature.safe_string_equals(sign, args["oauth_signature"]) self.log.debug(f"signature in request: {args['oauth_signature']}") self.log.debug(f"calculated signature: {sign}") if not is_valid: raise HTTPError(401, "Invalid oauth_signature") return True
def _make_lti11_basic_launch_args( roles: str = "Instructor", ext_roles: str = "urn:lti:instrole:ims/lis/Instructor", lms_vendor: str = "canvas", oauth_consumer_key: str = "my_consumer_key", oauth_consumer_secret: str = "my_shared_secret", ): oauth_timestamp = str(int(time.time())) oauth_nonce = secrets.token_urlsafe(32) args = { "oauth_callback": "about:blank", "oauth_consumer_key": oauth_consumer_key, "oauth_timestamp": str(int(oauth_timestamp)), "oauth_nonce": str(oauth_nonce), "oauth_signature_method": "HMAC-SHA1", "oauth_version": "1.0", "context_id": "888efe72d4bbbdf90619353bb8ab5965ccbe9b3f", "context_label": "Introduction to Data Science", "context_title": "Introduction101", "course_lineitems": "https://canvas.instructure.com/api/lti/courses/1/line_items", "custom_canvas_assignment_title": "test-assignment", "custom_canvas_course_id": "616", "custom_canvas_enrollment_state": "active", "custom_canvas_user_id": "1091", "custom_canvas_user_login_id": "*****@*****.**", "ext_roles": ext_roles, "launch_presentation_document_target": "iframe", "launch_presentation_height": "1000", "launch_presentation_locale": "en", "launch_presentation_return_url": "https://canvas.instructure.com/courses/161/external_content/success/external_tool_redirect", "launch_presentation_width": "1000", "lis_outcome_service_url": "http://www.imsglobal.org/developers/LTI/test/v1p1/common/tool_consumer_outcome.php?b64=MTIzNDU6OjpzZWNyZXQ=", "lis_person_contact_email_primary": "*****@*****.**", "lis_person_name_family": "Bar", "lis_person_name_full": "Foo Bar", "lis_person_name_given": "Foo", "lti_message_type": "basic-lti-launch-request", "lis_result_sourcedid": "feb-123-456-2929::28883", "lti_version": "LTI-1p0", "resource_link_id": "888efe72d4bbbdf90619353bb8ab5965ccbe9b3f", "resource_link_title": "Test-Assignment", "roles": roles, "tool_consumer_info_product_family_code": lms_vendor, "tool_consumer_info_version": "cloud", "tool_consumer_instance_contact_email": "*****@*****.**", "tool_consumer_instance_guid": "srnuz6h1U8kOMmETzoqZTJiPWzbPXIYkAUnnAJ4u:test-lms", "tool_consumer_instance_name": "myedutool", "user_id": "185d6c59731a553009ca9b59ca3a885100000", "user_image": "https://lms.example.com/avatar-50.png", } extra_args = {"my_key": "this_value"} headers = {"Content-Type": "application/x-www-form-urlencoded"} launch_url = "http://jupyterhub/hub/lti/launch" args.update(extra_args) base_string = signature.signature_base_string( "POST", signature.base_string_uri(launch_url), signature.normalize_parameters( signature.collect_parameters(body=args, headers=headers) ), ) args["oauth_signature"] = signature.sign_hmac_sha1( base_string, oauth_consumer_secret, None ) return args
def test_base_string_uri(self): """ Test the ``base_string_uri`` function. """ # ---------------- # Examples from the OAuth 1.0a specification: RFC 5849. # First example from RFC 5849 section 3.4.1.2. # # GET /r%20v/X?id=123 HTTP/1.1 # Host: EXAMPLE.COM:80 # # Note: there is a space between "r" and "v" self.assertEqual( 'http://example.com/r%20v/X', base_string_uri('http://EXAMPLE.COM:80/r v/X?id=123')) # Second example from RFC 5849 section 3.4.1.2. # # GET /?q=1 HTTP/1.1 # Host: www.example.net:8080 self.assertEqual( 'https://www.example.net:8080/', base_string_uri('https://www.example.net:8080/?q=1')) # ---------------- # Scheme: will always be in lowercase for uri in [ 'foobar://www.example.com', 'FOOBAR://www.example.com', 'Foobar://www.example.com', 'FooBar://www.example.com', 'fOObAR://www.example.com', ]: self.assertEqual('foobar://www.example.com/', base_string_uri(uri)) # ---------------- # Host: will always be in lowercase for uri in [ 'http://www.example.com', 'http://WWW.EXAMPLE.COM', 'http://www.EXAMPLE.com', 'http://wWW.eXAMPLE.cOM', ]: self.assertEqual('http://www.example.com/', base_string_uri(uri)) # base_string_uri has an optional host parameter that can be used to # override the URI's netloc (or used as the host if there is no netloc) # The "netloc" refers to the "hostname[:port]" part of the URI. self.assertEqual( 'http://actual.example.com/', base_string_uri('http://IGNORE.example.com', 'ACTUAL.example.com')) self.assertEqual( 'http://override.example.com/path', base_string_uri('http:///path', 'OVERRIDE.example.com')) # ---------------- # Port: default ports always excluded; non-default ports always included self.assertEqual( "http://www.example.com/", base_string_uri("http://www.example.com:80/")) # default port self.assertEqual( "https://www.example.com/", base_string_uri("https://www.example.com:443/")) # default port self.assertEqual( "https://www.example.com:999/", base_string_uri("https://www.example.com:999/")) # non-default port self.assertEqual( "http://www.example.com:443/", base_string_uri("HTTP://www.example.com:443/")) # non-default port self.assertEqual( "https://www.example.com:80/", base_string_uri("HTTPS://www.example.com:80/")) # non-default port self.assertEqual( "http://www.example.com/", base_string_uri("http://www.example.com:/")) # colon but no number # ---------------- # Paths self.assertEqual( 'http://www.example.com/', base_string_uri('http://www.example.com')) # no slash self.assertEqual( 'http://www.example.com/', base_string_uri('http://www.example.com/')) # with slash self.assertEqual( 'http://www.example.com:8080/', base_string_uri('http://www.example.com:8080')) # no slash self.assertEqual( 'http://www.example.com:8080/', base_string_uri('http://www.example.com:8080/')) # with slash self.assertEqual( 'http://www.example.com/foo/bar', base_string_uri('http://www.example.com/foo/bar')) # no slash self.assertEqual( 'http://www.example.com/foo/bar/', base_string_uri('http://www.example.com/foo/bar/')) # with slash # ---------------- # Query parameters & fragment IDs do not appear in the base string URI self.assertEqual( 'https://www.example.com/path', base_string_uri('https://www.example.com/path?foo=bar')) self.assertEqual( 'https://www.example.com/path', base_string_uri('https://www.example.com/path#fragment')) # ---------------- # Percent encoding # # RFC 5849 does not specify what characters are percent encoded, but in # one of its examples it shows spaces being percent encoded. # So it is assumed that spaces must be encoded, but we don't know what # other characters are encoded or not. self.assertEqual( 'https://www.example.com/hello%20world', base_string_uri('https://www.example.com/hello world')) self.assertEqual( 'https://www.hello%20world.com/', base_string_uri('https://www.hello world.com/')) # ---------------- # Errors detected # base_string_uri expects a string self.assertRaises(ValueError, base_string_uri, None) self.assertRaises(ValueError, base_string_uri, 42) self.assertRaises(ValueError, base_string_uri, b'http://example.com') # Missing scheme is an error self.assertRaises(ValueError, base_string_uri, '') self.assertRaises(ValueError, base_string_uri, ' ') # single space self.assertRaises(ValueError, base_string_uri, 'http') self.assertRaises(ValueError, base_string_uri, 'example.com') # Missing host is an error self.assertRaises(ValueError, base_string_uri, 'http:') self.assertRaises(ValueError, base_string_uri, 'http://') self.assertRaises(ValueError, base_string_uri, 'http://:8080') # Port is not a valid TCP/IP port number self.assertRaises(ValueError, base_string_uri, 'http://eg.com:0') self.assertRaises(ValueError, base_string_uri, 'http://eg.com:-1') self.assertRaises(ValueError, base_string_uri, 'http://eg.com:65536') self.assertRaises(ValueError, base_string_uri, 'http://eg.com:3.14') self.assertRaises(ValueError, base_string_uri, 'http://eg.com:BAD') self.assertRaises(ValueError, base_string_uri, 'http://eg.com:NaN') self.assertRaises(ValueError, base_string_uri, 'http://eg.com: ') self.assertRaises(ValueError, base_string_uri, 'http://eg.com:42:42')