diff --git a/.travis.yml b/.travis.yml index 179a9fb..042124c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: - 2.7 install: - python setup.py install --quiet + - pip install --upgrade -r test_requirements.txt - pip install coveralls script: - python setup.py test --coverage --pep8 --flakes diff --git a/README.rst b/README.rst index b43fe6b..a660f2f 100644 --- a/README.rst +++ b/README.rst @@ -38,21 +38,21 @@ Flask `mit_lti_flask_sample Dependencies: ============= -* Python 2.7+ -* oauth 1.0.1+ -* oauth2 1.5.211+ +* Python 2.7+ or Python 3.4+ +* oauth2 1.9.0+ * httplib2 0.9+ +* six 1.10.0+ Development dependencies: ========================= * Flask 0.10.1 * httpretty 0.8.3 * oauthlib 0.6.3 -* pyflakes 0.8.1 -* pytest 2.6.3 +* pyflakes 1.2.3 +* pytest 2.9.2 * pytest-cache 1.0 -* pytest-cov 1.8.0 -* pytest-flakes 0.2 +* pytest-cov 2.3.0 +* pytest-flakes 1.0.1 * pytest-pep8 1.0.6 * sphinx 1.2.3 diff --git a/pylti/common.py b/pylti/common.py index b80055e..62354e9 100644 --- a/pylti/common.py +++ b/pylti/common.py @@ -4,12 +4,15 @@ """ from __future__ import absolute_import + import logging import oauth2 -import oauth.oauth as oauth from xml.etree import ElementTree as etree +from oauth2 import STRING_TYPES +from six.moves.urllib.parse import urlparse, urlencode + log = logging.getLogger('pylti.common') # pylint: disable=invalid-name LTI_PROPERTY_LIST = [ @@ -49,18 +52,17 @@ LTI_REQUEST_TYPE = [u'any', u'initial', u'session'] -class LTIOAuthDataStore(oauth.OAuthDataStore): - # pylint: disable=abstract-method +class LTIOAuthServer(oauth2.Server): """ Largely taken from reference implementation for app engine at https://code.google.com/p/ims-dev/ """ - def __init__(self, consumers): + def __init__(self, consumers, signature_methods=None): """ - Create OAuth store + Create OAuth server """ - oauth.OAuthDataStore.__init__(self) + super(LTIOAuthServer, self).__init__(signature_methods) self.consumers = consumers def lookup_consumer(self, key): @@ -82,7 +84,7 @@ def lookup_consumer(self, key): log.critical(('Consumer %s, is missing secret' 'in settings file, and needs correction.'), key) return None - return oauth.OAuthConsumer(key, secret) + return oauth2.Consumer(key, secret) def lookup_cert(self, key): """ @@ -101,15 +103,6 @@ def lookup_cert(self, key): cert = consumer.get('cert', None) return cert - def lookup_nonce(self, oauth_consumer, oauth_token, nonce): - """ - Lookup nonce should check if nonce was already used - by this consumer in the past. - Reusing nonce is bad: http://cwe.mitre.org/data/definitions/323.html - Not implemented. - """ - return None - class LTIException(Exception): """ @@ -155,11 +148,10 @@ def _post_patched_request(consumers, lti_key, body, :return: response """ # pylint: disable=too-many-locals, too-many-arguments - oauth_store = LTIOAuthDataStore(consumers) - oauth_server = oauth.OAuthServer(oauth_store) - oauth_server.add_signature_method(oauth.OAuthSignatureMethod_HMAC_SHA1()) - lti_consumer = oauth_store.lookup_consumer(lti_key) - lti_cert = oauth_store.lookup_cert(lti_key) + oauth_server = LTIOAuthServer(consumers) + oauth_server.add_signature_method(SignatureMethod_HMAC_SHA1_Unicode()) + lti_consumer = oauth_server.lookup_consumer(lti_key) + lti_cert = oauth_server.lookup_cert(lti_key) secret = lti_consumer.secret @@ -190,7 +182,7 @@ def my_normalize(self, headers): response, content = client.request( url, method, - body=body, + body=body.encode('utf-8'), headers={'Content-Type': content_type}) http = httplib2.Http @@ -227,7 +219,7 @@ def post_message(consumers, lti_key, url, body): content_type, ) - is_success = "success" in content + is_success = b"success" in content log.debug("is success %s", is_success) return is_success @@ -277,18 +269,17 @@ def verify_request_common(consumers, url, method, headers, params): log.debug("headers %s", headers) log.debug("params %s", params) - oauth_store = LTIOAuthDataStore(consumers) - oauth_server = oauth.OAuthServer(oauth_store) + oauth_server = LTIOAuthServer(consumers) oauth_server.add_signature_method( - oauth.OAuthSignatureMethod_PLAINTEXT()) + SignatureMethod_PLAINTEXT_Unicode()) oauth_server.add_signature_method( - oauth.OAuthSignatureMethod_HMAC_SHA1()) + SignatureMethod_HMAC_SHA1_Unicode()) # Check header for SSL before selecting the url if headers.get('X-Forwarded-Proto', 'http') == 'https': url = url.replace('http:', 'https:', 1) - oauth_request = oauth.OAuthRequest.from_request( + oauth_request = Request_Fix_Duplicate.from_request( method, url, headers=dict(headers), @@ -301,9 +292,12 @@ def verify_request_common(consumers, url, method, headers, params): 'or request') try: # pylint: disable=protected-access - consumer = oauth_server._get_consumer(oauth_request) - oauth_server._check_signature(oauth_request, consumer, None) - except oauth.OAuthError: + oauth_consumer_key = oauth_request.get_parameter('oauth_consumer_key') + consumer = oauth_server.lookup_consumer(oauth_consumer_key) + if not consumer: + raise oauth2.Error('Invalid consumer.') + oauth_server.verify_request(oauth_request, consumer, None) + except oauth2.Error: # Rethrow our own for nice error handling (don't print # error message as it will contain the key raise LTIException("OAuth error: Please check your key and secret") @@ -350,7 +344,110 @@ def generate_request_xml(message_identifier_id, operation, text_string = etree.SubElement(result_score, 'textString') text_string.text = score.__str__() ret = "\n{}".format( - etree.tostring(root, encoding='utf-8')) + etree.tostring(root, encoding='utf-8').decode('utf-8')) log.debug("XML Response: \n%s", ret) return ret + + +class SignatureMethod_HMAC_SHA1_Unicode(oauth2.SignatureMethod_HMAC_SHA1): + """ + Temporary workaround for + https://github.com/joestump/python-oauth2/issues/207 + + Original code is Copyright (c) 2007 Leah Culver, MIT license. + """ + + def check(self, request, consumer, token, signature): + """ + Returns whether the given signature is the correct signature for + the given consumer and token signing the given request. + """ + built = self.sign(request, consumer, token) + if isinstance(signature, STRING_TYPES): + signature = signature.encode("utf8") + return built == signature + + +class SignatureMethod_PLAINTEXT_Unicode(oauth2.SignatureMethod_PLAINTEXT): + """ + Temporary workaround for + https://github.com/joestump/python-oauth2/issues/207 + + Original code is Copyright (c) 2007 Leah Culver, MIT license. + """ + + def check(self, request, consumer, token, signature): + """ + Returns whether the given signature is the correct signature for + the given consumer and token signing the given request. + """ + built = self.sign(request, consumer, token) + if isinstance(signature, STRING_TYPES): + signature = signature.encode("utf8") + return built == signature + + +class Request_Fix_Duplicate(oauth2.Request): + """ + Temporary workaround for + https://github.com/joestump/python-oauth2/pull/197 + + Original code is Copyright (c) 2007 Leah Culver, MIT license. + """ + + def get_normalized_parameters(self): + """ + Return a string that contains the parameters that must be signed. + """ + items = [] + for key, value in self.items(): + if key == 'oauth_signature': + continue + # 1.0a/9.1.1 states that kvp must be sorted by key, then by value, + # so we unpack sequence values into multiple items for sorting. + if isinstance(value, STRING_TYPES): + items.append( + (oauth2.to_utf8_if_string(key), oauth2.to_utf8(value)) + ) + else: + try: + value = list(value) + except TypeError as e: + assert 'is not iterable' in str(e) + items.append( + (oauth2.to_utf8_if_string(key), + oauth2.to_utf8_if_string(value)) + ) + else: + items.extend( + (oauth2.to_utf8_if_string(key), + oauth2.to_utf8_if_string(item)) + for item in value + ) + + # Include any query string parameters from the provided URL + query = urlparse(self.url)[4] + url_items = self._split_url_string(query).items() + url_items = [ + (oauth2.to_utf8(k), oauth2.to_utf8_optional_iterator(v)) + for k, v in url_items if k != 'oauth_signature' + ] + + # Merge together URL and POST parameters. + # Eliminates parameters duplicated between URL and POST. + items_dict = {} + for k, v in items: + items_dict.setdefault(k, []).append(v) + for k, v in url_items: + if not (k in items_dict and v in items_dict[k]): + items.append((k, v)) + + items.sort() + + encoded_str = urlencode(items, True) + # Encode signature parameters per Oauth Core 1.0 protocol + # spec draft 7, section 3.6 + # (http://tools.ietf.org/html/draft-hammer-oauth-07#section-3.6) + # Spaces must be encoded with "%20" instead of "+" + return encoded_str.replace('+', '%20').replace('%7E', '~') diff --git a/pylti/flask.py b/pylti/flask.py index 214c4ab..95c99e9 100644 --- a/pylti/flask.py +++ b/pylti/flask.py @@ -214,9 +214,9 @@ def response_url(self): urls = app_config.get('PYLTI_URL_FIX', dict()) # url remapping is useful for using devstack # devstack reports httpS://localhost:8000/ and listens on HTTP - for prefix, mapping in urls.iteritems(): + for prefix, mapping in urls.items(): if url.startswith(prefix): - for _from, _to in mapping.iteritems(): + for _from, _to in mapping.items(): url = url.replace(_from, _to) return url diff --git a/pylti/tests/test_common.py b/pylti/tests/test_common.py index 789fb3b..3818543 100644 --- a/pylti/tests/test_common.py +++ b/pylti/tests/test_common.py @@ -7,12 +7,12 @@ import httpretty import oauthlib.oauth1 -from urlparse import urlparse, parse_qs -import urllib + +from six.moves.urllib.parse import urlencode, urlparse, parse_qs import pylti from pylti.common import ( - LTIOAuthDataStore, + LTIOAuthServer, verify_request_common, LTIException, post_message, @@ -58,9 +58,9 @@ def test_version(): """ semantic_version.Version(pylti.VERSION) - def test_lti_oauth_data_store(self): + def test_lti_oauth_server(self): """ - Tests that LTIOAuthDataStore works + Tests that LTIOAuthServer works """ consumers = { "key1": {"secret": "secret1"}, @@ -69,7 +69,7 @@ def test_lti_oauth_data_store(self): "keyNS": {"test": "test"}, "keyWCert": {"secret": "secret", "cert": "cert"}, } - store = LTIOAuthDataStore(consumers) + store = LTIOAuthServer(consumers) self.assertEqual(store.lookup_consumer("key1").secret, "secret1") self.assertEqual(store.lookup_consumer("key2").secret, "secret2") self.assertEqual(store.lookup_consumer("key3").secret, "secret3") @@ -79,12 +79,12 @@ def test_lti_oauth_data_store(self): self.assertIsNone(store.lookup_consumer("keyNS")) self.assertIsNone(store.lookup_cert("keyNS")) - def test_lti_oauth_data_store_no_consumers(self): + def test_lti_oauth_server_no_consumers(self): """ If consumers are not given it there are no consumer to return. """ - store = LTIOAuthDataStore(None) + store = LTIOAuthServer(None) self.assertIsNone(store.lookup_consumer("key1")) self.assertIsNone(store.lookup_cert("key1")) @@ -248,7 +248,7 @@ def generate_oauth_request(url_to_sign=None): u'6ac8/handler_noauth' u'/grade_handler', 'lti_message_type': u'basic-lti-launch-request'} - urlparams = urllib.urlencode(params) + urlparams = urlencode(params) client = oauthlib.oauth1.Client('__consumer_key__', client_secret='__lti_secret__', @@ -261,6 +261,6 @@ def generate_oauth_request(url_to_sign=None): url_parts = urlparse(signature[0]) query_string = parse_qs(url_parts.query, keep_blank_values=True) verify_params = dict() - for key, value in query_string.iteritems(): + for key, value in query_string.items(): verify_params[key] = value[0] return consumers, method, url, verify_params, params diff --git a/pylti/tests/test_flask.py b/pylti/tests/test_flask.py index 8c1a73e..6a29879 100644 --- a/pylti/tests/test_flask.py +++ b/pylti/tests/test_flask.py @@ -4,12 +4,13 @@ """ from __future__ import absolute_import import unittest -import urllib import httpretty import mock import oauthlib.oauth1 +from six.moves.urllib.parse import urlencode + from pylti.common import LTIException from pylti.flask import LTI from pylti.tests.test_flask_app import app_exception, app @@ -188,7 +189,7 @@ def test_access_to_oauth_resource_name_passed(self): ret = self.app.get(new_url) self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, u'person') + self.assertEqual(ret.data.decode('utf-8'), u'person') def test_access_to_oauth_resource_email_passed(self): """ @@ -204,7 +205,7 @@ def test_access_to_oauth_resource_email_passed(self): ret = self.app.get(new_url) self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, u'email@email.com') + self.assertEqual(ret.data.decode('utf-8'), u'email@email.com') def test_access_to_oauth_resource_name_and_email_passed(self): """ @@ -221,7 +222,7 @@ def test_access_to_oauth_resource_name_and_email_passed(self): ret = self.app.get(new_url) self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, u'person') + self.assertEqual(ret.data.decode('utf-8'), u'person') def test_access_to_oauth_resource_staff_only_as_student(self): """ @@ -348,7 +349,7 @@ def generate_launch_request(consumers, url, if add_params is not None: params.update(add_params) - urlparams = urllib.urlencode(params) + urlparams = urlencode(params) client = oauthlib.oauth1.Client('__consumer_key__', client_secret='__lti_secret__', @@ -442,11 +443,11 @@ def test_access_to_oauth_resource_post_grade(self): ret = self.app.get("/post_grade/1.0") self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, "grade=True") + self.assertEqual(ret.data.decode('utf-8'), "grade=True") ret = self.app.get("/post_grade/2.0") self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, "grade=False") + self.assertEqual(ret.data.decode('utf-8'), "grade=False") @httpretty.activate def test_access_to_oauth_resource_post_grade_fail(self): @@ -477,7 +478,7 @@ def request_callback(request, cburi, headers): ret = self.app.get("/post_grade/1.0") self.assertTrue(self.has_exception()) - self.assertEqual(ret.data, "error") + self.assertEqual(ret.data.decode('utf-8'), "error") @httpretty.activate def test_access_to_oauth_resource_post_grade_fix_url(self): @@ -498,11 +499,11 @@ def test_access_to_oauth_resource_post_grade_fix_url(self): ret = self.app.get("/post_grade/1.0") self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, "grade=True") + self.assertEqual(ret.data.decode('utf-8'), "grade=True") ret = self.app.get("/post_grade/2.0") self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, "grade=False") + self.assertEqual(ret.data.decode('utf-8'), "grade=False") @httpretty.activate def test_access_to_oauth_resource_post_grade2(self): @@ -526,11 +527,11 @@ def test_access_to_oauth_resource_post_grade2(self): ret = self.app.get("/post_grade2/1.0") self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, "grade=True") + self.assertEqual(ret.data.decode('utf-8'), "grade=True") ret = self.app.get("/post_grade2/2.0") self.assertFalse(self.has_exception()) - self.assertEqual(ret.data, "grade=False") + self.assertEqual(ret.data.decode('utf-8'), "grade=False") def request_callback(self, request, cburi, headers): # pylint: disable=unused-argument @@ -568,7 +569,7 @@ def request_callback(request, cburi, headers): ret = self.app.get("/post_grade2/1.0") self.assertTrue(self.has_exception()) - self.assertEqual(ret.data, "error") + self.assertEqual(ret.data.decode('utf-8'), "error") @mock.patch.object(LTI, '_check_role') @mock.patch.object(LTI, 'verify') @@ -578,7 +579,7 @@ def test_decorator_no_app(self, mock_verify, _): mock_verify.return_value = True response = self.app.get('/no_app') self.assertEqual(200, response.status_code) - self.assertEqual('hi', response.data) + self.assertEqual('hi', response.data.decode('utf-8')) def test_default_decorator(self): """ @@ -598,4 +599,5 @@ def test_default_decorator_bad(self): # pylint: disable=maybe-no-member response = self.app.get('/default_lti') self.assertEqual(500, response.status_code) - self.assertEqual("There was an LTI communication error", response.data) + self.assertEqual("There was an LTI communication error", + response.data.decode('utf-8')) diff --git a/pytest.ini b/pytest.ini index 47e756d..0fbfb33 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ [pytest] -addopts = pylti -xvs -rs --pep8 --flakes --clearcache +addopts = pylti -xvs -rs --pep8 --flakes diff --git a/requirements.txt b/requirements.txt index 58082f0..b8177f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ Flask==0.10.1 httpretty==0.8.3 -oauth==1.0.1 -oauth2==1.5.211 +oauth2==1.9.0.post1 oauthlib==0.6.3 -pyflakes==0.8.1 -pytest==2.6.3 +pyflakes==1.2.3 +pytest==2.9.2 pytest-cache==1.0 -pytest-cov==1.8.0 -pytest-flakes==0.2 +pytest-cov==2.3.0 +pytest-flakes==1.0.1 pytest-pep8==1.0.6 -httplib2==0.9 +httplib2==0.9.2 +six==1.10.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 64da4b0..56cccc8 100644 --- a/setup.py +++ b/setup.py @@ -4,12 +4,14 @@ # This file is part of PyLTI. # +from __future__ import print_function + import os import sys if sys.version_info < (2, 7): error = "ERROR: PyLTI requires Python 2.7+ ... exiting." - print >> sys.stderr, error + print(error, file=sys.stderr) sys.exit(1) try: @@ -48,24 +50,17 @@ def finalize_options(self): def run_tests(self): # import here, cause outside the eggs aren't loaded import pytest - # Needed in order for pytest_cache to load properly - # Alternate fix: import pytest_cache and pass to pytest.main - import _pytest.config - - pm = _pytest.config.get_plugin_manager() - pm.consider_setuptools_entrypoints() errno = pytest.main(self.test_args) sys.exit(errno) extra = dict(test_suite="pylti.tests", - tests_require=["pytest-cov>=1.8.0", "pytest-pep8>=1.0.6", - "pytest-flakes>=0.2", "pytest>=2.6.3", + tests_require=["pytest-cov>=2.3.0", "pytest-pep8>=1.0.6", + "pytest-flakes>=1.0.1", "pytest>=2.9.2", "httpretty>=0.8.3", "flask>=0.10.1", "oauthlib>=0.6.3", "semantic_version>=2.3.1", "mock==1.0.1"], cmdclass={"test": PyTest}, - install_requires=["oauth>=1.0.1", "oauth2>=1.5.211", - "httplib2>=0.9" ], + install_requires=["oauth2>=1.9.0.post1", "httplib2>=0.9", "six>=1.10.0"], include_package_data=True, zip_safe=False) except ImportError as err: @@ -114,10 +109,10 @@ def find_packages(where='.', exclude=()): out = [item for item in out if not fnmatchcase(item, pat)] return out - print "Non-Fatal Error:", err, "\n" - print "Setup encountered an error while importing setuptools (see above)." - print "Proceeding anyway with manual replacements for setuptools.find_packages." - print "Try installing setuptools if you continue to have problems.\n\n" + print("Non-Fatal Error:", err, "\n") + print("Setup encountered an error while importing setuptools (see above).") + print("Proceeding anyway with manual replacements for setuptools.find_packages.") + print("Try installing setuptools if you continue to have problems.\n\n") extra = dict() diff --git a/test_requirements.txt b/test_requirements.txt index 2f719e1..3ce3cf5 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,9 +1,11 @@ -pytest-cov>=1.8.0 +pytest-cov>=2.3.0 pytest-pep8>=1.0.6 -pytest-flakes>=0.2 -pytest>=2.6.3 +pytest-flakes>=1.0.1 +pytest>=2.9.2 httpretty>=0.8.3 flask>=0.10.1 oauthlib>=0.6.3 semantic_version>=2.3.1 mock>=1.0.1 +oauth2>=1.9.0.post1 +six>=1.10.0 \ No newline at end of file