From 31197b826f663ce30bc46bcf2a1bcdc8aa9b042c Mon Sep 17 00:00:00 2001 From: mksh Date: Tue, 3 Mar 2015 13:08:58 +0200 Subject: [PATCH 01/53] Added HTTP Digest authentication support --- docs/examples/digest_auth.py | 16 +++ docs/howto.rst | 18 +++ docs/index.rst | 2 +- treq/auth.py | 179 ++++++++++++++++++++++++++++- treq/client.py | 6 +- treq/test/test_auth.py | 16 ++- treq/test/test_treq_integration.py | 39 +++++++ 7 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 docs/examples/digest_auth.py diff --git a/docs/examples/digest_auth.py b/docs/examples/digest_auth.py new file mode 100644 index 00000000..ae590718 --- /dev/null +++ b/docs/examples/digest_auth.py @@ -0,0 +1,16 @@ +from twisted.internet.task import react +from _utils import print_response + +import treq +from treq.auth import HTTPDigestAuth + + +def main(reactor, *args): + d = treq.get( + 'http://httpbin.org/digest-auth/auth/treq/treq', + auth=HTTPDigestAuth('treq', 'treq') + ) + d.addCallback(print_response) + return d + +react(main, []) diff --git a/docs/howto.rst b/docs/howto.rst index 3b597227..400cf996 100644 --- a/docs/howto.rst +++ b/docs/howto.rst @@ -52,7 +52,25 @@ The ``auth`` argument should be a tuple of the form ``('username', 'password')`` Full example: :download:`basic_auth.py ` +HTTP Digest authentication is supported by passing an instance of +:py:class:`treq.auth.HTTPDigestAuth` class with ``auth`` keyword argument to any of +the request functions. We support only "auth" QoP as defined at `RFC 2617`_ +or simple `RFC 2069`_ without QoP at the moment. Treq takes care about +HTTP digest credentials caching - after authorization on any URL/method pair, +the library will use the first time received HTTP digest credentials on that endpoint +for further requests, and will not perform any redundant requests for obtaining the creds. + +:py:class:`treq.auth.HTTPDigestAuth` class accepts ``username`` and ``password`` +as constructor arguments. + +.. literalinclude:: examples/digest_auth.py + :linenos: + :lines: 5-14 + +Full example: :download:`digest_auth.py ` + .. _RFC 2617: http://www.ietf.org/rfc/rfc2617.txt +.. _RFC 2069: http://www.ietf.org/rfc/rfc2069.txt Redirects --------- diff --git a/docs/index.rst b/docs/index.rst index fce55e59..6f46e3f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -71,7 +71,7 @@ Here is a list of `requests`_ features and their status in treq. +----------------------------------+----------+----------+ | Basic Authentication | yes | yes | +----------------------------------+----------+----------+ -| Digest Authentication | yes | no | +| Digest Authentication | yes | yes | +----------------------------------+----------+----------+ | Elegant Key/Value Cookies | yes | yes | +----------------------------------+----------+----------+ diff --git a/treq/auth.py b/treq/auth.py index d693ff33..21de2cca 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -1,7 +1,109 @@ from __future__ import absolute_import, division, print_function +import re +import time +import base64 +import hashlib +import urlparse from twisted.web.http_headers import Headers -import base64 +from twisted.python.randbytes import secureRandom +from requests.utils import parse_dict_header + + +_DIGEST_HEADER_PREFIX_REGEXP = re.compile(r'digest ', flags=re.IGNORECASE) + + +def generate_client_nonce(server_side_nonce): + return hashlib.sha1( + hashlib.sha1(server_side_nonce).digest() + secureRandom(16) + time.ctime() + ).hexdigest()[:16] + + +def _md5_utf_digest(x): + if isinstance(x, str): + x = x.encode('utf-8') + return hashlib.md5(x).hexdigest() + + +def _sha1_utf_digest(x): + if isinstance(x, str): + x = x.encode('utf-8') + return hashlib.sha1(x).hexdigest() + + +def build_digest_authentication_header(agent, **kwargs): + algo = kwargs.get('algorithm', 'MD5').upper() + original_algo = kwargs.get('algorithm') + qop = kwargs.get('qop', None) + nonce = kwargs.get('nonce') + opaque = kwargs.get('opaque', None) + path_parsed = urlparse.urlparse(kwargs['path']) + actual_path = path_parsed.path + + if path_parsed.query: + actual_path += '?' + path_parsed.query + + a1 = '%s:%s:%s' % ( + agent.username, + kwargs['realm'], + agent.password + ) + + a2 = '%s:%s' % ( + kwargs['method'], + actual_path + ) + + if algo == 'MD5' or algo == 'MD5-SESS': + digest_hash_func = _md5_utf_digest + elif algo == 'SHA': + digest_hash_func = _sha1_utf_digest + else: + raise UnknownDigestAuthAlgorithm(algo) + + ha1 = digest_hash_func(a1) + ha2 = digest_hash_func(a2) + + cnonce = generate_client_nonce(nonce) + + if algo == 'MD5-SESS': + ha1 = digest_hash_func("%s:%s:%s" % (ha1, nonce, cnonce), algo) + + if kwargs['cached']: + agent.digest_auth_cache[(kwargs['method'], kwargs['path'])]['c'] += 1 + nonce_count = agent.digest_auth_cache[(kwargs['method'], kwargs['path'])]['c'] + else: + nonce_count = 1 + + ncvalue = '%08x' % nonce_count + if qop is None: + response_digest = digest_hash_func("%s:%s" % (ha1, "%s:%s" % (ha2, nonce))) + else: + noncebit = "%s:%s:%s:%s:%s" % (nonce, ncvalue, cnonce.encode('utf-8'), 'auth', ha2) + response_digest = digest_hash_func("%s:%s" % (ha1, noncebit)) + + header_base = 'username="%s", realm="%s", nonce="%s", uri="%s", response="%s"' % ( + agent.username, kwargs['realm'], nonce, actual_path, response_digest + ) + if opaque: + header_base += ', opaque="%s"' % opaque + if original_algo: + header_base += ', algorithm="%s"' % original_algo + if qop: + header_base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) + if not kwargs['cached']: + agent.digest_auth_cache[(kwargs['method'], kwargs['path'])] = { + 'p': kwargs, + 'c': 1 + } + return 'Digest %s' % header_base + + +class HTTPDigestAuth(object): + + def __init__(self, username, password): + self.username = username + self.password = password class UnknownAuthConfig(Exception): @@ -10,6 +112,22 @@ def __init__(self, config): '{0!r} not of a known type.'.format(config)) +class UnknownQopForDigestAuth(Exception): + + def __init__(self, qop): + super(Exception, self).__init__( + 'Unsupported Quality Of Protection value passed: {qop}'.format(qop=qop) + ) + + +class UnknownDigestAuthAlgorithm(Exception): + + def __init__(self, algorithm): + super(Exception, self).__init__( + 'Unsupported Digest Auth algorithm identifier passed: {algorithm}'.format(algorithm=algorithm) + ) + + class _RequestHeaderSettingAgent(object): def __init__(self, agent, request_headers): self._agent = agent @@ -26,6 +144,61 @@ def request(self, method, uri, headers=None, bodyProducer=None): method, uri, headers=headers, bodyProducer=bodyProducer) +class _RequestDigestAuthenticationAgent(object): + + digest_auth_cache = {} + + def __init__(self, agent, username, password): + self._agent = agent + self.username = username + self.password = password + + def _on_401_response(self, www_authenticate_response, method, uri, headers, bodyProducer): + assert www_authenticate_response.code == 401, """Got invalid pre-authentication response code, probably URL + does not support Digest auth + """ + www_authenticate_header_string = www_authenticate_response.headers._rawHeaders.get('www-authenticate', [''])[0] + digest_authentication_params = parse_dict_header( + _DIGEST_HEADER_PREFIX_REGEXP.sub('', www_authenticate_header_string, count=1) + ) + if digest_authentication_params.get('qop', None) is not None and \ + digest_authentication_params['qop'] != 'auth' and \ + 'auth' not in digest_authentication_params['qop'].split(','): + # We support only "auth" QoP as defined in rfc-2617 or rfc-2069 + raise UnknownQopForDigestAuth(digest_authentication_params['qop']) + digest_authentication_header = build_digest_authentication_header( + self, + path=uri, + method=method, + cached=False, + **digest_authentication_params + ) + return self._perform_request( + digest_authentication_header, method, uri, headers, bodyProducer + ) + + def _perform_request(self, digest_authentication_header, method, uri, headers, bodyProducer): + if not headers: + headers = Headers({'Authorization': digest_authentication_header}) + else: + headers.addRawHeader('Authorization', digest_authentication_header) + return self._agent.request( + method, uri, headers=headers, bodyProducer=bodyProducer + ) + + def request(self, method, uri, headers=None, bodyProducer=None): + if self.digest_auth_cache.get((method, uri), None) is None: + # Perform first request for getting the realm; the client awaits for 401 response code here + d = self._agent.request(method, uri, headers=headers, bodyProducer=None) + d.addCallback(self._on_401_response, method, uri, headers, bodyProducer) + else: + digest_params_from_cache = self.digest_auth_cache.get((method, uri))['p'] + digest_params_from_cache['cached'] = True + digest_authentication_header = build_digest_authentication_header(self, **digest_params_from_cache) + d = self._perform_request(digest_authentication_header, method, uri, headers, bodyProducer) + return d + + def add_basic_auth(agent, username, password): creds = base64.b64encode( '{0}:{1}'.format(username, password).encode('ascii')) @@ -34,6 +207,10 @@ def add_basic_auth(agent, username, password): Headers({b'Authorization': [b'Basic ' + creds]})) +def add_digest_auth(agent, http_digest_auth): + return _RequestDigestAuthenticationAgent(agent, http_digest_auth.username, http_digest_auth.password) + + def add_auth(agent, auth_config): if isinstance(auth_config, tuple): return add_basic_auth(agent, auth_config[0], auth_config[1]) diff --git a/treq/client.py b/treq/client.py index 40785078..ff4558a4 100644 --- a/treq/client.py +++ b/treq/client.py @@ -29,7 +29,7 @@ from twisted.python.components import registerAdapter from treq._utils import default_reactor -from treq.auth import add_auth +from treq.auth import HTTPDigestAuth, add_auth, add_digest_auth from treq import multipart from treq.response import _Response from requests.cookies import cookiejar_from_dict, merge_cookies @@ -212,7 +212,9 @@ def request(self, method, url, **kwargs): [(b'gzip', GzipDecoder)]) auth = kwargs.get('auth') - if auth: + if isinstance(auth, HTTPDigestAuth): + wrapped_agent = add_digest_auth(wrapped_agent, auth) + elif auth: wrapped_agent = add_auth(wrapped_agent, auth) d = wrapped_agent.request( diff --git a/treq/test/test_auth.py b/treq/test/test_auth.py index 9b0f7557..67a80014 100644 --- a/treq/test/test_auth.py +++ b/treq/test/test_auth.py @@ -4,7 +4,7 @@ from twisted.web.http_headers import Headers from treq.test.util import TestCase -from treq.auth import _RequestHeaderSettingAgent, add_auth, UnknownAuthConfig +from treq.auth import _RequestHeaderSettingAgent, add_auth, UnknownAuthConfig, HTTPDigestAuth, add_digest_auth class RequestHeaderSettingAgentTests(TestCase): @@ -45,8 +45,11 @@ def test_overrides_per_request_headers(self): class AddAuthTests(TestCase): def setUp(self): self.rhsa_patcher = mock.patch('treq.auth._RequestHeaderSettingAgent') + self.rdaa_patcher = mock.patch('treq.auth._RequestDigestAuthenticationAgent') + self._RequestDigestAuthenticationAgent = self.rdaa_patcher.start() self._RequestHeaderSettingAgent = self.rhsa_patcher.start() self.addCleanup(self.rhsa_patcher.stop) + self.addCleanup(self.rdaa_patcher.stop) def test_add_basic_auth(self): agent = mock.Mock() @@ -70,6 +73,17 @@ def test_add_basic_auth_huge(self): agent, Headers({b'authorization': [auth]})) + def test_add_digest_auth(self): + agent = mock.Mock() + username = 'spam' + password = 'eggs' + + add_digest_auth(agent, HTTPDigestAuth(username, password)) + + self._RequestDigestAuthenticationAgent.assert_called_once_with( + agent, username, password + ) + def test_add_unknown_auth(self): agent = mock.Mock() self.assertRaises(UnknownAuthConfig, add_auth, agent, mock.Mock()) diff --git a/treq/test/test_treq_integration.py b/treq/test/test_treq_integration.py index 6b7db819..a2bdab33 100644 --- a/treq/test/test_treq_integration.py +++ b/treq/test/test_treq_integration.py @@ -14,6 +14,7 @@ from treq.test.util import DEBUG import treq +from treq.auth import HTTPDigestAuth HTTPBIN_URL = "http://httpbin.org" HTTPSBIN_URL = "https://httpbin.org" @@ -237,6 +238,44 @@ def test_failed_basic_auth(self): self.assertEqual(response.code, 401) yield print_response(response) + @inlineCallbacks + def test_digest_auth(self): + response = yield self.get('/digest-auth/auth/treq/treq', + auth=HTTPDigestAuth('treq', 'treq')) + self.assertEqual(response.code, 200) + yield print_response(response) + json = yield treq.json_content(response) + self.assertTrue(json['authenticated']) + self.assertEqual(json['user'], 'treq') + + @inlineCallbacks + def test_digest_auth_multiple_calls(self): + response1 = yield self.get( + '/digest-auth/auth/treq-digest-auth-multiple/treq', + auth=HTTPDigestAuth('treq-digest-auth-multiple', 'treq') + ) + self.assertEqual(response1.code, 200) + yield print_response(response1) + json1 = yield treq.json_content(response1) + response2 = yield self.get( + '/digest-auth/auth/treq-digest-auth-multiple/treq', + auth=HTTPDigestAuth('treq-digest-auth-multiple', 'treq'), + cookies=response1.cookies() + ) + self.assertEqual(response2.code, 200) + yield print_response(response2) + json2 = yield treq.json_content(response2) + self.assertTrue(json1['authenticated']) + self.assertEqual(json1['user'], 'treq-digest-auth-multiple') + self.assertDictEqual(json1, json2) + + @inlineCallbacks + def test_failed_digest_auth(self): + response = yield self.get('/digest-auth/auth/treq/treq', + auth=HTTPDigestAuth('not-treq', 'not-treq')) + self.assertEqual(response.code, 401) + yield print_response(response) + @inlineCallbacks def test_timeout(self): """ From fffaaca66b2828df3b85264fba34e9ee1bdb30ed Mon Sep 17 00:00:00 2001 From: mksh Date: Tue, 3 Mar 2015 14:49:21 +0200 Subject: [PATCH 02/53] Added docstrings; got rid of assertDictEqual in test_digest_auth_multiple_calls test case --- treq/auth.py | 46 ++++++++++++++++++++++++++++++ treq/test/test_treq_integration.py | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/treq/auth.py b/treq/auth.py index 21de2cca..05a7df13 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -32,6 +32,21 @@ def _sha1_utf_digest(x): def build_digest_authentication_header(agent, **kwargs): + """ + Build the authorization header for credentials got from the server + :param agent: _RequestDigestAuthenticationAgent instance + :param kwargs: - algorithm - algorithm to be used for authentication, defaults to MD5, supported values are + "MD5", "MD5-SESS" and "SHA" + - realm - HTTP Digest authentication realm + - nonce - "nonce" HTTP Digest authentication param + - qop - Quality Of Protection HTTP Digest authentication param + - opaque - "opaque" HTTP Digest authentication param (should be sent back to server unchanged) + - cached - Identifies that authentication already have been performed for URI/method, + and new request should use the same params as first authenticated request + - path - the URI path where we are authenticating + - method - HTTP method to be used when requesting + :return: HTTP Digest authentication string + """ algo = kwargs.get('algorithm', 'MD5').upper() original_algo = kwargs.get('algorithm') qop = kwargs.get('qop', None) @@ -100,6 +115,9 @@ def build_digest_authentication_header(agent, **kwargs): class HTTPDigestAuth(object): + """ + The container for HTTP Digest authentication credentials + """ def __init__(self, username, password): self.username = username @@ -154,6 +172,16 @@ def __init__(self, agent, username, password): self.password = password def _on_401_response(self, www_authenticate_response, method, uri, headers, bodyProducer): + """ + Handle the server`s 401 response, that is capable with authentication headers, build the Authorization header + for + :param www_authenticate_response: t.w.client.Response object + :param method: HTTP method to be used to perform the request + :param uri: URI to be used + :param headers: Additional headers to be sent with the request, instead of "Authorization" header + :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :return: + """ assert www_authenticate_response.code == 401, """Got invalid pre-authentication response code, probably URL does not support Digest auth """ @@ -178,6 +206,15 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, body ) def _perform_request(self, digest_authentication_header, method, uri, headers, bodyProducer): + """ + Add Authorization header and perform the request with actual credentials + :param digest_authentication_header: HTTP Digest Authorization header string + :param method: HTTP method to be used to perform the request + :param uri: URI to be used + :param headers: Headers to be sent with the request + :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :return: t.i.defer.Deferred (holding the result of the request) + """ if not headers: headers = Headers({'Authorization': digest_authentication_header}) else: @@ -187,11 +224,20 @@ def _perform_request(self, digest_authentication_header, method, uri, headers, b ) def request(self, method, uri, headers=None, bodyProducer=None): + """ + Wrap the agent with HTTP Digest authentication. + :param method: HTTP method to be used to perform the request + :param uri: URI to be used + :param headers: Headers to be sent with the request + :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :return: t.i.defer.Deferred (holding the result of the request) + """ if self.digest_auth_cache.get((method, uri), None) is None: # Perform first request for getting the realm; the client awaits for 401 response code here d = self._agent.request(method, uri, headers=headers, bodyProducer=None) d.addCallback(self._on_401_response, method, uri, headers, bodyProducer) else: + # We have performed authentication on that URI already digest_params_from_cache = self.digest_auth_cache.get((method, uri))['p'] digest_params_from_cache['cached'] = True digest_authentication_header = build_digest_authentication_header(self, **digest_params_from_cache) diff --git a/treq/test/test_treq_integration.py b/treq/test/test_treq_integration.py index a2bdab33..924ae6b9 100644 --- a/treq/test/test_treq_integration.py +++ b/treq/test/test_treq_integration.py @@ -267,7 +267,7 @@ def test_digest_auth_multiple_calls(self): json2 = yield treq.json_content(response2) self.assertTrue(json1['authenticated']) self.assertEqual(json1['user'], 'treq-digest-auth-multiple') - self.assertDictEqual(json1, json2) + self.assertEqual(json1, json2) @inlineCallbacks def test_failed_digest_auth(self): From 0c5d729bfa18385bc99b96920079353cd525ccc1 Mon Sep 17 00:00:00 2001 From: mksh Date: Tue, 3 Mar 2015 16:24:31 +0200 Subject: [PATCH 03/53] PEP8 --- treq/auth.py | 114 ++++++++++++++++++++++++++++------------- treq/test/test_auth.py | 7 ++- 2 files changed, 82 insertions(+), 39 deletions(-) diff --git a/treq/auth.py b/treq/auth.py index 05a7df13..c0735e65 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -15,7 +15,9 @@ def generate_client_nonce(server_side_nonce): return hashlib.sha1( - hashlib.sha1(server_side_nonce).digest() + secureRandom(16) + time.ctime() + hashlib.sha1(server_side_nonce).digest() + + secureRandom(16) + + time.ctime() ).hexdigest()[:16] @@ -35,14 +37,18 @@ def build_digest_authentication_header(agent, **kwargs): """ Build the authorization header for credentials got from the server :param agent: _RequestDigestAuthenticationAgent instance - :param kwargs: - algorithm - algorithm to be used for authentication, defaults to MD5, supported values are + :param kwargs: - algorithm - algorithm to be used for authentication, + defaults to MD5, supported values are "MD5", "MD5-SESS" and "SHA" - realm - HTTP Digest authentication realm - nonce - "nonce" HTTP Digest authentication param - - qop - Quality Of Protection HTTP Digest authentication param - - opaque - "opaque" HTTP Digest authentication param (should be sent back to server unchanged) - - cached - Identifies that authentication already have been performed for URI/method, - and new request should use the same params as first authenticated request + - qop - Quality Of Protection HTTP Digest auth param + - opaque - "opaque" HTTP Digest authentication param + (should be sent back to server unchanged) + - cached - Identifies that authentication already have been + performed for URI/method, + and new request should use the same params as first + authenticated request - path - the URI path where we are authenticating - method - HTTP method to be used when requesting :return: HTTP Digest authentication string @@ -86,32 +92,38 @@ def build_digest_authentication_header(agent, **kwargs): if kwargs['cached']: agent.digest_auth_cache[(kwargs['method'], kwargs['path'])]['c'] += 1 - nonce_count = agent.digest_auth_cache[(kwargs['method'], kwargs['path'])]['c'] + nonce_count = agent.digest_auth_cache[ + (kwargs['method'], kwargs['path']) + ]['c'] else: nonce_count = 1 ncvalue = '%08x' % nonce_count if qop is None: - response_digest = digest_hash_func("%s:%s" % (ha1, "%s:%s" % (ha2, nonce))) + response_digest = digest_hash_func( + "%s:%s" % (ha1, "%s:%s" % (ha2, nonce)) + ) else: - noncebit = "%s:%s:%s:%s:%s" % (nonce, ncvalue, cnonce.encode('utf-8'), 'auth', ha2) + noncebit = "%s:%s:%s:%s:%s" % ( + nonce, ncvalue, cnonce.encode('utf-8'), 'auth', ha2 + ) response_digest = digest_hash_func("%s:%s" % (ha1, noncebit)) - header_base = 'username="%s", realm="%s", nonce="%s", uri="%s", response="%s"' % ( + hb = 'username="%s", realm="%s", nonce="%s", uri="%s", response="%s"' % ( agent.username, kwargs['realm'], nonce, actual_path, response_digest ) if opaque: - header_base += ', opaque="%s"' % opaque + hb += ', opaque="%s"' % opaque if original_algo: - header_base += ', algorithm="%s"' % original_algo + hb += ', algorithm="%s"' % original_algo if qop: - header_base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) + hb += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) if not kwargs['cached']: agent.digest_auth_cache[(kwargs['method'], kwargs['path'])] = { 'p': kwargs, 'c': 1 } - return 'Digest %s' % header_base + return 'Digest %s' % hb class HTTPDigestAuth(object): @@ -134,7 +146,9 @@ class UnknownQopForDigestAuth(Exception): def __init__(self, qop): super(Exception, self).__init__( - 'Unsupported Quality Of Protection value passed: {qop}'.format(qop=qop) + 'Unsupported Quality Of Protection value passed: {qop}'.format( + qop=qop + ) ) @@ -142,7 +156,8 @@ class UnknownDigestAuthAlgorithm(Exception): def __init__(self, algorithm): super(Exception, self).__init__( - 'Unsupported Digest Auth algorithm identifier passed: {algorithm}'.format(algorithm=algorithm) + 'Unsupported Digest Auth algorithm identifier passed: {algorithm}' + .format(algorithm=algorithm) ) @@ -171,23 +186,31 @@ def __init__(self, agent, username, password): self.username = username self.password = password - def _on_401_response(self, www_authenticate_response, method, uri, headers, bodyProducer): + def _on_401_response(self, www_authenticate_response, method, uri, headers, + bodyProducer): """ - Handle the server`s 401 response, that is capable with authentication headers, build the Authorization header + Handle the server`s 401 response, that is capable with authentication + headers, build the Authorization header for :param www_authenticate_response: t.w.client.Response object :param method: HTTP method to be used to perform the request :param uri: URI to be used - :param headers: Additional headers to be sent with the request, instead of "Authorization" header - :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :param headers: Additional headers to be sent with the request, + instead of "Authorization" header + :param bodyProducer: IBodyProducer implementer instance that would be + used to fetch the response body :return: """ - assert www_authenticate_response.code == 401, """Got invalid pre-authentication response code, probably URL - does not support Digest auth - """ - www_authenticate_header_string = www_authenticate_response.headers._rawHeaders.get('www-authenticate', [''])[0] + assert www_authenticate_response.code == 401, \ + """Got invalid pre-authentication response code, probably URL + does not support Digest auth + """ + www_authenticate_header_string = www_authenticate_response.\ + headers._rawHeaders.get('www-authenticate', [''])[0] digest_authentication_params = parse_dict_header( - _DIGEST_HEADER_PREFIX_REGEXP.sub('', www_authenticate_header_string, count=1) + _DIGEST_HEADER_PREFIX_REGEXP.sub( + '', www_authenticate_header_string, count=1 + ) ) if digest_authentication_params.get('qop', None) is not None and \ digest_authentication_params['qop'] != 'auth' and \ @@ -205,14 +228,18 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, body digest_authentication_header, method, uri, headers, bodyProducer ) - def _perform_request(self, digest_authentication_header, method, uri, headers, bodyProducer): + def _perform_request(self, digest_authentication_header, method, uri, + headers, bodyProducer): """ - Add Authorization header and perform the request with actual credentials - :param digest_authentication_header: HTTP Digest Authorization header string + Add Authorization header and perform the request with + actual credentials + :param digest_authentication_header: HTTP Digest Authorization + header string :param method: HTTP method to be used to perform the request :param uri: URI to be used :param headers: Headers to be sent with the request - :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :param bodyProducer: IBodyProducer implementer instance that would be + used to fetch the response body :return: t.i.defer.Deferred (holding the result of the request) """ if not headers: @@ -229,19 +256,30 @@ def request(self, method, uri, headers=None, bodyProducer=None): :param method: HTTP method to be used to perform the request :param uri: URI to be used :param headers: Headers to be sent with the request - :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :param bodyProducer: IBodyProducer implementer instance that would be + used to fetch the response body :return: t.i.defer.Deferred (holding the result of the request) """ if self.digest_auth_cache.get((method, uri), None) is None: - # Perform first request for getting the realm; the client awaits for 401 response code here - d = self._agent.request(method, uri, headers=headers, bodyProducer=None) - d.addCallback(self._on_401_response, method, uri, headers, bodyProducer) + # Perform first request for getting the realm; + # the client awaits for 401 response code here + d = self._agent.request(method, uri, + headers=headers, bodyProducer=None) + d.addCallback(self._on_401_response, method, uri, + headers, bodyProducer) else: # We have performed authentication on that URI already - digest_params_from_cache = self.digest_auth_cache.get((method, uri))['p'] + digest_params_from_cache = self.digest_auth_cache.get( + (method, uri) + )['p'] digest_params_from_cache['cached'] = True - digest_authentication_header = build_digest_authentication_header(self, **digest_params_from_cache) - d = self._perform_request(digest_authentication_header, method, uri, headers, bodyProducer) + digest_authentication_header = build_digest_authentication_header( + self, **digest_params_from_cache + ) + d = self._perform_request( + digest_authentication_header, method, + uri, headers, bodyProducer + ) return d @@ -254,7 +292,9 @@ def add_basic_auth(agent, username, password): def add_digest_auth(agent, http_digest_auth): - return _RequestDigestAuthenticationAgent(agent, http_digest_auth.username, http_digest_auth.password) + return _RequestDigestAuthenticationAgent( + agent, http_digest_auth.username, http_digest_auth.password + ) def add_auth(agent, auth_config): diff --git a/treq/test/test_auth.py b/treq/test/test_auth.py index 67a80014..b8f8d86f 100644 --- a/treq/test/test_auth.py +++ b/treq/test/test_auth.py @@ -4,7 +4,8 @@ from twisted.web.http_headers import Headers from treq.test.util import TestCase -from treq.auth import _RequestHeaderSettingAgent, add_auth, UnknownAuthConfig, HTTPDigestAuth, add_digest_auth +from treq.auth import _RequestHeaderSettingAgent, add_auth, \ + UnknownAuthConfig, HTTPDigestAuth, add_digest_auth class RequestHeaderSettingAgentTests(TestCase): @@ -45,7 +46,9 @@ def test_overrides_per_request_headers(self): class AddAuthTests(TestCase): def setUp(self): self.rhsa_patcher = mock.patch('treq.auth._RequestHeaderSettingAgent') - self.rdaa_patcher = mock.patch('treq.auth._RequestDigestAuthenticationAgent') + self.rdaa_patcher = mock.patch( + 'treq.auth._RequestDigestAuthenticationAgent' + ) self._RequestDigestAuthenticationAgent = self.rdaa_patcher.start() self._RequestHeaderSettingAgent = self.rhsa_patcher.start() self.addCleanup(self.rhsa_patcher.stop) From 1d3b18c6369b958fabf530f23d61b6ed64dcc96f Mon Sep 17 00:00:00 2001 From: mksh Date: Fri, 13 Mar 2015 17:50:00 +0200 Subject: [PATCH 04/53] Docstrings, typing fixes and small code improvements --- docs/howto.rst | 6 +-- treq/auth.py | 9 ++++- treq/client.py | 6 +-- treq/test/test_treq_integration.py | 61 +++++++++++++++++++++++++++++- 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/docs/howto.rst b/docs/howto.rst index 400cf996..a71ccac7 100644 --- a/docs/howto.rst +++ b/docs/howto.rst @@ -53,9 +53,9 @@ The ``auth`` argument should be a tuple of the form ``('username', 'password')`` Full example: :download:`basic_auth.py ` HTTP Digest authentication is supported by passing an instance of -:py:class:`treq.auth.HTTPDigestAuth` class with ``auth`` keyword argument to any of -the request functions. We support only "auth" QoP as defined at `RFC 2617`_ -or simple `RFC 2069`_ without QoP at the moment. Treq takes care about +:py:class:`treq.auth.HTTPDigestAuth` to any of the request functions by using the `auth` keyword argument. +We support only "auth" QoP as defined at `RFC 2617`_ +or simple `RFC 2069`_ without QoP at the moment. Treq takes care of HTTP digest credentials caching - after authorization on any URL/method pair, the library will use the first time received HTTP digest credentials on that endpoint for further requests, and will not perform any redundant requests for obtaining the creds. diff --git a/treq/auth.py b/treq/auth.py index c0735e65..6d065cf1 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -35,7 +35,12 @@ def _sha1_utf_digest(x): def build_digest_authentication_header(agent, **kwargs): """ - Build the authorization header for credentials got from the server + Build the authorization header for credentials got from the server. + Algorithm is accurately ported from http://python-requests.org + with small adjustments. + See + https://github.com/kennethreitz/requests/blob/v2.5.1/requests/auth.py#L72 + for details. :param agent: _RequestDigestAuthenticationAgent instance :param kwargs: - algorithm - algorithm to be used for authentication, defaults to MD5, supported values are @@ -300,5 +305,7 @@ def add_digest_auth(agent, http_digest_auth): def add_auth(agent, auth_config): if isinstance(auth_config, tuple): return add_basic_auth(agent, auth_config[0], auth_config[1]) + elif isinstance(auth_config, HTTPDigestAuth): + return add_digest_auth(agent, auth_config) raise UnknownAuthConfig(auth_config) diff --git a/treq/client.py b/treq/client.py index ff4558a4..40785078 100644 --- a/treq/client.py +++ b/treq/client.py @@ -29,7 +29,7 @@ from twisted.python.components import registerAdapter from treq._utils import default_reactor -from treq.auth import HTTPDigestAuth, add_auth, add_digest_auth +from treq.auth import add_auth from treq import multipart from treq.response import _Response from requests.cookies import cookiejar_from_dict, merge_cookies @@ -212,9 +212,7 @@ def request(self, method, url, **kwargs): [(b'gzip', GzipDecoder)]) auth = kwargs.get('auth') - if isinstance(auth, HTTPDigestAuth): - wrapped_agent = add_digest_auth(wrapped_agent, auth) - elif auth: + if auth: wrapped_agent = add_auth(wrapped_agent, auth) d = wrapped_agent.request( diff --git a/treq/test/test_treq_integration.py b/treq/test/test_treq_integration.py index 924ae6b9..0d0eada7 100644 --- a/treq/test/test_treq_integration.py +++ b/treq/test/test_treq_integration.py @@ -8,8 +8,9 @@ from twisted import version as current_version from twisted.python.versions import Version +from twisted.python.monkey import MonkeyPatcher -from twisted.web.client import HTTPConnectionPool, ResponseFailed +from twisted.web.client import HTTPConnectionPool, ResponseFailed, Agent from treq.test.util import DEBUG @@ -240,6 +241,10 @@ def test_failed_basic_auth(self): @inlineCallbacks def test_digest_auth(self): + """ + Test successful Digest authentication + :return: + """ response = yield self.get('/digest-auth/auth/treq/treq', auth=HTTPDigestAuth('treq', 'treq')) self.assertEqual(response.code, 200) @@ -250,6 +255,35 @@ def test_digest_auth(self): @inlineCallbacks def test_digest_auth_multiple_calls(self): + """ + Test proper Digest authentication credentials caching + """ + + # A mutable holder for call counter + agent_request_call_storage = { + 'c': 0, + 'i': [] + } + + # Original Agent request call + agent_request_orig = Agent.request + + def agent_request_patched(*args, **kwargs): + """ + Patched Agent.request function, + that inscreaces call count on every HTTP request + and appends + """ + response_deferred = agent_request_orig(*args, **kwargs) + agent_request_call_storage['c'] += 1 + agent_request_call_storage['i'].append((args, kwargs)) + return response_deferred + + patcher = MonkeyPatcher( + (Agent, 'request', agent_request_patched) + ) + patcher.patch() + response1 = yield self.get( '/digest-auth/auth/treq-digest-auth-multiple/treq', auth=HTTPDigestAuth('treq-digest-auth-multiple', 'treq') @@ -257,6 +291,19 @@ def test_digest_auth_multiple_calls(self): self.assertEqual(response1.code, 200) yield print_response(response1) json1 = yield treq.json_content(response1) + + # Assume we did two actual HTTP requests - one to obtain credentials + # and second is original request with authentication + self.assertEqual( + agent_request_call_storage['c'], + 2 + ) + headers_for_second_request = agent_request_call_storage['i'][1][0][3] + self.assertIn( + 'Authorization', + dict(headers_for_second_request.getAllRawHeaders()) + ) + response2 = yield self.get( '/digest-auth/auth/treq-digest-auth-multiple/treq', auth=HTTPDigestAuth('treq-digest-auth-multiple', 'treq'), @@ -267,10 +314,22 @@ def test_digest_auth_multiple_calls(self): json2 = yield treq.json_content(response2) self.assertTrue(json1['authenticated']) self.assertEqual(json1['user'], 'treq-digest-auth-multiple') + + # Assume that responses are the same self.assertEqual(json1, json2) + # Assume we need only one call to obtain second response + self.assertEqual( + agent_request_call_storage['c'], + 3 + ) + patcher.restore() + @inlineCallbacks def test_failed_digest_auth(self): + """ + Test digest auth with invalid credentials + """ response = yield self.get('/digest-auth/auth/treq/treq', auth=HTTPDigestAuth('not-treq', 'not-treq')) self.assertEqual(response.code, 401) From 42d695d0c3f50187067a73c6a414504e91c5dd46 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 31 Jul 2016 02:40:04 +0200 Subject: [PATCH 05/53] Fix POST Request Digest Authentication --- treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/treq/auth.py b/treq/auth.py index 6d065cf1..bc0c09fa 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -268,7 +268,7 @@ def request(self, method, uri, headers=None, bodyProducer=None): if self.digest_auth_cache.get((method, uri), None) is None: # Perform first request for getting the realm; # the client awaits for 401 response code here - d = self._agent.request(method, uri, + d = self._agent.request('GET', uri, headers=headers, bodyProducer=None) d.addCallback(self._on_401_response, method, uri, headers, bodyProducer) From e74966545f3ea6e99ea6d763aa1b174c2bf709a6 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Tue, 2 Aug 2016 00:10:18 +0200 Subject: [PATCH 06/53] python 3 urlparse compatibility --- treq/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/treq/auth.py b/treq/auth.py index bc0c09fa..49adfd3d 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -3,8 +3,8 @@ import time import base64 import hashlib -import urlparse +from six.moves.urllib.parse import urlparse from twisted.web.http_headers import Headers from twisted.python.randbytes import secureRandom from requests.utils import parse_dict_header @@ -63,7 +63,7 @@ def build_digest_authentication_header(agent, **kwargs): qop = kwargs.get('qop', None) nonce = kwargs.get('nonce') opaque = kwargs.get('opaque', None) - path_parsed = urlparse.urlparse(kwargs['path']) + path_parsed = urlparse(kwargs['path']) actual_path = path_parsed.path if path_parsed.query: From a627f0b091cf51cf34942b60ade000552d253e81 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sat, 10 Sep 2016 13:21:21 +0000 Subject: [PATCH 07/53] Refactor build_digest_authentication_header as a private method of _RequestDigestAuthenticationAgent without kwargs --- treq/auth.py | 228 +++++++++++++++++++++++++++------------------------ 1 file changed, 123 insertions(+), 105 deletions(-) diff --git a/treq/auth.py b/treq/auth.py index 49adfd3d..58c266b5 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -33,104 +33,6 @@ def _sha1_utf_digest(x): return hashlib.sha1(x).hexdigest() -def build_digest_authentication_header(agent, **kwargs): - """ - Build the authorization header for credentials got from the server. - Algorithm is accurately ported from http://python-requests.org - with small adjustments. - See - https://github.com/kennethreitz/requests/blob/v2.5.1/requests/auth.py#L72 - for details. - :param agent: _RequestDigestAuthenticationAgent instance - :param kwargs: - algorithm - algorithm to be used for authentication, - defaults to MD5, supported values are - "MD5", "MD5-SESS" and "SHA" - - realm - HTTP Digest authentication realm - - nonce - "nonce" HTTP Digest authentication param - - qop - Quality Of Protection HTTP Digest auth param - - opaque - "opaque" HTTP Digest authentication param - (should be sent back to server unchanged) - - cached - Identifies that authentication already have been - performed for URI/method, - and new request should use the same params as first - authenticated request - - path - the URI path where we are authenticating - - method - HTTP method to be used when requesting - :return: HTTP Digest authentication string - """ - algo = kwargs.get('algorithm', 'MD5').upper() - original_algo = kwargs.get('algorithm') - qop = kwargs.get('qop', None) - nonce = kwargs.get('nonce') - opaque = kwargs.get('opaque', None) - path_parsed = urlparse(kwargs['path']) - actual_path = path_parsed.path - - if path_parsed.query: - actual_path += '?' + path_parsed.query - - a1 = '%s:%s:%s' % ( - agent.username, - kwargs['realm'], - agent.password - ) - - a2 = '%s:%s' % ( - kwargs['method'], - actual_path - ) - - if algo == 'MD5' or algo == 'MD5-SESS': - digest_hash_func = _md5_utf_digest - elif algo == 'SHA': - digest_hash_func = _sha1_utf_digest - else: - raise UnknownDigestAuthAlgorithm(algo) - - ha1 = digest_hash_func(a1) - ha2 = digest_hash_func(a2) - - cnonce = generate_client_nonce(nonce) - - if algo == 'MD5-SESS': - ha1 = digest_hash_func("%s:%s:%s" % (ha1, nonce, cnonce), algo) - - if kwargs['cached']: - agent.digest_auth_cache[(kwargs['method'], kwargs['path'])]['c'] += 1 - nonce_count = agent.digest_auth_cache[ - (kwargs['method'], kwargs['path']) - ]['c'] - else: - nonce_count = 1 - - ncvalue = '%08x' % nonce_count - if qop is None: - response_digest = digest_hash_func( - "%s:%s" % (ha1, "%s:%s" % (ha2, nonce)) - ) - else: - noncebit = "%s:%s:%s:%s:%s" % ( - nonce, ncvalue, cnonce.encode('utf-8'), 'auth', ha2 - ) - response_digest = digest_hash_func("%s:%s" % (ha1, noncebit)) - - hb = 'username="%s", realm="%s", nonce="%s", uri="%s", response="%s"' % ( - agent.username, kwargs['realm'], nonce, actual_path, response_digest - ) - if opaque: - hb += ', opaque="%s"' % opaque - if original_algo: - hb += ', algorithm="%s"' % original_algo - if qop: - hb += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) - if not kwargs['cached']: - agent.digest_auth_cache[(kwargs['method'], kwargs['path'])] = { - 'p': kwargs, - 'c': 1 - } - return 'Digest %s' % hb - - class HTTPDigestAuth(object): """ The container for HTTP Digest authentication credentials @@ -191,6 +93,112 @@ def __init__(self, agent, username, password): self.username = username self.password = password + def _build_digest_authentication_header(self, agent, path, method, cached, + nonce, realm, qop=None, algorithm='MD5', opaque=None): + """ + Build the authorization header for credentials got from the server. + Algorithm is accurately ported from http://python-requests.org + with small adjustments. + See + https://github.com/kennethreitz/requests/blob/v2.5.1/requests/auth.py#L72 + for details. + :param agent: _RequestDigestAuthenticationAgent instance + :param algorithm: algorithm to be used for authentication, + defaults to MD5, supported values are + "MD5", "MD5-SESS" and "SHA" + :param realm: HTTP Digest authentication realm + :param nonce: "nonce" HTTP Digest authentication param + :param qop: Quality Of Protection HTTP Digest auth param + :param opaque: "opaque" HTTP Digest authentication param + (should be sent back to server unchanged) + :param cached: Identifies that authentication already have been + performed for URI/method, + and new request should use the same params as first + authenticated request + :param path: the URI path where we are authenticating + :param method: HTTP method to be used when requesting + :return: HTTP Digest authentication string + """ + algo = algorithm.upper() + original_algo = algorithm + path_parsed = urlparse(path) + actual_path = path_parsed.path + + if path_parsed.query: + actual_path += '?' + path_parsed.query + + a1 = '%s:%s:%s' % ( + agent.username, + realm, + agent.password + ) + + a2 = '%s:%s' % ( + method, + actual_path + ) + + if algo == 'MD5' or algo == 'MD5-SESS': + digest_hash_func = _md5_utf_digest + elif algo == 'SHA': + digest_hash_func = _sha1_utf_digest + else: + raise UnknownDigestAuthAlgorithm(algo) + + ha1 = digest_hash_func(a1) + ha2 = digest_hash_func(a2) + + cnonce = generate_client_nonce(nonce) + + if algo == 'MD5-SESS': + ha1 = digest_hash_func("%s:%s:%s" % (ha1, nonce, cnonce), algo) + + if cached: + agent.digest_auth_cache[(method, path)]['c'] += 1 + nonce_count = agent.digest_auth_cache[ + (method, path) + ]['c'] + else: + nonce_count = 1 + + ncvalue = '%08x' % nonce_count + if qop is None: + response_digest = digest_hash_func( + "%s:%s" % (ha1, "%s:%s" % (ha2, nonce)) + ) + else: + noncebit = "%s:%s:%s:%s:%s" % ( + nonce, ncvalue, cnonce.encode('utf-8'), 'auth', ha2 + ) + response_digest = digest_hash_func("%s:%s" % (ha1, noncebit)) + + hb = 'username="%s", realm="%s", nonce="%s", uri="%s", response="%s"' % ( + agent.username, realm, nonce, actual_path, response_digest + ) + if opaque: + hb += ', opaque="%s"' % opaque + if original_algo: + hb += ', algorithm="%s"' % original_algo + if qop: + hb += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) + if not cached: + cache_params = { + 'agent': agent, + 'path': path, + 'method': method, + 'cached': cached, + 'nonce': nonce, + 'realm': realm, + 'qop': qop, + 'algorithm': algorithm, + 'opaque': opaque + } + agent.digest_auth_cache[(method, path)] = { + 'p': cache_params, + 'c': 1 + } + return 'Digest %s' % hb + def _on_401_response(self, www_authenticate_response, method, uri, headers, bodyProducer): """ @@ -222,12 +230,14 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, 'auth' not in digest_authentication_params['qop'].split(','): # We support only "auth" QoP as defined in rfc-2617 or rfc-2069 raise UnknownQopForDigestAuth(digest_authentication_params['qop']) - digest_authentication_header = build_digest_authentication_header( + digest_authentication_header = self._build_digest_authentication_header( self, - path=uri, - method=method, - cached=False, - **digest_authentication_params + uri, + method, + False, + digest_authentication_params['nonce'], + digest_authentication_params['realm'], + qop=digest_authentication_params['qop'] ) return self._perform_request( digest_authentication_header, method, uri, headers, bodyProducer @@ -278,8 +288,16 @@ def request(self, method, uri, headers=None, bodyProducer=None): (method, uri) )['p'] digest_params_from_cache['cached'] = True - digest_authentication_header = build_digest_authentication_header( - self, **digest_params_from_cache + digest_authentication_header = self._build_digest_authentication_header( + self, + digest_params_from_cache['path'], + digest_params_from_cache['method'], + digest_params_from_cache['cached'], + digest_params_from_cache['nonce'], + digest_params_from_cache['realm'], + qop=digest_params_from_cache['qop'], + algorithm=digest_params_from_cache['algorithm'], + opaque=digest_params_from_cache['opaque'] ) d = self._perform_request( digest_authentication_header, method, From aa8e1028a552cb89437f8086ac26ad3bcf0d7d51 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sat, 10 Sep 2016 14:29:14 +0000 Subject: [PATCH 08/53] Make username and password private --- treq/auth.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/treq/auth.py b/treq/auth.py index 58c266b5..f3b61f10 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -90,8 +90,8 @@ class _RequestDigestAuthenticationAgent(object): def __init__(self, agent, username, password): self._agent = agent - self.username = username - self.password = password + self._username = username + self._password = password def _build_digest_authentication_header(self, agent, path, method, cached, nonce, realm, qop=None, algorithm='MD5', opaque=None): @@ -128,9 +128,9 @@ def _build_digest_authentication_header(self, agent, path, method, cached, actual_path += '?' + path_parsed.query a1 = '%s:%s:%s' % ( - agent.username, + self._username, realm, - agent.password + self._password ) a2 = '%s:%s' % ( @@ -173,7 +173,7 @@ def _build_digest_authentication_header(self, agent, path, method, cached, response_digest = digest_hash_func("%s:%s" % (ha1, noncebit)) hb = 'username="%s", realm="%s", nonce="%s", uri="%s", response="%s"' % ( - agent.username, realm, nonce, actual_path, response_digest + self._username, realm, nonce, actual_path, response_digest ) if opaque: hb += ', opaque="%s"' % opaque From 2c1817eb99aa87aea54e5009d8fb1b5c97450799 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 11 Sep 2016 18:32:10 +0000 Subject: [PATCH 09/53] Remove redundant agent arguement from _build_digest_authentication_header --- treq/auth.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/treq/auth.py b/treq/auth.py index f3b61f10..3823a78b 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -93,7 +93,7 @@ def __init__(self, agent, username, password): self._username = username self._password = password - def _build_digest_authentication_header(self, agent, path, method, cached, + def _build_digest_authentication_header(self, path, method, cached, nonce, realm, qop=None, algorithm='MD5', opaque=None): """ Build the authorization header for credentials got from the server. @@ -102,7 +102,6 @@ def _build_digest_authentication_header(self, agent, path, method, cached, See https://github.com/kennethreitz/requests/blob/v2.5.1/requests/auth.py#L72 for details. - :param agent: _RequestDigestAuthenticationAgent instance :param algorithm: algorithm to be used for authentication, defaults to MD5, supported values are "MD5", "MD5-SESS" and "SHA" @@ -154,8 +153,8 @@ def _build_digest_authentication_header(self, agent, path, method, cached, ha1 = digest_hash_func("%s:%s:%s" % (ha1, nonce, cnonce), algo) if cached: - agent.digest_auth_cache[(method, path)]['c'] += 1 - nonce_count = agent.digest_auth_cache[ + self.digest_auth_cache[(method, path)]['c'] += 1 + nonce_count = self.digest_auth_cache[ (method, path) ]['c'] else: @@ -183,7 +182,6 @@ def _build_digest_authentication_header(self, agent, path, method, cached, hb += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) if not cached: cache_params = { - 'agent': agent, 'path': path, 'method': method, 'cached': cached, @@ -193,7 +191,7 @@ def _build_digest_authentication_header(self, agent, path, method, cached, 'algorithm': algorithm, 'opaque': opaque } - agent.digest_auth_cache[(method, path)] = { + self.digest_auth_cache[(method, path)] = { 'p': cache_params, 'c': 1 } @@ -231,7 +229,6 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, # We support only "auth" QoP as defined in rfc-2617 or rfc-2069 raise UnknownQopForDigestAuth(digest_authentication_params['qop']) digest_authentication_header = self._build_digest_authentication_header( - self, uri, method, False, @@ -289,7 +286,6 @@ def request(self, method, uri, headers=None, bodyProducer=None): )['p'] digest_params_from_cache['cached'] = True digest_authentication_header = self._build_digest_authentication_header( - self, digest_params_from_cache['path'], digest_params_from_cache['method'], digest_params_from_cache['cached'], From 91a5c7bcd5705ddc723c13484dedc30b7ac726cc Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 11 Sep 2016 18:48:18 +0000 Subject: [PATCH 10/53] Remove redundant None default from self.digest_auth_cache.get --- treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/treq/auth.py b/treq/auth.py index 3823a78b..b837d9cf 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -272,7 +272,7 @@ def request(self, method, uri, headers=None, bodyProducer=None): used to fetch the response body :return: t.i.defer.Deferred (holding the result of the request) """ - if self.digest_auth_cache.get((method, uri), None) is None: + if self.digest_auth_cache.get((method, uri)) is None: # Perform first request for getting the realm; # the client awaits for 401 response code here d = self._agent.request('GET', uri, From 19f6ad888d12ed861aa9658649d1655fc25aa375 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 11 Sep 2016 20:29:26 +0000 Subject: [PATCH 11/53] Use bytes instead of strings --- treq/auth.py | 84 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/treq/auth.py b/treq/auth.py index b837d9cf..0386e213 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -10,14 +10,14 @@ from requests.utils import parse_dict_header -_DIGEST_HEADER_PREFIX_REGEXP = re.compile(r'digest ', flags=re.IGNORECASE) +_DIGEST_HEADER_PREFIX_REGEXP = re.compile(b'digest ', flags=re.IGNORECASE) def generate_client_nonce(server_side_nonce): return hashlib.sha1( hashlib.sha1(server_side_nonce).digest() + secureRandom(16) + - time.ctime() + time.ctime().encode('utf-8') ).hexdigest()[:16] @@ -90,11 +90,11 @@ class _RequestDigestAuthenticationAgent(object): def __init__(self, agent, username, password): self._agent = agent - self._username = username - self._password = password + self._username = username.encode('utf-8') + self._password = password.encode('utf-8') def _build_digest_authentication_header(self, path, method, cached, - nonce, realm, qop=None, algorithm='MD5', opaque=None): + nonce, realm, qop=None, algorithm=b'MD5', opaque=None): """ Build the authorization header for credentials got from the server. Algorithm is accurately ported from http://python-requests.org @@ -137,9 +137,9 @@ def _build_digest_authentication_header(self, path, method, cached, actual_path ) - if algo == 'MD5' or algo == 'MD5-SESS': + if algo == b'MD5' or algo == b'MD5-SESS': digest_hash_func = _md5_utf_digest - elif algo == 'SHA': + elif algo == b'SHA': digest_hash_func = _sha1_utf_digest else: raise UnknownDigestAuthAlgorithm(algo) @@ -164,22 +164,37 @@ def _build_digest_authentication_header(self, path, method, cached, if qop is None: response_digest = digest_hash_func( "%s:%s" % (ha1, "%s:%s" % (ha2, nonce)) - ) + ).encode('utf-8') else: noncebit = "%s:%s:%s:%s:%s" % ( nonce, ncvalue, cnonce.encode('utf-8'), 'auth', ha2 ) - response_digest = digest_hash_func("%s:%s" % (ha1, noncebit)) - - hb = 'username="%s", realm="%s", nonce="%s", uri="%s", response="%s"' % ( - self._username, realm, nonce, actual_path, response_digest - ) + response_digest = digest_hash_func("%s:%s" % (ha1, noncebit)).encode('utf-8') + hb = b'username="' + hb += self._username + hb += b'", realm="' + hb += realm + hb += b'", nonce="' + hb += nonce + hb += b'", uri="' + hb += actual_path + hb += b'", response="' + hb += response_digest + hb += b'"' if opaque: - hb += ', opaque="%s"' % opaque + hb += b', opaque="' + hb += opaque + hb += b'"' if original_algo: - hb += ', algorithm="%s"' % original_algo + hb += b', algorithm="' + hb += original_algo + hb += b'"' if qop: - hb += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) + hb += b', qop="auth", nc=' + hb += ncvalue.encode('utf-8') + hb += b', cnonce="' + hb += cnonce.encode('utf-8') + hb += b'"' if not cached: cache_params = { 'path': path, @@ -195,7 +210,9 @@ def _build_digest_authentication_header(self, path, method, cached, 'p': cache_params, 'c': 1 } - return 'Digest %s' % hb + digest_res = b'Digest ' + digest_res += hb + return digest_res def _on_401_response(self, www_authenticate_response, method, uri, headers, bodyProducer): @@ -217,24 +234,27 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, does not support Digest auth """ www_authenticate_header_string = www_authenticate_response.\ - headers._rawHeaders.get('www-authenticate', [''])[0] - digest_authentication_params = parse_dict_header( - _DIGEST_HEADER_PREFIX_REGEXP.sub( - '', www_authenticate_header_string, count=1 - ) + headers._rawHeaders.get(b'www-authenticate', [b''])[0] + digest_header = _DIGEST_HEADER_PREFIX_REGEXP.sub( + b'', www_authenticate_header_string, count=1 + ) + digest_authentication_params_str = parse_dict_header( + digest_header.decode("utf-8") ) - if digest_authentication_params.get('qop', None) is not None and \ - digest_authentication_params['qop'] != 'auth' and \ - 'auth' not in digest_authentication_params['qop'].split(','): + digest_authentication_params = {k.encode('utf8'): v.encode('utf8') \ + for k, v in digest_authentication_params_str.items()} + if digest_authentication_params.get(b'qop', None) is not None and \ + digest_authentication_params[b'qop'] != b'auth' and \ + b'auth' not in digest_authentication_params[b'qop'].split(b','): # We support only "auth" QoP as defined in rfc-2617 or rfc-2069 - raise UnknownQopForDigestAuth(digest_authentication_params['qop']) + raise UnknownQopForDigestAuth(digest_authentication_params[b'qop']) digest_authentication_header = self._build_digest_authentication_header( uri, method, False, - digest_authentication_params['nonce'], - digest_authentication_params['realm'], - qop=digest_authentication_params['qop'] + digest_authentication_params[b'nonce'], + digest_authentication_params[b'realm'], + qop=digest_authentication_params[b'qop'] ) return self._perform_request( digest_authentication_header, method, uri, headers, bodyProducer @@ -255,9 +275,9 @@ def _perform_request(self, digest_authentication_header, method, uri, :return: t.i.defer.Deferred (holding the result of the request) """ if not headers: - headers = Headers({'Authorization': digest_authentication_header}) + headers = Headers({b'Authorization': digest_authentication_header}) else: - headers.addRawHeader('Authorization', digest_authentication_header) + headers.addRawHeader(b'Authorization', digest_authentication_header) return self._agent.request( method, uri, headers=headers, bodyProducer=bodyProducer ) @@ -275,7 +295,7 @@ def request(self, method, uri, headers=None, bodyProducer=None): if self.digest_auth_cache.get((method, uri)) is None: # Perform first request for getting the realm; # the client awaits for 401 response code here - d = self._agent.request('GET', uri, + d = self._agent.request(b'GET', uri, headers=headers, bodyProducer=None) d.addCallback(self._on_401_response, method, uri, headers, bodyProducer) From 1848e935908dcd4c35fa4e1df9e0035e53c1f2d3 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Mon, 12 Sep 2016 09:56:48 +0000 Subject: [PATCH 12/53] Fix python 3 digest hash encoding --- treq/auth.py | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/treq/auth.py b/treq/auth.py index 0386e213..3fa60f13 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -22,14 +22,10 @@ def generate_client_nonce(server_side_nonce): def _md5_utf_digest(x): - if isinstance(x, str): - x = x.encode('utf-8') return hashlib.md5(x).hexdigest() def _sha1_utf_digest(x): - if isinstance(x, str): - x = x.encode('utf-8') return hashlib.sha1(x).hexdigest() @@ -126,16 +122,15 @@ def _build_digest_authentication_header(self, path, method, cached, if path_parsed.query: actual_path += '?' + path_parsed.query - a1 = '%s:%s:%s' % ( - self._username, - realm, - self._password - ) + a1 = self._username + a1 += b':' + a1 += realm + a1 += b':' + a1 += self._password - a2 = '%s:%s' % ( - method, - actual_path - ) + a2 = method + a2 += b':' + a2 += actual_path if algo == b'MD5' or algo == b'MD5-SESS': digest_hash_func = _md5_utf_digest @@ -162,14 +157,25 @@ def _build_digest_authentication_header(self, path, method, cached, ncvalue = '%08x' % nonce_count if qop is None: - response_digest = digest_hash_func( - "%s:%s" % (ha1, "%s:%s" % (ha2, nonce)) - ).encode('utf-8') + rd = ha1.encode('utf-8') + rd += b':' + rd += ha2 + rd += b':' + rd += nonce + response_digest = digest_hash_func(rd).encode('utf-8') else: - noncebit = "%s:%s:%s:%s:%s" % ( - nonce, ncvalue, cnonce.encode('utf-8'), 'auth', ha2 - ) - response_digest = digest_hash_func("%s:%s" % (ha1, noncebit)).encode('utf-8') + rd = ha1.encode('utf-8') + rd += b':' + rd += nonce + rd += b':' + rd += ncvalue.encode('utf-8') + rd += b':' + rd += cnonce.encode('utf-8') + rd += b':' + rd += b'auth' + rd += b':' + rd += ha2.encode('utf-8') + response_digest = digest_hash_func(rd).encode('utf-8') hb = b'username="' hb += self._username hb += b'", realm="' From 5651fc35d839f5d9f45f30592b2629989daf6044 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Mon, 12 Sep 2016 11:52:33 +0000 Subject: [PATCH 13/53] Use byte encoding for MD5-SESS digest --- treq/auth.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/treq/auth.py b/treq/auth.py index 3fa60f13..c86d54ce 100644 --- a/treq/auth.py +++ b/treq/auth.py @@ -144,8 +144,13 @@ def _build_digest_authentication_header(self, path, method, cached, cnonce = generate_client_nonce(nonce) - if algo == 'MD5-SESS': - ha1 = digest_hash_func("%s:%s:%s" % (ha1, nonce, cnonce), algo) + if algo == b'MD5-SESS': + sess = ha1.encode('utf-8') + sess += b':' + sess += nonce + sess += b':' + sess += cnonce.encode('utf-8') + ha1 = digest_hash_func(sess) if cached: self.digest_auth_cache[(method, path)]['c'] += 1 From 9e6e298be1108c14765c525d701a4d21655e9f5a Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Thu, 11 May 2017 01:21:40 -0500 Subject: [PATCH 14/53] Fix python3 auth multiple calls test --- src/treq/test/test_treq_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/test/test_treq_integration.py b/src/treq/test/test_treq_integration.py index 6e6f187c..aa585062 100644 --- a/src/treq/test/test_treq_integration.py +++ b/src/treq/test/test_treq_integration.py @@ -285,7 +285,7 @@ def agent_request_patched(*args, **kwargs): ) headers_for_second_request = agent_request_call_storage['i'][1][0][3] self.assertIn( - 'Authorization', + b'Authorization', dict(headers_for_second_request.getAllRawHeaders()) ) From ffdbfa76211dd805a6cf567e5cb11db0857ba5a2 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Thu, 11 May 2017 04:09:50 -0500 Subject: [PATCH 15/53] Pass correct algorithm back for digest auth --- src/treq/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index c86d54ce..13c57d3b 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -265,7 +265,8 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, False, digest_authentication_params[b'nonce'], digest_authentication_params[b'realm'], - qop=digest_authentication_params[b'qop'] + qop=digest_authentication_params[b'qop'], + algorithm=digest_authentication_params[b'algorithm'] ) return self._perform_request( digest_authentication_header, method, uri, headers, bodyProducer From 4bd52ff2f152e5df3b271d03259937a7c6c1f316 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Thu, 11 May 2017 04:22:50 -0500 Subject: [PATCH 16/53] set default algorithm for digest auth --- src/treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 13c57d3b..b2ea7b0b 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -266,7 +266,7 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, digest_authentication_params[b'nonce'], digest_authentication_params[b'realm'], qop=digest_authentication_params[b'qop'], - algorithm=digest_authentication_params[b'algorithm'] + algorithm=digest_authentication_params.get(b'algorithm', b'MD5') ) return self._perform_request( digest_authentication_header, method, uri, headers, bodyProducer From a4dd9f2582dce5321c72ce1190dd7d8ec0cb6521 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Thu, 11 May 2017 04:53:03 -0500 Subject: [PATCH 17/53] PEP8 --- src/treq/auth.py | 56 ++++++++++++++++++++++---------------- src/treq/test/test_auth.py | 1 + 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index b2ea7b0b..e4f45bf8 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -89,8 +89,10 @@ def __init__(self, agent, username, password): self._username = username.encode('utf-8') self._password = password.encode('utf-8') - def _build_digest_authentication_header(self, path, method, cached, - nonce, realm, qop=None, algorithm=b'MD5', opaque=None): + def _build_digest_authentication_header( + self, path, method, cached, nonce, realm, qop=None, + algorithm=b'MD5', opaque=None + ): """ Build the authorization header for credentials got from the server. Algorithm is accurately ported from http://python-requests.org @@ -252,22 +254,26 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, digest_authentication_params_str = parse_dict_header( digest_header.decode("utf-8") ) - digest_authentication_params = {k.encode('utf8'): v.encode('utf8') \ + digest_authentication_params = { + k.encode('utf8'): v.encode('utf8') for k, v in digest_authentication_params_str.items()} if digest_authentication_params.get(b'qop', None) is not None and \ digest_authentication_params[b'qop'] != b'auth' and \ - b'auth' not in digest_authentication_params[b'qop'].split(b','): + b'auth' not in \ + digest_authentication_params[b'qop'].split(b','): # We support only "auth" QoP as defined in rfc-2617 or rfc-2069 raise UnknownQopForDigestAuth(digest_authentication_params[b'qop']) - digest_authentication_header = self._build_digest_authentication_header( - uri, - method, - False, - digest_authentication_params[b'nonce'], - digest_authentication_params[b'realm'], - qop=digest_authentication_params[b'qop'], - algorithm=digest_authentication_params.get(b'algorithm', b'MD5') - ) + digest_authentication_header = \ + self._build_digest_authentication_header( + uri, + method, + False, + digest_authentication_params[b'nonce'], + digest_authentication_params[b'realm'], + qop=digest_authentication_params[b'qop'], + algorithm=digest_authentication_params.get(b'algorithm', + b'MD5') + ) return self._perform_request( digest_authentication_header, method, uri, headers, bodyProducer ) @@ -289,7 +295,8 @@ def _perform_request(self, digest_authentication_header, method, uri, if not headers: headers = Headers({b'Authorization': digest_authentication_header}) else: - headers.addRawHeader(b'Authorization', digest_authentication_header) + headers.addRawHeader(b'Authorization', + digest_authentication_header) return self._agent.request( method, uri, headers=headers, bodyProducer=bodyProducer ) @@ -317,16 +324,17 @@ def request(self, method, uri, headers=None, bodyProducer=None): (method, uri) )['p'] digest_params_from_cache['cached'] = True - digest_authentication_header = self._build_digest_authentication_header( - digest_params_from_cache['path'], - digest_params_from_cache['method'], - digest_params_from_cache['cached'], - digest_params_from_cache['nonce'], - digest_params_from_cache['realm'], - qop=digest_params_from_cache['qop'], - algorithm=digest_params_from_cache['algorithm'], - opaque=digest_params_from_cache['opaque'] - ) + digest_authentication_header = \ + self._build_digest_authentication_header( + digest_params_from_cache['path'], + digest_params_from_cache['method'], + digest_params_from_cache['cached'], + digest_params_from_cache['nonce'], + digest_params_from_cache['realm'], + qop=digest_params_from_cache['qop'], + algorithm=digest_params_from_cache['algorithm'], + opaque=digest_params_from_cache['opaque'] + ) d = self._perform_request( digest_authentication_header, method, uri, headers, bodyProducer diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index 94b9ce84..165ab61c 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -7,6 +7,7 @@ from treq.auth import _RequestHeaderSettingAgent, add_auth, \ UnknownAuthConfig, HTTPDigestAuth, add_digest_auth + class RequestHeaderSettingAgentTests(TestCase): def setUp(self): self.agent = mock.Mock(Agent) From 05ecf656af95a13698a6f4a29e63034635ae1785 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Thu, 11 May 2017 06:20:15 -0500 Subject: [PATCH 18/53] sha256 digest support --- src/treq/auth.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/treq/auth.py b/src/treq/auth.py index e4f45bf8..3f36feb6 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -29,6 +29,10 @@ def _sha1_utf_digest(x): return hashlib.sha1(x).hexdigest() +def _sha256_utf_digest(x): + return hashlib.sha256(x).hexdigest() + + class HTTPDigestAuth(object): """ The container for HTTP Digest authentication credentials @@ -138,6 +142,8 @@ def _build_digest_authentication_header( digest_hash_func = _md5_utf_digest elif algo == b'SHA': digest_hash_func = _sha1_utf_digest + elif algo == b'SHA-256': + digest_hash_func = _sha256_utf_digest else: raise UnknownDigestAuthAlgorithm(algo) From 03b784bedd7064c207492b87c15380e87d182c35 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Fri, 28 Sep 2018 22:44:06 -0600 Subject: [PATCH 19/53] bump httpbin version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ad21d795..ac6663c2 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ "pep8", "pyflakes", "sphinx", - "httpbin==0.5.0", + "httpbin==0.7.0", ], }, package_data={"treq": ["_version"]}, From 3515008972959dd8015d2cb0591add1b95bcf6fb Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Fri, 28 Sep 2018 22:54:58 -0600 Subject: [PATCH 20/53] Add more tests and sha512 digest support. --- src/treq/auth.py | 22 ++++++++--- src/treq/test/test_treq_integration.py | 54 +++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 3f36feb6..bfdced92 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -33,6 +33,10 @@ def _sha256_utf_digest(x): return hashlib.sha256(x).hexdigest() +def _sha512_utf_digest(x): + return hashlib.sha512(x).hexdigest() + + class HTTPDigestAuth(object): """ The container for HTTP Digest authentication credentials @@ -144,6 +148,8 @@ def _build_digest_authentication_header( digest_hash_func = _sha1_utf_digest elif algo == b'SHA-256': digest_hash_func = _sha256_utf_digest + elif algo == b'SHA-512': + digest_hash_func = _sha512_utf_digest else: raise UnknownDigestAuthAlgorithm(algo) @@ -263,12 +269,16 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, digest_authentication_params = { k.encode('utf8'): v.encode('utf8') for k, v in digest_authentication_params_str.items()} - if digest_authentication_params.get(b'qop', None) is not None and \ - digest_authentication_params[b'qop'] != b'auth' and \ - b'auth' not in \ - digest_authentication_params[b'qop'].split(b','): + if digest_authentication_params.get(b'qop', None) == b'auth': + qop = digest_authentication_params[b'qop'] + elif b'auth' in digest_authentication_params.get(b'qop', None).\ + split(b','): + qop = b'auth' + else: # We support only "auth" QoP as defined in rfc-2617 or rfc-2069 - raise UnknownQopForDigestAuth(digest_authentication_params[b'qop']) + raise UnknownQopForDigestAuth(digest_authentication_params. + get(b'qop', None)) + digest_authentication_header = \ self._build_digest_authentication_header( uri, @@ -276,7 +286,7 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, False, digest_authentication_params[b'nonce'], digest_authentication_params[b'realm'], - qop=digest_authentication_params[b'qop'], + qop=qop, algorithm=digest_authentication_params.get(b'algorithm', b'MD5') ) diff --git a/src/treq/test/test_treq_integration.py b/src/treq/test/test_treq_integration.py index 7309a75e..44c83b8e 100644 --- a/src/treq/test/test_treq_integration.py +++ b/src/treq/test/test_treq_integration.py @@ -18,7 +18,7 @@ from .local_httpbin.parent import _HTTPBinProcess import treq -from treq.auth import HTTPDigestAuth +from treq.auth import HTTPDigestAuth, UnknownQopForDigestAuth skip = skip_on_windows_because_of_199() @@ -256,6 +256,20 @@ def test_digest_auth(self): self.assertTrue(json['authenticated']) self.assertEqual(json['user'], 'treq') + @inlineCallbacks + def test_digest_auth_multi_qop(self): + """ + Test successful Digest authentication with multiple qop types + :return: + """ + response = yield self.get('/digest-auth/undefined/treq/treq', + auth=HTTPDigestAuth('treq', 'treq')) + self.assertEqual(response.code, 200) + yield print_response(response) + json = yield treq.json_content(response) + self.assertTrue(json['authenticated']) + self.assertEqual(json['user'], 'treq') + @inlineCallbacks def test_digest_auth_multiple_calls(self): """ @@ -328,6 +342,34 @@ def agent_request_patched(*args, **kwargs): ) patcher.restore() + @inlineCallbacks + def test_digest_auth_sha256(self): + """ + Test successful Digest authentication with sha256 + :return: + """ + response = yield self.get('/digest-auth/auth/treq/treq/SHA-256', + auth=HTTPDigestAuth('treq', 'treq')) + self.assertEqual(response.code, 200) + yield print_response(response) + json = yield treq.json_content(response) + self.assertTrue(json['authenticated']) + self.assertEqual(json['user'], 'treq') + + @inlineCallbacks + def test_digest_auth_sha512(self): + """ + Test successful Digest authentication with sha512 + :return: + """ + response = yield self.get('/digest-auth/auth/treq/treq/SHA-512', + auth=HTTPDigestAuth('treq', 'treq')) + self.assertEqual(response.code, 200) + yield print_response(response) + json = yield treq.json_content(response) + self.assertTrue(json['authenticated']) + self.assertEqual(json['user'], 'treq') + @inlineCallbacks def test_failed_digest_auth(self): """ @@ -338,6 +380,16 @@ def test_failed_digest_auth(self): self.assertEqual(response.code, 401) yield print_response(response) + @inlineCallbacks + def test_failed_digest_auth_int(self): + """ + Test failed Digest authentication when qop type is unsupported + :return: + """ + with self.assertRaises(UnknownQopForDigestAuth): + yield self.get('/digest-auth/auth-int/treq/treq', + auth=HTTPDigestAuth('treq', 'treq')) + @inlineCallbacks def test_timeout(self): """ From e0d7208e31dd9019c85eb99697f2eba5b9b0b233 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Fri, 28 Sep 2018 23:21:39 -0600 Subject: [PATCH 21/53] Set opaque header for digest auth --- src/treq/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index bfdced92..1a24fd4f 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -288,7 +288,8 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, digest_authentication_params[b'realm'], qop=qop, algorithm=digest_authentication_params.get(b'algorithm', - b'MD5') + b'MD5'), + opaque=digest_authentication_params.get(b'opaque', None) ) return self._perform_request( digest_authentication_header, method, uri, headers, bodyProducer From f8c5e7f02b412f0b909e59c6d95cfcd3d2b60ee5 Mon Sep 17 00:00:00 2001 From: mksh Date: Fri, 7 Feb 2020 20:50:06 +0200 Subject: [PATCH 22/53] Make treq.auth.generate_client_nonce() private --- src/treq/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 1a24fd4f..a6e3eddb 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -13,7 +13,7 @@ _DIGEST_HEADER_PREFIX_REGEXP = re.compile(b'digest ', flags=re.IGNORECASE) -def generate_client_nonce(server_side_nonce): +def _generate_client_nonce(server_side_nonce): return hashlib.sha1( hashlib.sha1(server_side_nonce).digest() + secureRandom(16) + @@ -156,7 +156,7 @@ def _build_digest_authentication_header( ha1 = digest_hash_func(a1) ha2 = digest_hash_func(a2) - cnonce = generate_client_nonce(nonce) + cnonce = _generate_client_nonce(nonce) if algo == b'MD5-SESS': sess = ha1.encode('utf-8') From dcc772d7837054f2f7c5d578027cfe43991a7b50 Mon Sep 17 00:00:00 2001 From: mksh Date: Fri, 7 Feb 2020 22:06:13 +0200 Subject: [PATCH 23/53] Refactor digest auth parameter caching, add unit tests --- src/treq/auth.py | 148 +++++++++++++------------ src/treq/test/test_auth.py | 100 ++++++++++++++++- src/treq/test/test_treq_integration.py | 6 +- 3 files changed, 176 insertions(+), 78 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index a6e3eddb..da5cae5e 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -39,65 +39,20 @@ def _sha512_utf_digest(x): class HTTPDigestAuth(object): """ - The container for HTTP Digest authentication credentials + The container for HTTP Digest authentication credentials. + + This container will cache digest auth parameters, + in order not to recompute these for each request. """ def __init__(self, username, password): - self.username = username - self.password = password - - -class UnknownAuthConfig(Exception): - def __init__(self, config): - super(Exception, self).__init__( - '{0!r} not of a known type.'.format(config)) - - -class UnknownQopForDigestAuth(Exception): - - def __init__(self, qop): - super(Exception, self).__init__( - 'Unsupported Quality Of Protection value passed: {qop}'.format( - qop=qop - ) - ) - - -class UnknownDigestAuthAlgorithm(Exception): - - def __init__(self, algorithm): - super(Exception, self).__init__( - 'Unsupported Digest Auth algorithm identifier passed: {algorithm}' - .format(algorithm=algorithm) - ) - - -class _RequestHeaderSettingAgent(object): - def __init__(self, agent, request_headers): - self._agent = agent - self._request_headers = request_headers - - def request(self, method, uri, headers=None, bodyProducer=None): - if headers is None: - headers = self._request_headers - else: - for header, values in self._request_headers.getAllRawHeaders(): - headers.setRawHeaders(header, values) - - return self._agent.request( - method, uri, headers=headers, bodyProducer=bodyProducer) - - -class _RequestDigestAuthenticationAgent(object): - - digest_auth_cache = {} - - def __init__(self, agent, username, password): - self._agent = agent self._username = username.encode('utf-8') self._password = password.encode('utf-8') - def _build_digest_authentication_header( + # (method,uri) --> digest auth cache + self._digest_auth_cache = {} + + def build_authentication_header( self, path, method, cached, nonce, realm, qop=None, algorithm=b'MD5', opaque=None ): @@ -130,7 +85,7 @@ def _build_digest_authentication_header( actual_path = path_parsed.path if path_parsed.query: - actual_path += '?' + path_parsed.query + actual_path += b'?' + path_parsed.query a1 = self._username a1 += b':' @@ -167,10 +122,8 @@ def _build_digest_authentication_header( ha1 = digest_hash_func(sess) if cached: - self.digest_auth_cache[(method, path)]['c'] += 1 - nonce_count = self.digest_auth_cache[ - (method, path) - ]['c'] + self._digest_auth_cache[(method, path)]['c'] += 1 + nonce_count = self._digest_auth_cache[(method, path)]['c'] else: nonce_count = 1 @@ -178,7 +131,7 @@ def _build_digest_authentication_header( if qop is None: rd = ha1.encode('utf-8') rd += b':' - rd += ha2 + rd += ha2.encode('utf-8') rd += b':' rd += nonce response_digest = digest_hash_func(rd).encode('utf-8') @@ -231,7 +184,7 @@ def _build_digest_authentication_header( 'algorithm': algorithm, 'opaque': opaque } - self.digest_auth_cache[(method, path)] = { + self._digest_auth_cache[(method, path)] = { 'p': cache_params, 'c': 1 } @@ -239,6 +192,57 @@ def _build_digest_authentication_header( digest_res += hb return digest_res + def cached_metadata_for(self, method, uri): + return self._digest_auth_cache.get((method, uri), None) + + +class UnknownAuthConfig(Exception): + def __init__(self, config): + super(Exception, self).__init__( + '{0!r} not of a known type.'.format(config)) + + +class UnknownQopForDigestAuth(Exception): + + def __init__(self, qop): + super(Exception, self).__init__( + 'Unsupported Quality Of Protection value passed: {qop}'.format( + qop=qop + ) + ) + + +class UnknownDigestAuthAlgorithm(Exception): + + def __init__(self, algorithm): + super(Exception, self).__init__( + 'Unsupported Digest Auth algorithm identifier passed: {algorithm}' + .format(algorithm=algorithm) + ) + + +class _RequestHeaderSettingAgent(object): + def __init__(self, agent, request_headers): + self._agent = agent + self._request_headers = request_headers + + def request(self, method, uri, headers=None, bodyProducer=None): + if headers is None: + headers = self._request_headers + else: + for header, values in self._request_headers.getAllRawHeaders(): + headers.setRawHeaders(header, values) + + return self._agent.request( + method, uri, headers=headers, bodyProducer=bodyProducer) + + +class _RequestDigestAuthenticationAgent(object): + + def __init__(self, agent, auth): + self._agent = agent + self._auth = auth + def _on_401_response(self, www_authenticate_response, method, uri, headers, bodyProducer): """ @@ -263,12 +267,11 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, digest_header = _DIGEST_HEADER_PREFIX_REGEXP.sub( b'', www_authenticate_header_string, count=1 ) - digest_authentication_params_str = parse_dict_header( - digest_header.decode("utf-8") - ) digest_authentication_params = { k.encode('utf8'): v.encode('utf8') - for k, v in digest_authentication_params_str.items()} + for k, v in + parse_dict_header(digest_header.decode("utf-8")).items()} + if digest_authentication_params.get(b'qop', None) == b'auth': qop = digest_authentication_params[b'qop'] elif b'auth' in digest_authentication_params.get(b'qop', None).\ @@ -280,7 +283,7 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, get(b'qop', None)) digest_authentication_header = \ - self._build_digest_authentication_header( + self._auth.build_authentication_header( uri, method, False, @@ -328,7 +331,10 @@ def request(self, method, uri, headers=None, bodyProducer=None): used to fetch the response body :return: t.i.defer.Deferred (holding the result of the request) """ - if self.digest_auth_cache.get((method, uri)) is None: + + digest_auth_metadata = self._auth.cached_metadata_for(method, uri) + + if digest_auth_metadata is None: # Perform first request for getting the realm; # the client awaits for 401 response code here d = self._agent.request(b'GET', uri, @@ -337,12 +343,10 @@ def request(self, method, uri, headers=None, bodyProducer=None): headers, bodyProducer) else: # We have performed authentication on that URI already - digest_params_from_cache = self.digest_auth_cache.get( - (method, uri) - )['p'] + digest_params_from_cache = digest_auth_metadata['p'] digest_params_from_cache['cached'] = True digest_authentication_header = \ - self._build_digest_authentication_header( + self._auth.build_authentication_header( digest_params_from_cache['path'], digest_params_from_cache['method'], digest_params_from_cache['cached'], @@ -368,9 +372,7 @@ def add_basic_auth(agent, username, password): def add_digest_auth(agent, http_digest_auth): - return _RequestDigestAuthenticationAgent( - agent, http_digest_auth.username, http_digest_auth.password - ) + return _RequestDigestAuthenticationAgent(agent, http_digest_auth) def add_auth(agent, auth_config): diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index 165ab61c..d611fb5e 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -5,7 +5,7 @@ from twisted.web.http_headers import Headers from treq.auth import _RequestHeaderSettingAgent, add_auth, \ - UnknownAuthConfig, HTTPDigestAuth, add_digest_auth + UnknownAuthConfig, HTTPDigestAuth, UnknownDigestAuthAlgorithm, add_digest_auth class RequestHeaderSettingAgentTests(TestCase): @@ -80,13 +80,107 @@ def test_add_digest_auth(self): agent = mock.Mock() username = 'spam' password = 'eggs' + auth = HTTPDigestAuth(username, password) - add_digest_auth(agent, HTTPDigestAuth(username, password)) + add_digest_auth(agent, auth) self._RequestDigestAuthenticationAgent.assert_called_once_with( - agent, username, password + agent, auth ) def test_add_unknown_auth(self): agent = mock.Mock() self.assertRaises(UnknownAuthConfig, add_auth, agent, mock.Mock()) + + + +class HttpDigestAuthTests(TestCase): + + def setUp(self): + self._auth = HTTPDigestAuth('spam', 'eggs') + + def test_build_authentication_header_unknown_alforythm(self): + self.assertRaises(UnknownDigestAuthAlgorithm, self._auth.build_authentication_header, + b'/spam/eggs', b'GET', False, + b'b7f36bc385a662ed615f27bd9e94eecd', + b'me@dragons', qop=None, + algorithm=b'UNKNOWN') + + def test_build_authentication_header_md5_no_cache_no_qop(self): + auth_header = self._auth.build_authentication_header( + b'/spam/eggs', b'GET', False, + b'b7f36bc385a662ed615f27bd9e94eecd', + b'me@dragons', qop=None, + algorithm=b'MD5' + ) + self.assertEquals( + auth_header, + b'Digest username="spam", realm="me@dragons", ' + + b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + + b'uri="/spam/eggs", ' + + b'response="fc05d17c55156b278132a52dc0dca526", algorithm="MD5"', + ) + + def test_build_authentication_header_md5_sess_no_cache(self): + auth_header = self._auth.build_authentication_header( + b'/spam/eggs?ham=bacon', b'GET', False, + b'b7f36bc385a662ed615f27bd9e94eecd', + b'me@dragons', qop='auth', + algorithm=b'MD5-SESS' + ) + self.assertRegex( + auth_header, + b'Digest username="spam", realm="me@dragons", ' + + b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + + b'uri="/spam/eggs\?ham=bacon", ' + + b'response="([0-9a-f]{32})", ' + + b'algorithm="MD5-SESS", qop="auth", ' + + b'nc=00000001, cnonce="([0-9a-f]{16})"', + ) + + def test_build_authentication_header_sha_no_cache_no_qop(self): + auth_header = self._auth.build_authentication_header( + b'/spam/eggs', b'GET', False, + b'b7f36bc385a662ed615f27bd9e94eecd', + b'me@dragons', qop=None, + algorithm=b'SHA' + ) + + self.assertEquals( + auth_header, + b'Digest username="spam", realm="me@dragons", ' + + b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + + b'uri="/spam/eggs", ' + + b'response="45420a4786287998bcb99dfde563c3a198109b31", ' + + b'algorithm="SHA"' + ) + + + def test_build_authentication_header_sha512_cache(self): + # Emulate 1st request + self._auth.build_authentication_header( + b'/spam/eggs', b'GET', False, + b'b7f36bc385a662ed615f27bd9e94eecd', + b'me@dragons', qop='auth', + algorithm=b'SHA-512' + ) + # Get header after cached request + auth_header = self._auth.build_authentication_header( + b'/spam/eggs', b'GET', True, + b'b7f36bc385a662ed615f27bd9e94eecd', + b'me@dragons', qop='auth', + algorithm=b'SHA-512' + ) + + # Make sure metadata was cached + self.assertTrue(self._auth.cached_metadata_for(b'GET', b'/spam/eggs')) + + self.assertRegex( + auth_header, + b'Digest username="spam", realm="me@dragons", ' + + b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + + b'uri="/spam/eggs", ' + + b'response="([0-9a-f]{128})", ' + + b'algorithm="SHA-512", qop="auth", ' + + b'nc=00000002, cnonce="([0-9a-f]+?)"', + ) diff --git a/src/treq/test/test_treq_integration.py b/src/treq/test/test_treq_integration.py index 44c83b8e..cef9587e 100644 --- a/src/treq/test/test_treq_integration.py +++ b/src/treq/test/test_treq_integration.py @@ -301,9 +301,11 @@ def agent_request_patched(*args, **kwargs): ) patcher.patch() + auth = HTTPDigestAuth('treq-digest-auth-multiple', 'treq') + response1 = yield self.get( '/digest-auth/auth/treq-digest-auth-multiple/treq', - auth=HTTPDigestAuth('treq-digest-auth-multiple', 'treq') + auth=auth ) self.assertEqual(response1.code, 200) yield print_response(response1) @@ -323,7 +325,7 @@ def agent_request_patched(*args, **kwargs): response2 = yield self.get( '/digest-auth/auth/treq-digest-auth-multiple/treq', - auth=HTTPDigestAuth('treq-digest-auth-multiple', 'treq'), + auth=auth, cookies=response1.cookies() ) self.assertEqual(response2.code, 200) From f1e322254ce307843f7f7959bec20f000e8fc450 Mon Sep 17 00:00:00 2001 From: mksh Date: Fri, 7 Feb 2020 22:14:52 +0200 Subject: [PATCH 24/53] None is default ret value of .get() --- src/treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index da5cae5e..fa103e04 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -193,7 +193,7 @@ def build_authentication_header( return digest_res def cached_metadata_for(self, method, uri): - return self._digest_auth_cache.get((method, uri), None) + return self._digest_auth_cache.get((method, uri)) class UnknownAuthConfig(Exception): From 00af1b7392bc877918e7241e33b559a42d7bf50e Mon Sep 17 00:00:00 2001 From: mksh Date: Fri, 7 Feb 2020 22:27:11 +0200 Subject: [PATCH 25/53] Fix flakes for test_auth.py --- src/treq/test/test_auth.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index d611fb5e..1557a2ac 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -5,7 +5,8 @@ from twisted.web.http_headers import Headers from treq.auth import _RequestHeaderSettingAgent, add_auth, \ - UnknownAuthConfig, HTTPDigestAuth, UnknownDigestAuthAlgorithm, add_digest_auth + UnknownAuthConfig, HTTPDigestAuth, UnknownDigestAuthAlgorithm, \ + add_digest_auth class RequestHeaderSettingAgentTests(TestCase): @@ -93,14 +94,14 @@ def test_add_unknown_auth(self): self.assertRaises(UnknownAuthConfig, add_auth, agent, mock.Mock()) - class HttpDigestAuthTests(TestCase): def setUp(self): self._auth = HTTPDigestAuth('spam', 'eggs') def test_build_authentication_header_unknown_alforythm(self): - self.assertRaises(UnknownDigestAuthAlgorithm, self._auth.build_authentication_header, + self.assertRaises( + UnknownDigestAuthAlgorithm, self._auth.build_authentication_header, b'/spam/eggs', b'GET', False, b'b7f36bc385a662ed615f27bd9e94eecd', b'me@dragons', qop=None, @@ -132,7 +133,7 @@ def test_build_authentication_header_md5_sess_no_cache(self): auth_header, b'Digest username="spam", realm="me@dragons", ' + b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + - b'uri="/spam/eggs\?ham=bacon", ' + + b'uri="/spam/eggs\\?ham=bacon", ' + b'response="([0-9a-f]{32})", ' + b'algorithm="MD5-SESS", qop="auth", ' + b'nc=00000001, cnonce="([0-9a-f]{16})"', @@ -155,7 +156,6 @@ def test_build_authentication_header_sha_no_cache_no_qop(self): b'algorithm="SHA"' ) - def test_build_authentication_header_sha512_cache(self): # Emulate 1st request self._auth.build_authentication_header( From ec3e5c90f720eaf09b65182680efc7fbe74c2d04 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Tue, 22 Nov 2022 21:04:53 -0400 Subject: [PATCH 26/53] Use urlparse from urllib instead of six --- src/treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 1a537e5d..4e15c03a 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -7,8 +7,8 @@ import binascii from typing import Union +from urllib.parse import urlparse -from six.moves.urllib.parse import urlparse from twisted.python.randbytes import secureRandom from twisted.web.http_headers import Headers from twisted.web.iweb import IAgent From 259a059a91f65f31e33e54d2843b197b4c65d095 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Tue, 22 Nov 2022 22:21:54 -0400 Subject: [PATCH 27/53] Add type annotations --- src/treq/auth.py | 64 +++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 4e15c03a..e52e856b 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -6,12 +6,12 @@ import hashlib import binascii -from typing import Union +from typing import Union, Optional from urllib.parse import urlparse from twisted.python.randbytes import secureRandom from twisted.web.http_headers import Headers -from twisted.web.iweb import IAgent +from twisted.web.iweb import IAgent, IBodyProducer, IResponse from zope.interface import implementer from requests.utils import parse_dict_header @@ -19,7 +19,7 @@ _DIGEST_HEADER_PREFIX_REGEXP = re.compile(b'digest ', flags=re.IGNORECASE) -def _generate_client_nonce(server_side_nonce): +def _generate_client_nonce(server_side_nonce: bytes) -> str: return hashlib.sha1( hashlib.sha1(server_side_nonce).digest() + secureRandom(16) + @@ -27,19 +27,19 @@ def _generate_client_nonce(server_side_nonce): ).hexdigest()[:16] -def _md5_utf_digest(x): +def _md5_utf_digest(x: bytes) -> str: return hashlib.md5(x).hexdigest() -def _sha1_utf_digest(x): +def _sha1_utf_digest(x: bytes) -> str: return hashlib.sha1(x).hexdigest() -def _sha256_utf_digest(x): +def _sha256_utf_digest(x: bytes) -> str: return hashlib.sha256(x).hexdigest() -def _sha512_utf_digest(x): +def _sha512_utf_digest(x: bytes) -> str: return hashlib.sha512(x).hexdigest() @@ -51,17 +51,25 @@ class HTTPDigestAuth(object): in order not to recompute these for each request. """ - def __init__(self, username, password): - self._username = username.encode('utf-8') - self._password = password.encode('utf-8') + def __init__(self, username: Union[str, bytes], + password: Union[str, bytes]): + if not isinstance(username, bytes): + self._username = username.encode('utf-8') + else: + self._username = username + if not isinstance(password, bytes): + self._password = password.encode('utf-8') + else: + self._password = password # (method,uri) --> digest auth cache self._digest_auth_cache = {} def build_authentication_header( - self, path, method, cached, nonce, realm, qop=None, - algorithm=b'MD5', opaque=None - ): + self, path: bytes, method: bytes, cached: bool, nonce: bytes, + realm: bytes, qop: Optional[bytes] = None, + algorithm: bytes = b'MD5', opaque: Optional[bytes] = None + ) -> bytes: """ Build the authorization header for credentials got from the server. Algorithm is accurately ported from http://python-requests.org @@ -198,7 +206,7 @@ def build_authentication_header( digest_res += hb return digest_res - def cached_metadata_for(self, method, uri): + def cached_metadata_for(self, method: bytes, uri: bytes) -> Optional[dict]: return self._digest_auth_cache.get((method, uri)) @@ -213,7 +221,7 @@ def __init__(self, config): class UnknownQopForDigestAuth(Exception): - def __init__(self, qop): + def __init__(self, qop: Optional[bytes]): super(Exception, self).__init__( 'Unsupported Quality Of Protection value passed: {qop}'.format( qop=qop @@ -223,7 +231,7 @@ def __init__(self, qop): class UnknownDigestAuthAlgorithm(Exception): - def __init__(self, algorithm): + def __init__(self, algorithm: Optional[bytes]): super(Exception, self).__init__( 'Unsupported Digest Auth algorithm identifier passed: {algorithm}' .format(algorithm=algorithm) @@ -257,14 +265,17 @@ def request(self, method, uri, headers=None, bodyProducer=None): method, uri, headers=requestHeaders, bodyProducer=bodyProducer) -class _RequestDigestAuthenticationAgent(object): +@implementer(IAgent) +class _RequestDigestAuthenticationAgent: - def __init__(self, agent, auth): + def __init__(self, agent: IAgent, auth: HTTPDigestAuth): self._agent = agent self._auth = auth - def _on_401_response(self, www_authenticate_response, method, uri, headers, - bodyProducer): + def _on_401_response(self, www_authenticate_response: IResponse, + method: bytes, uri: bytes, + headers: Optional[Headers], + bodyProducer: Optional[IBodyProducer]): """ Handle the server`s 401 response, that is capable with authentication headers, build the Authorization header @@ -318,8 +329,9 @@ def _on_401_response(self, www_authenticate_response, method, uri, headers, digest_authentication_header, method, uri, headers, bodyProducer ) - def _perform_request(self, digest_authentication_header, method, uri, - headers, bodyProducer): + def _perform_request(self, digest_authentication_header: bytes, + method: bytes, uri: bytes, headers: Optional[Headers], + bodyProducer: Optional[IBodyProducer]): """ Add Authorization header and perform the request with actual credentials @@ -341,7 +353,9 @@ def _perform_request(self, digest_authentication_header, method, uri, method, uri, headers=headers, bodyProducer=bodyProducer ) - def request(self, method, uri, headers=None, bodyProducer=None): + def request(self, method: bytes, uri: bytes, + headers: Optional[Headers] = None, + bodyProducer: Optional[IBodyProducer] = None): """ Wrap the agent with HTTP Digest authentication. :param method: HTTP method to be used to perform the request @@ -415,11 +429,11 @@ def add_basic_auth(agent, username, password): ) -def add_digest_auth(agent, http_digest_auth): +def add_digest_auth(agent: IAgent, http_digest_auth: HTTPDigestAuth) -> IAgent: return _RequestDigestAuthenticationAgent(agent, http_digest_auth) -def add_auth(agent, auth_config): +def add_auth(agent: IAgent, auth_config: Union[tuple, HTTPDigestAuth]): """ Wrap an agent to perform authentication From c5a6e86eef0ff7af0317d249f6360cd909b8afbb Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Wed, 23 Nov 2022 09:29:17 -0400 Subject: [PATCH 28/53] Fix digest auth comment links --- src/treq/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index e52e856b..f11b9d8b 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -72,10 +72,10 @@ def build_authentication_header( ) -> bytes: """ Build the authorization header for credentials got from the server. - Algorithm is accurately ported from http://python-requests.org + Algorithm is accurately ported from https://github.com/psf/requests with small adjustments. See - https://github.com/kennethreitz/requests/blob/v2.5.1/requests/auth.py#L72 + https://github.com/psf/requests/blob/v2.5.1/requests/auth.py#L72 for details. :param algorithm: algorithm to be used for authentication, defaults to MD5, supported values are From f5a30eabeae622bc5fdf71546bfe4a5d903aa811 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Wed, 23 Nov 2022 09:35:47 -0400 Subject: [PATCH 29/53] Improve digest auth howto caching wording --- docs/howto.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/howto.rst b/docs/howto.rst index 4aa4f1b6..90b3addf 100644 --- a/docs/howto.rst +++ b/docs/howto.rst @@ -96,9 +96,10 @@ HTTP Digest authentication is supported by passing an instance of :py:class:`treq.auth.HTTPDigestAuth` to any of the request functions by using the `auth` keyword argument. We support only "auth" QoP as defined at `RFC 2617`_ or simple `RFC 2069`_ without QoP at the moment. Treq takes care of -HTTP digest credentials caching - after authorization on any URL/method pair, -the library will use the first time received HTTP digest credentials on that endpoint -for further requests, and will not perform any redundant requests for obtaining the creds. +caching HTTP digest credentials — after authorizing any URL/method pair, +the library will use the initially received HTTP digest credentials on that endpoint +for subsequent requests, and will not perform any redundant requests to obtain the +credentials. :py:class:`treq.auth.HTTPDigestAuth` class accepts ``username`` and ``password`` as constructor arguments. From b87b767bef308cacbd412439c5e92606849c2a59 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Wed, 23 Nov 2022 10:07:58 -0400 Subject: [PATCH 30/53] Add blank lines around param comments --- src/treq/auth.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/treq/auth.py b/src/treq/auth.py index f11b9d8b..bd78eb8b 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -77,6 +77,7 @@ def build_authentication_header( See https://github.com/psf/requests/blob/v2.5.1/requests/auth.py#L72 for details. + :param algorithm: algorithm to be used for authentication, defaults to MD5, supported values are "MD5", "MD5-SESS" and "SHA" @@ -91,6 +92,7 @@ def build_authentication_header( authenticated request :param path: the URI path where we are authenticating :param method: HTTP method to be used when requesting + :return: HTTP Digest authentication string """ algo = algorithm.upper() @@ -280,6 +282,7 @@ def _on_401_response(self, www_authenticate_response: IResponse, Handle the server`s 401 response, that is capable with authentication headers, build the Authorization header for + :param www_authenticate_response: t.w.client.Response object :param method: HTTP method to be used to perform the request :param uri: URI to be used @@ -287,6 +290,7 @@ def _on_401_response(self, www_authenticate_response: IResponse, instead of "Authorization" header :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :return: """ assert www_authenticate_response.code == 401, \ @@ -335,6 +339,7 @@ def _perform_request(self, digest_authentication_header: bytes, """ Add Authorization header and perform the request with actual credentials + :param digest_authentication_header: HTTP Digest Authorization header string :param method: HTTP method to be used to perform the request @@ -342,6 +347,7 @@ def _perform_request(self, digest_authentication_header: bytes, :param headers: Headers to be sent with the request :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :return: t.i.defer.Deferred (holding the result of the request) """ if not headers: @@ -358,11 +364,13 @@ def request(self, method: bytes, uri: bytes, bodyProducer: Optional[IBodyProducer] = None): """ Wrap the agent with HTTP Digest authentication. + :param method: HTTP method to be used to perform the request :param uri: URI to be used :param headers: Headers to be sent with the request :param bodyProducer: IBodyProducer implementer instance that would be used to fetch the response body + :return: t.i.defer.Deferred (holding the result of the request) """ From d01bd08892d2e84b041fe5d7f9adfb06233a2a91 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Wed, 23 Nov 2022 10:32:02 -0400 Subject: [PATCH 31/53] Don't hardcode qop as auth --- src/treq/auth.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index bd78eb8b..63be7db2 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -67,7 +67,7 @@ def __init__(self, username: Union[str, bytes], def build_authentication_header( self, path: bytes, method: bytes, cached: bool, nonce: bytes, - realm: bytes, qop: Optional[bytes] = None, + realm: bytes, qop: Optional[Union[str, bytes]] = None, algorithm: bytes = b'MD5', opaque: Optional[bytes] = None ) -> bytes: """ @@ -160,7 +160,10 @@ def build_authentication_header( rd += b':' rd += cnonce.encode('utf-8') rd += b':' - rd += b'auth' + if not isinstance(qop, bytes): + rd += qop.encode('utf-8') + else: + rd += qop rd += b':' rd += ha2.encode('utf-8') response_digest = digest_hash_func(rd).encode('utf-8') @@ -184,7 +187,12 @@ def build_authentication_header( hb += original_algo hb += b'"' if qop: - hb += b', qop="auth", nc=' + hb += b', qop="' + if not isinstance(qop, bytes): + hb += qop.encode('utf-8') + else: + hb += qop + hb += b'", nc=' hb += ncvalue.encode('utf-8') hb += b', cnonce="' hb += cnonce.encode('utf-8') From 73a68d3be41531c130012f1cfd7a0871f6090fee Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Wed, 23 Nov 2022 10:56:00 -0400 Subject: [PATCH 32/53] Use agent_spy instead of mock in test_add_digest_auth. --- src/treq/test/test_auth.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index e47a452d..4e79ed23 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -1,15 +1,12 @@ # Copyright (c) The treq Authors. # See LICENSE for details. -from unittest import mock - from twisted.trial.unittest import SynchronousTestCase from twisted.web.http_headers import Headers from twisted.web.iweb import IAgent from treq._agentspy import agent_spy from treq.auth import _RequestHeaderSetterAgent, add_auth, \ - UnknownAuthConfig, HTTPDigestAuth, UnknownDigestAuthAlgorithm, \ - add_digest_auth + UnknownAuthConfig, HTTPDigestAuth, UnknownDigestAuthAlgorithm class RequestHeaderSetterAgentTests(SynchronousTestCase): @@ -63,13 +60,6 @@ def test_no_mutation(self): class AddAuthTests(SynchronousTestCase): - def setUp(self): - self.rdaa_patcher = mock.patch( - 'treq.auth._RequestDigestAuthenticationAgent' - ) - self._RequestDigestAuthenticationAgent = self.rdaa_patcher.start() - self.addCleanup(self.rdaa_patcher.stop) - def test_add_basic_auth(self): """ add_auth() wraps the given agent with one that adds an ``Authorization: @@ -142,15 +132,25 @@ def test_add_basic_auth_bytes(self): ) def test_add_digest_auth(self): - agent = mock.Mock() + agent, requests = agent_spy() username = 'spam' password = 'eggs' auth = HTTPDigestAuth(username, password) + authAgent = add_auth(agent, auth) - add_digest_auth(agent, auth) + authAgent.request(b'method', b'uri') - self._RequestDigestAuthenticationAgent.assert_called_once_with( - agent, auth + self.assertEqual( + authAgent._auth, + auth, + ) + self.assertEqual( + authAgent._auth._username, + username.encode('utf-8'), + ) + self.assertEqual( + authAgent._auth._password, + password.encode('utf-8'), ) def test_add_unknown_auth(self): From b1ade85d83eaee9cc2964cd06081dd165452577f Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Wed, 23 Nov 2022 11:41:14 -0400 Subject: [PATCH 33/53] Use self.patch instead of MonkeyPatcher --- src/treq/test/test_treq_integration.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/treq/test/test_treq_integration.py b/src/treq/test/test_treq_integration.py index 3587fcfd..ae44e241 100644 --- a/src/treq/test/test_treq_integration.py +++ b/src/treq/test/test_treq_integration.py @@ -8,7 +8,6 @@ from twisted.internet import reactor from twisted.internet.tcp import Client from twisted.internet.ssl import Certificate, trustRootFromCertificates -from twisted.python.monkey import MonkeyPatcher from twisted.web.client import (Agent, BrowserLikePolicyForHTTPS, HTTPConnectionPool, ResponseFailed) @@ -53,7 +52,7 @@ class TreqIntegrationTests(TestCase): head = with_baseurl(treq.head) post = with_baseurl(treq.post) put = with_baseurl(treq.put) - patch = with_baseurl(treq.patch) + patch_req = with_baseurl(treq.patch) delete = with_baseurl(treq.delete) _httpbin_process = _HTTPBinProcess(https=False) @@ -207,7 +206,7 @@ def test_put(self): @inlineCallbacks def test_patch(self): - response = yield self.patch('/patch', data=b'Hello!') + response = yield self.patch_req('/patch', data=b'Hello!') self.assertEqual(response.code, 200) yield self.assert_data(response, 'Hello!') yield print_response(response) @@ -289,7 +288,7 @@ def test_digest_auth_multiple_calls(self): def agent_request_patched(*args, **kwargs): """ Patched Agent.request function, - that inscreaces call count on every HTTP request + that increases call count on every HTTP request and appends """ response_deferred = agent_request_orig(*args, **kwargs) @@ -297,10 +296,7 @@ def agent_request_patched(*args, **kwargs): agent_request_call_storage['i'].append((args, kwargs)) return response_deferred - patcher = MonkeyPatcher( - (Agent, 'request', agent_request_patched) - ) - patcher.patch() + self.patch(Agent, 'request', agent_request_patched) auth = HTTPDigestAuth('treq-digest-auth-multiple', 'treq') @@ -343,7 +339,6 @@ def agent_request_patched(*args, **kwargs): agent_request_call_storage['c'], 3 ) - patcher.restore() @inlineCallbacks def test_digest_auth_sha256(self): From d9a48a611b0f02107963d2c2ca5c10587e676f74 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Wed, 23 Nov 2022 11:55:24 -0400 Subject: [PATCH 34/53] Use nonlocal for call counter variable --- src/treq/test/test_treq_integration.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/treq/test/test_treq_integration.py b/src/treq/test/test_treq_integration.py index ae44e241..74ccaf3b 100644 --- a/src/treq/test/test_treq_integration.py +++ b/src/treq/test/test_treq_integration.py @@ -276,11 +276,8 @@ def test_digest_auth_multiple_calls(self): Test proper Digest authentication credentials caching """ - # A mutable holder for call counter - agent_request_call_storage = { - 'c': 0, - 'i': [] - } + c = 0 + i = [] # Original Agent request call agent_request_orig = Agent.request @@ -291,9 +288,10 @@ def agent_request_patched(*args, **kwargs): that increases call count on every HTTP request and appends """ + nonlocal c, i response_deferred = agent_request_orig(*args, **kwargs) - agent_request_call_storage['c'] += 1 - agent_request_call_storage['i'].append((args, kwargs)) + c += 1 + i.append((args, kwargs)) return response_deferred self.patch(Agent, 'request', agent_request_patched) @@ -311,10 +309,10 @@ def agent_request_patched(*args, **kwargs): # Assume we did two actual HTTP requests - one to obtain credentials # and second is original request with authentication self.assertEqual( - agent_request_call_storage['c'], + c, 2 ) - headers_for_second_request = agent_request_call_storage['i'][1][0][3] + headers_for_second_request = i[1][0][3] self.assertIn( b'Authorization', dict(headers_for_second_request.getAllRawHeaders()) @@ -336,7 +334,7 @@ def agent_request_patched(*args, **kwargs): # Assume we need only one call to obtain second response self.assertEqual( - agent_request_call_storage['c'], + c, 3 ) From 455605d9cec400c025a355e4b8020502e784d1e7 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Wed, 23 Nov 2022 12:15:14 -0400 Subject: [PATCH 35/53] Cleanup test_digest_auth_multiple_calls nonlocal variable naming and remove nested indexing --- src/treq/test/test_treq_integration.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/treq/test/test_treq_integration.py b/src/treq/test/test_treq_integration.py index 74ccaf3b..0659ef61 100644 --- a/src/treq/test/test_treq_integration.py +++ b/src/treq/test/test_treq_integration.py @@ -1,4 +1,5 @@ from io import BytesIO +from typing import Optional from twisted.python.url import URL @@ -11,6 +12,7 @@ from twisted.web.client import (Agent, BrowserLikePolicyForHTTPS, HTTPConnectionPool, ResponseFailed) +from twisted.web.http_headers import Headers from treq.test.util import DEBUG, skip_on_windows_because_of_199 @@ -276,8 +278,8 @@ def test_digest_auth_multiple_calls(self): Test proper Digest authentication credentials caching """ - c = 0 - i = [] + calls = 0 + headers_for_second_request: Optional[Headers] = None # Original Agent request call agent_request_orig = Agent.request @@ -288,10 +290,11 @@ def agent_request_patched(*args, **kwargs): that increases call count on every HTTP request and appends """ - nonlocal c, i + nonlocal calls, headers_for_second_request response_deferred = agent_request_orig(*args, **kwargs) - c += 1 - i.append((args, kwargs)) + calls += 1 + if calls == 2: + headers_for_second_request = args[3] return response_deferred self.patch(Agent, 'request', agent_request_patched) @@ -309,10 +312,9 @@ def agent_request_patched(*args, **kwargs): # Assume we did two actual HTTP requests - one to obtain credentials # and second is original request with authentication self.assertEqual( - c, + calls, 2 ) - headers_for_second_request = i[1][0][3] self.assertIn( b'Authorization', dict(headers_for_second_request.getAllRawHeaders()) @@ -334,7 +336,7 @@ def agent_request_patched(*args, **kwargs): # Assume we need only one call to obtain second response self.assertEqual( - c, + calls, 3 ) From 12c0739c95638e3adf56b7f43ad7a4d818858f66 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Fri, 25 Nov 2022 22:31:42 -0400 Subject: [PATCH 36/53] Make cached_metadata_for private --- src/treq/_agentspy.py | 23 ++++++++++++----------- src/treq/auth.py | 4 ++-- src/treq/test/test_auth.py | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/treq/_agentspy.py b/src/treq/_agentspy.py index 42475bab..c52f506d 100644 --- a/src/treq/_agentspy.py +++ b/src/treq/_agentspy.py @@ -5,7 +5,7 @@ import attr from twisted.internet.defer import Deferred from twisted.web.http_headers import Headers -from twisted.web.iweb import IAgent, IBodyProducer, IResponse +from twisted.web.iweb import IAgent, IBodyProducer from zope.interface import implementer @@ -21,11 +21,11 @@ class RequestRecord: :ivar deferred: The :class:`Deferred` returned by :meth:`IAgent.request` """ - method = attr.ib() # type: bytes - uri = attr.ib() # type: bytes - headers = attr.ib() # type: Optional[Headers] - bodyProducer = attr.ib() # type: Optional[IBodyProducer] - deferred = attr.ib() # type: Deferred + method: bytes = attr.ib() + uri: bytes = attr.ib() + headers: Optional[Headers] = attr.ib() + bodyProducer: Optional[IBodyProducer] = attr.ib() + deferred: Deferred = attr.ib() @implementer(IAgent) @@ -38,10 +38,12 @@ class _AgentSpy: A function called with each :class:`RequestRecord` """ - _callback = attr.ib() # type: Callable[Tuple[RequestRecord], None] + _callback: Callable[[Tuple[RequestRecord]], None] = attr.ib() - def request(self, method, uri, headers=None, bodyProducer=None): - # type: (bytes, bytes, Optional[Headers], Optional[IBodyProducer]) -> Deferred[IResponse] # noqa + def request(self, method: bytes, uri: bytes, + headers: Optional[Headers] = None, + bodyProducer: Optional[IBodyProducer] = None + ): if not isinstance(method, bytes): raise TypeError( "method must be bytes, not {!r} of type {}".format(method, type(method)) @@ -69,8 +71,7 @@ def request(self, method, uri, headers=None, bodyProducer=None): return d -def agent_spy(): - # type: () -> Tuple[IAgent, List[RequestRecord]] +def agent_spy() -> Tuple[IAgent, List[RequestRecord]]: """ Record HTTP requests made with an agent diff --git a/src/treq/auth.py b/src/treq/auth.py index 63be7db2..e07aef51 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -216,7 +216,7 @@ def build_authentication_header( digest_res += hb return digest_res - def cached_metadata_for(self, method: bytes, uri: bytes) -> Optional[dict]: + def _cached_metadata_for(self, method: bytes, uri: bytes) -> Optional[dict]: return self._digest_auth_cache.get((method, uri)) @@ -382,7 +382,7 @@ def request(self, method: bytes, uri: bytes, :return: t.i.defer.Deferred (holding the result of the request) """ - digest_auth_metadata = self._auth.cached_metadata_for(method, uri) + digest_auth_metadata = self._auth._cached_metadata_for(method, uri) if digest_auth_metadata is None: # Perform first request for getting the realm; diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index 4e79ed23..66f82192 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -243,7 +243,7 @@ def test_build_authentication_header_sha512_cache(self): ) # Make sure metadata was cached - self.assertTrue(self._auth.cached_metadata_for(b'GET', b'/spam/eggs')) + self.assertTrue(self._auth._cached_metadata_for(b'GET', b'/spam/eggs')) self.assertRegex( auth_header, From 4a8e46360737ee45a3c467530c111dcfd5e400de Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Fri, 25 Nov 2022 23:09:26 -0400 Subject: [PATCH 37/53] Fix treq integration test docstring indentation --- src/treq/test/test_treq_integration.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/treq/test/test_treq_integration.py b/src/treq/test/test_treq_integration.py index 0659ef61..2f9db9be 100644 --- a/src/treq/test/test_treq_integration.py +++ b/src/treq/test/test_treq_integration.py @@ -247,7 +247,7 @@ def test_failed_basic_auth(self): @inlineCallbacks def test_digest_auth(self): """ - Test successful Digest authentication + Test successful Digest authentication :return: """ response = yield self.get('/digest-auth/auth/treq/treq', @@ -261,7 +261,7 @@ def test_digest_auth(self): @inlineCallbacks def test_digest_auth_multi_qop(self): """ - Test successful Digest authentication with multiple qop types + Test successful Digest authentication with multiple qop types :return: """ response = yield self.get('/digest-auth/undefined/treq/treq', @@ -275,7 +275,7 @@ def test_digest_auth_multi_qop(self): @inlineCallbacks def test_digest_auth_multiple_calls(self): """ - Test proper Digest authentication credentials caching + Test proper Digest authentication credentials caching """ calls = 0 @@ -286,9 +286,9 @@ def test_digest_auth_multiple_calls(self): def agent_request_patched(*args, **kwargs): """ - Patched Agent.request function, - that increases call count on every HTTP request - and appends + Patched Agent.request function, + that increases call count on every HTTP request + and appends """ nonlocal calls, headers_for_second_request response_deferred = agent_request_orig(*args, **kwargs) @@ -343,7 +343,7 @@ def agent_request_patched(*args, **kwargs): @inlineCallbacks def test_digest_auth_sha256(self): """ - Test successful Digest authentication with sha256 + Test successful Digest authentication with sha256 :return: """ response = yield self.get('/digest-auth/auth/treq/treq/SHA-256', @@ -357,7 +357,7 @@ def test_digest_auth_sha256(self): @inlineCallbacks def test_digest_auth_sha512(self): """ - Test successful Digest authentication with sha512 + Test successful Digest authentication with sha512 :return: """ response = yield self.get('/digest-auth/auth/treq/treq/SHA-512', @@ -371,7 +371,7 @@ def test_digest_auth_sha512(self): @inlineCallbacks def test_failed_digest_auth(self): """ - Test digest auth with invalid credentials + Test digest auth with invalid credentials """ response = yield self.get('/digest-auth/auth/treq/treq', auth=HTTPDigestAuth('not-treq', 'not-treq')) @@ -381,7 +381,7 @@ def test_failed_digest_auth(self): @inlineCallbacks def test_failed_digest_auth_int(self): """ - Test failed Digest authentication when qop type is unsupported + Test failed Digest authentication when qop type is unsupported :return: """ with self.assertRaises(UnknownQopForDigestAuth): From 99a0532b584f81a21436c4aba9ddcce08e8121f9 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sat, 26 Nov 2022 11:52:20 -0400 Subject: [PATCH 38/53] Refactor build_authentication_header based on current requests fstring imp --- src/treq/auth.py | 213 ++++++++++++++----------------------- src/treq/test/test_auth.py | 85 +++++++-------- 2 files changed, 121 insertions(+), 177 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index e07aef51..c476030d 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -19,28 +19,27 @@ _DIGEST_HEADER_PREFIX_REGEXP = re.compile(b'digest ', flags=re.IGNORECASE) -def _generate_client_nonce(server_side_nonce: bytes) -> str: +def _generate_client_nonce(server_side_nonce: str) -> str: return hashlib.sha1( - hashlib.sha1(server_side_nonce).digest() + - secureRandom(16) + - time.ctime().encode('utf-8') + hashlib.sha1(server_side_nonce.encode('utf-8')).digest() + + secureRandom(16) + time.ctime().encode('utf-8') ).hexdigest()[:16] -def _md5_utf_digest(x: bytes) -> str: - return hashlib.md5(x).hexdigest() +def _md5_utf_digest(x: str) -> str: + return hashlib.md5(x.encode('utf-8')).hexdigest() -def _sha1_utf_digest(x: bytes) -> str: - return hashlib.sha1(x).hexdigest() +def _sha1_utf_digest(x: str) -> str: + return hashlib.sha1(x.encode('utf-8')).hexdigest() -def _sha256_utf_digest(x: bytes) -> str: - return hashlib.sha256(x).hexdigest() +def _sha256_utf_digest(x: str) -> str: + return hashlib.sha256(x.encode('utf-8')).hexdigest() -def _sha512_utf_digest(x: bytes) -> str: - return hashlib.sha512(x).hexdigest() +def _sha512_utf_digest(x: str) -> str: + return hashlib.sha512(x.encode('utf-8')).hexdigest() class HTTPDigestAuth(object): @@ -53,23 +52,23 @@ class HTTPDigestAuth(object): def __init__(self, username: Union[str, bytes], password: Union[str, bytes]): - if not isinstance(username, bytes): - self._username = username.encode('utf-8') + if isinstance(username, bytes): + self._username: str = username.decode('utf-8') else: - self._username = username - if not isinstance(password, bytes): - self._password = password.encode('utf-8') + self._username: str = username + if isinstance(password, bytes): + self._password: str = password.decode('utf-8') else: - self._password = password + self._password: str = password # (method,uri) --> digest auth cache self._digest_auth_cache = {} def build_authentication_header( - self, path: bytes, method: bytes, cached: bool, nonce: bytes, - realm: bytes, qop: Optional[Union[str, bytes]] = None, - algorithm: bytes = b'MD5', opaque: Optional[bytes] = None - ) -> bytes: + self, url: bytes, method: bytes, cached: bool, nonce: str, + realm: str, qop: Optional[str] = None, algorithm: str = 'MD5', + opaque: Optional[str] = None + ) -> str: """ Build the authorization header for credentials got from the server. Algorithm is accurately ported from https://github.com/psf/requests @@ -90,116 +89,71 @@ def build_authentication_header( performed for URI/method, and new request should use the same params as first authenticated request - :param path: the URI path where we are authenticating + :param url: the URI path where we are authenticating :param method: HTTP method to be used when requesting :return: HTTP Digest authentication string """ algo = algorithm.upper() - original_algo = algorithm - path_parsed = urlparse(path) - actual_path = path_parsed.path + p_parsed = urlparse(url.decode('utf-8')) + # path is request-uri defined in RFC 2616 which should not be empty + path = p_parsed.path or "/" + if p_parsed.query: + path += f"?{p_parsed.query}" - if path_parsed.query: - actual_path += b'?' + path_parsed.query + A1 = f"{self._username}:{realm}:{self._password}" + A2 = f"{method.decode('utf-8')}:{path}" - a1 = self._username - a1 += b':' - a1 += realm - a1 += b':' - a1 += self._password - - a2 = method - a2 += b':' - a2 += actual_path - - if algo == b'MD5' or algo == b'MD5-SESS': + if algo == 'MD5' or algo == 'MD5-SESS': digest_hash_func = _md5_utf_digest - elif algo == b'SHA': + elif algo == 'SHA': digest_hash_func = _sha1_utf_digest - elif algo == b'SHA-256': + elif algo == 'SHA-256': digest_hash_func = _sha256_utf_digest - elif algo == b'SHA-512': + elif algo == 'SHA-512': digest_hash_func = _sha512_utf_digest else: raise UnknownDigestAuthAlgorithm(algo) - ha1 = digest_hash_func(a1) - ha2 = digest_hash_func(a2) - - cnonce = _generate_client_nonce(nonce) + KD = lambda s, d: digest_hash_func(f"{s}:{d}") # noqa:E731 - if algo == b'MD5-SESS': - sess = ha1.encode('utf-8') - sess += b':' - sess += nonce - sess += b':' - sess += cnonce.encode('utf-8') - ha1 = digest_hash_func(sess) + HA1 = digest_hash_func(A1) + HA2 = digest_hash_func(A2) if cached: - self._digest_auth_cache[(method, path)]['c'] += 1 - nonce_count = self._digest_auth_cache[(method, path)]['c'] + self._digest_auth_cache[(method, url)]['c'] += 1 + nonce_count = self._digest_auth_cache[(method, url)]['c'] else: nonce_count = 1 ncvalue = '%08x' % nonce_count - if qop is None: - rd = ha1.encode('utf-8') - rd += b':' - rd += ha2.encode('utf-8') - rd += b':' - rd += nonce - response_digest = digest_hash_func(rd).encode('utf-8') + + cnonce = _generate_client_nonce(nonce) + if algo == 'MD5-SESS': + HA1 = digest_hash_func(f"{HA1}:{nonce}:{cnonce}") + + if not qop: + respdig = KD(HA1, f"{HA2}:{nonce}") + elif qop == "auth" or "auth" in qop.split(","): + noncebit = f"{nonce}:{ncvalue}:{cnonce}:auth:{HA2}" + respdig = KD(HA1, noncebit) else: - rd = ha1.encode('utf-8') - rd += b':' - rd += nonce - rd += b':' - rd += ncvalue.encode('utf-8') - rd += b':' - rd += cnonce.encode('utf-8') - rd += b':' - if not isinstance(qop, bytes): - rd += qop.encode('utf-8') - else: - rd += qop - rd += b':' - rd += ha2.encode('utf-8') - response_digest = digest_hash_func(rd).encode('utf-8') - hb = b'username="' - hb += self._username - hb += b'", realm="' - hb += realm - hb += b'", nonce="' - hb += nonce - hb += b'", uri="' - hb += actual_path - hb += b'", response="' - hb += response_digest - hb += b'"' + raise UnknownQopForDigestAuth(qop) + + base = ( + f'username="{self._username}", realm="{realm}", nonce="{nonce}", ' + f'uri="{path}", response="{respdig}"' + ) if opaque: - hb += b', opaque="' - hb += opaque - hb += b'"' - if original_algo: - hb += b', algorithm="' - hb += original_algo - hb += b'"' + base += f', opaque="{opaque}"' + if algorithm: + base += f', algorithm="{algorithm}"' if qop: - hb += b', qop="' - if not isinstance(qop, bytes): - hb += qop.encode('utf-8') - else: - hb += qop - hb += b'", nc=' - hb += ncvalue.encode('utf-8') - hb += b', cnonce="' - hb += cnonce.encode('utf-8') - hb += b'"' + base += f', qop="auth", nc={ncvalue}, cnonce="{cnonce}"' + if not cached: cache_params = { - 'path': path, + 'path': url, 'method': method, 'cached': cached, 'nonce': nonce, @@ -208,13 +162,12 @@ def build_authentication_header( 'algorithm': algorithm, 'opaque': opaque } - self._digest_auth_cache[(method, path)] = { + self._digest_auth_cache[(method, url)] = { 'p': cache_params, 'c': 1 } - digest_res = b'Digest ' - digest_res += hb - return digest_res + + return f"Digest {base}" def _cached_metadata_for(self, method: bytes, uri: bytes) -> Optional[dict]: return self._digest_auth_cache.get((method, uri)) @@ -231,7 +184,7 @@ def __init__(self, config): class UnknownQopForDigestAuth(Exception): - def __init__(self, qop: Optional[bytes]): + def __init__(self, qop: Optional[str]): super(Exception, self).__init__( 'Unsupported Quality Of Protection value passed: {qop}'.format( qop=qop @@ -241,7 +194,7 @@ def __init__(self, qop: Optional[bytes]): class UnknownDigestAuthAlgorithm(Exception): - def __init__(self, algorithm: Optional[bytes]): + def __init__(self, algorithm: Optional[str]): super(Exception, self).__init__( 'Unsupported Digest Auth algorithm identifier passed: {algorithm}' .format(algorithm=algorithm) @@ -310,38 +263,26 @@ def _on_401_response(self, www_authenticate_response: IResponse, digest_header = _DIGEST_HEADER_PREFIX_REGEXP.sub( b'', www_authenticate_header_string, count=1 ) - digest_authentication_params = { - k.encode('utf8'): v.encode('utf8') - for k, v in - parse_dict_header(digest_header.decode("utf-8")).items()} - - if digest_authentication_params.get(b'qop', None) == b'auth': - qop = digest_authentication_params[b'qop'] - elif b'auth' in digest_authentication_params.get(b'qop', None).\ - split(b','): - qop = b'auth' - else: - # We support only "auth" QoP as defined in rfc-2617 or rfc-2069 - raise UnknownQopForDigestAuth(digest_authentication_params. - get(b'qop', None)) + digest_authentication_params = \ + parse_dict_header(digest_header.decode("utf-8")) digest_authentication_header = \ self._auth.build_authentication_header( uri, method, False, - digest_authentication_params[b'nonce'], - digest_authentication_params[b'realm'], - qop=qop, - algorithm=digest_authentication_params.get(b'algorithm', - b'MD5'), - opaque=digest_authentication_params.get(b'opaque', None) + digest_authentication_params['nonce'], + digest_authentication_params['realm'], + qop=digest_authentication_params.get('qop', None), + algorithm=digest_authentication_params.get('algorithm', + 'MD5'), + opaque=digest_authentication_params.get('opaque', None) ) return self._perform_request( digest_authentication_header, method, uri, headers, bodyProducer ) - def _perform_request(self, digest_authentication_header: bytes, + def _perform_request(self, digest_authentication_header: str, method: bytes, uri: bytes, headers: Optional[Headers], bodyProducer: Optional[IBodyProducer]): """ @@ -359,10 +300,12 @@ def _perform_request(self, digest_authentication_header: bytes, :return: t.i.defer.Deferred (holding the result of the request) """ if not headers: - headers = Headers({b'Authorization': digest_authentication_header}) + headers = Headers( + {b'Authorization': digest_authentication_header.encode("utf-8")} + ) else: headers.addRawHeader(b'Authorization', - digest_authentication_header) + digest_authentication_header.encode("utf-8")) return self._agent.request( method, uri, headers=headers, bodyProducer=bodyProducer ) diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index 66f82192..b56444db 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -146,11 +146,11 @@ def test_add_digest_auth(self): ) self.assertEqual( authAgent._auth._username, - username.encode('utf-8'), + username, ) self.assertEqual( authAgent._auth._password, - password.encode('utf-8'), + password, ) def test_add_unknown_auth(self): @@ -167,79 +167,80 @@ def test_add_unknown_auth(self): class HttpDigestAuthTests(SynchronousTestCase): def setUp(self): + self.maxDiff = None self._auth = HTTPDigestAuth('spam', 'eggs') - def test_build_authentication_header_unknown_alforythm(self): + def test_build_authentication_header_unknown_algorythm(self): self.assertRaises( UnknownDigestAuthAlgorithm, self._auth.build_authentication_header, b'/spam/eggs', b'GET', False, - b'b7f36bc385a662ed615f27bd9e94eecd', - b'me@dragons', qop=None, - algorithm=b'UNKNOWN') + 'b7f36bc385a662ed615f27bd9e94eecd', + 'me@dragons', qop=None, + algorithm='UNKNOWN') def test_build_authentication_header_md5_no_cache_no_qop(self): auth_header = self._auth.build_authentication_header( b'/spam/eggs', b'GET', False, - b'b7f36bc385a662ed615f27bd9e94eecd', - b'me@dragons', qop=None, - algorithm=b'MD5' + 'b7f36bc385a662ed615f27bd9e94eecd', + 'me@dragons', qop=None, + algorithm='MD5' ) self.assertEquals( auth_header, - b'Digest username="spam", realm="me@dragons", ' + - b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + - b'uri="/spam/eggs", ' + - b'response="fc05d17c55156b278132a52dc0dca526", algorithm="MD5"', + 'Digest username="spam", realm="me@dragons", ' + + 'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + + 'uri="/spam/eggs", ' + + 'response="fc05d17c55156b278132a52dc0dca526", algorithm="MD5"', ) def test_build_authentication_header_md5_sess_no_cache(self): auth_header = self._auth.build_authentication_header( b'/spam/eggs?ham=bacon', b'GET', False, - b'b7f36bc385a662ed615f27bd9e94eecd', - b'me@dragons', qop='auth', - algorithm=b'MD5-SESS' + 'b7f36bc385a662ed615f27bd9e94eecd', + 'me@dragons', qop='auth', + algorithm='MD5-SESS' ) self.assertRegex( auth_header, - b'Digest username="spam", realm="me@dragons", ' + - b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + - b'uri="/spam/eggs\\?ham=bacon", ' + - b'response="([0-9a-f]{32})", ' + - b'algorithm="MD5-SESS", qop="auth", ' + - b'nc=00000001, cnonce="([0-9a-f]{16})"', + 'Digest username="spam", realm="me@dragons", ' + + 'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + + 'uri="/spam/eggs\\?ham=bacon", ' + + 'response="([0-9a-f]{32})", ' + + 'algorithm="MD5-SESS", qop="auth", ' + + 'nc=00000001, cnonce="([0-9a-f]{16})"', ) def test_build_authentication_header_sha_no_cache_no_qop(self): auth_header = self._auth.build_authentication_header( b'/spam/eggs', b'GET', False, - b'b7f36bc385a662ed615f27bd9e94eecd', - b'me@dragons', qop=None, - algorithm=b'SHA' + 'b7f36bc385a662ed615f27bd9e94eecd', + 'me@dragons', qop=None, + algorithm='SHA' ) self.assertEquals( auth_header, - b'Digest username="spam", realm="me@dragons", ' + - b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + - b'uri="/spam/eggs", ' + - b'response="45420a4786287998bcb99dfde563c3a198109b31", ' + - b'algorithm="SHA"' + 'Digest username="spam", realm="me@dragons", ' + + 'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + + 'uri="/spam/eggs", ' + + 'response="45420a4786287998bcb99dfde563c3a198109b31", ' + + 'algorithm="SHA"' ) def test_build_authentication_header_sha512_cache(self): # Emulate 1st request self._auth.build_authentication_header( b'/spam/eggs', b'GET', False, - b'b7f36bc385a662ed615f27bd9e94eecd', - b'me@dragons', qop='auth', - algorithm=b'SHA-512' + 'b7f36bc385a662ed615f27bd9e94eecd', + 'me@dragons', qop='auth', + algorithm='SHA-512' ) # Get header after cached request auth_header = self._auth.build_authentication_header( b'/spam/eggs', b'GET', True, - b'b7f36bc385a662ed615f27bd9e94eecd', - b'me@dragons', qop='auth', - algorithm=b'SHA-512' + 'b7f36bc385a662ed615f27bd9e94eecd', + 'me@dragons', qop='auth', + algorithm='SHA-512' ) # Make sure metadata was cached @@ -247,10 +248,10 @@ def test_build_authentication_header_sha512_cache(self): self.assertRegex( auth_header, - b'Digest username="spam", realm="me@dragons", ' + - b'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + - b'uri="/spam/eggs", ' + - b'response="([0-9a-f]{128})", ' + - b'algorithm="SHA-512", qop="auth", ' + - b'nc=00000002, cnonce="([0-9a-f]+?)"', + 'Digest username="spam", realm="me@dragons", ' + + 'nonce="b7f36bc385a662ed615f27bd9e94eecd", ' + + 'uri="/spam/eggs", ' + + 'response="([0-9a-f]{128})", ' + + 'algorithm="SHA-512", qop="auth", ' + + 'nc=00000002, cnonce="([0-9a-f]+?)"', ) From 5269ff1d3d2e9334cf8a72bff313fe4c5ce4017e Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 27 Nov 2022 12:02:36 -0400 Subject: [PATCH 39/53] Make build_authentication_header private --- src/treq/auth.py | 6 +++--- src/treq/test/test_auth.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index c476030d..282b157c 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -64,7 +64,7 @@ def __init__(self, username: Union[str, bytes], # (method,uri) --> digest auth cache self._digest_auth_cache = {} - def build_authentication_header( + def _build_authentication_header( self, url: bytes, method: bytes, cached: bool, nonce: str, realm: str, qop: Optional[str] = None, algorithm: str = 'MD5', opaque: Optional[str] = None @@ -267,7 +267,7 @@ def _on_401_response(self, www_authenticate_response: IResponse, parse_dict_header(digest_header.decode("utf-8")) digest_authentication_header = \ - self._auth.build_authentication_header( + self._auth._build_authentication_header( uri, method, False, @@ -339,7 +339,7 @@ def request(self, method: bytes, uri: bytes, digest_params_from_cache = digest_auth_metadata['p'] digest_params_from_cache['cached'] = True digest_authentication_header = \ - self._auth.build_authentication_header( + self._auth._build_authentication_header( digest_params_from_cache['path'], digest_params_from_cache['method'], digest_params_from_cache['cached'], diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index b56444db..ba13c647 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -172,14 +172,14 @@ def setUp(self): def test_build_authentication_header_unknown_algorythm(self): self.assertRaises( - UnknownDigestAuthAlgorithm, self._auth.build_authentication_header, + UnknownDigestAuthAlgorithm, self._auth._build_authentication_header, b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop=None, algorithm='UNKNOWN') def test_build_authentication_header_md5_no_cache_no_qop(self): - auth_header = self._auth.build_authentication_header( + auth_header = self._auth._build_authentication_header( b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop=None, @@ -194,7 +194,7 @@ def test_build_authentication_header_md5_no_cache_no_qop(self): ) def test_build_authentication_header_md5_sess_no_cache(self): - auth_header = self._auth.build_authentication_header( + auth_header = self._auth._build_authentication_header( b'/spam/eggs?ham=bacon', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop='auth', @@ -211,7 +211,7 @@ def test_build_authentication_header_md5_sess_no_cache(self): ) def test_build_authentication_header_sha_no_cache_no_qop(self): - auth_header = self._auth.build_authentication_header( + auth_header = self._auth._build_authentication_header( b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop=None, @@ -229,14 +229,14 @@ def test_build_authentication_header_sha_no_cache_no_qop(self): def test_build_authentication_header_sha512_cache(self): # Emulate 1st request - self._auth.build_authentication_header( + self._auth._build_authentication_header( b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop='auth', algorithm='SHA-512' ) # Get header after cached request - auth_header = self._auth.build_authentication_header( + auth_header = self._auth._build_authentication_header( b'/spam/eggs', b'GET', True, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop='auth', From 4cbd5565cf5dab5f4c309270c0290b51de6bbfc5 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 27 Nov 2022 13:22:32 -0400 Subject: [PATCH 40/53] Use _DIGEST_ALGO enum for digest auth algo --- src/treq/auth.py | 38 ++++++++++++++++++++------------------ src/treq/test/test_auth.py | 23 ++++++++++------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 282b157c..82706dbe 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -6,6 +6,7 @@ import hashlib import binascii +from enum import Enum from typing import Union, Optional from urllib.parse import urlparse @@ -19,6 +20,14 @@ _DIGEST_HEADER_PREFIX_REGEXP = re.compile(b'digest ', flags=re.IGNORECASE) +class _DIGEST_ALGO(str, Enum): + MD5 = 'MD5' + MD5_SESS = 'MD5-SESS' + SHA = 'SHA' + SHA_256 = 'SHA-256' + SHA_512 = 'SHA-512' + + def _generate_client_nonce(server_side_nonce: str) -> str: return hashlib.sha1( hashlib.sha1(server_side_nonce.encode('utf-8')).digest() + @@ -66,7 +75,8 @@ def __init__(self, username: Union[str, bytes], def _build_authentication_header( self, url: bytes, method: bytes, cached: bool, nonce: str, - realm: str, qop: Optional[str] = None, algorithm: str = 'MD5', + realm: str, qop: Optional[str] = None, + algorithm: _DIGEST_ALGO = _DIGEST_ALGO.MD5, opaque: Optional[str] = None ) -> str: """ @@ -104,16 +114,17 @@ def _build_authentication_header( A1 = f"{self._username}:{realm}:{self._password}" A2 = f"{method.decode('utf-8')}:{path}" - if algo == 'MD5' or algo == 'MD5-SESS': + if algo == _DIGEST_ALGO.MD5 or algo == _DIGEST_ALGO.MD5_SESS: digest_hash_func = _md5_utf_digest - elif algo == 'SHA': + elif algo == _DIGEST_ALGO.SHA: digest_hash_func = _sha1_utf_digest - elif algo == 'SHA-256': + elif algo == _DIGEST_ALGO.SHA_256: digest_hash_func = _sha256_utf_digest - elif algo == 'SHA-512': + elif algo == _DIGEST_ALGO.SHA_512: digest_hash_func = _sha512_utf_digest else: - raise UnknownDigestAuthAlgorithm(algo) + raise ValueError(f"Unsupported Digest Auth algorithm identifier " + f"passed: {algo.name}") KD = lambda s, d: digest_hash_func(f"{s}:{d}") # noqa:E731 @@ -129,7 +140,7 @@ def _build_authentication_header( ncvalue = '%08x' % nonce_count cnonce = _generate_client_nonce(nonce) - if algo == 'MD5-SESS': + if algo == _DIGEST_ALGO.MD5_SESS: HA1 = digest_hash_func(f"{HA1}:{nonce}:{cnonce}") if not qop: @@ -192,15 +203,6 @@ def __init__(self, qop: Optional[str]): ) -class UnknownDigestAuthAlgorithm(Exception): - - def __init__(self, algorithm: Optional[str]): - super(Exception, self).__init__( - 'Unsupported Digest Auth algorithm identifier passed: {algorithm}' - .format(algorithm=algorithm) - ) - - @implementer(IAgent) class _RequestHeaderSetterAgent: """ @@ -274,8 +276,8 @@ def _on_401_response(self, www_authenticate_response: IResponse, digest_authentication_params['nonce'], digest_authentication_params['realm'], qop=digest_authentication_params.get('qop', None), - algorithm=digest_authentication_params.get('algorithm', - 'MD5'), + algorithm=_DIGEST_ALGO(digest_authentication_params.get( + 'algorithm', 'MD5')), opaque=digest_authentication_params.get('opaque', None) ) return self._perform_request( diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index ba13c647..d968282d 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -6,7 +6,7 @@ from treq._agentspy import agent_spy from treq.auth import _RequestHeaderSetterAgent, add_auth, \ - UnknownAuthConfig, HTTPDigestAuth, UnknownDigestAuthAlgorithm + UnknownAuthConfig, HTTPDigestAuth, _DIGEST_ALGO class RequestHeaderSetterAgentTests(SynchronousTestCase): @@ -170,20 +170,17 @@ def setUp(self): self.maxDiff = None self._auth = HTTPDigestAuth('spam', 'eggs') - def test_build_authentication_header_unknown_algorythm(self): - self.assertRaises( - UnknownDigestAuthAlgorithm, self._auth._build_authentication_header, - b'/spam/eggs', b'GET', False, - 'b7f36bc385a662ed615f27bd9e94eecd', - 'me@dragons', qop=None, - algorithm='UNKNOWN') + def test_digest_unknown_algorithm(self): + with self.assertRaises(ValueError) as e: + _DIGEST_ALGO('UNKNOWN') + self.assertIn("'UNKNOWN' is not a valid _DIGEST_ALGO", str(e.exception)) def test_build_authentication_header_md5_no_cache_no_qop(self): auth_header = self._auth._build_authentication_header( b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop=None, - algorithm='MD5' + algorithm=_DIGEST_ALGO('MD5') ) self.assertEquals( auth_header, @@ -198,7 +195,7 @@ def test_build_authentication_header_md5_sess_no_cache(self): b'/spam/eggs?ham=bacon', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop='auth', - algorithm='MD5-SESS' + algorithm=_DIGEST_ALGO('MD5-SESS') ) self.assertRegex( auth_header, @@ -215,7 +212,7 @@ def test_build_authentication_header_sha_no_cache_no_qop(self): b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop=None, - algorithm='SHA' + algorithm=_DIGEST_ALGO('SHA') ) self.assertEquals( @@ -233,14 +230,14 @@ def test_build_authentication_header_sha512_cache(self): b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop='auth', - algorithm='SHA-512' + algorithm=_DIGEST_ALGO('SHA-512') ) # Get header after cached request auth_header = self._auth._build_authentication_header( b'/spam/eggs', b'GET', True, 'b7f36bc385a662ed615f27bd9e94eecd', 'me@dragons', qop='auth', - algorithm='SHA-512' + algorithm=_DIGEST_ALGO('SHA-512') ) # Make sure metadata was cached From 5359a2a984f228a46490165bd6fc0a6e507a164b Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 27 Nov 2022 13:50:33 -0400 Subject: [PATCH 41/53] Improve and add additional digest auth test docstring --- src/treq/test/test_auth.py | 52 ++++++++++++++++++++++++++ src/treq/test/test_treq_integration.py | 23 +++++------- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/treq/test/test_auth.py b/src/treq/test/test_auth.py index d968282d..db4f1e8e 100644 --- a/src/treq/test/test_auth.py +++ b/src/treq/test/test_auth.py @@ -132,6 +132,10 @@ def test_add_basic_auth_bytes(self): ) def test_add_digest_auth(self): + """ + add_auth() wraps the given agent with one that adds a ``Authorization: + Digest ...`` authentication handler. + """ agent, requests = agent_spy() username = 'spam' password = 'eggs' @@ -153,6 +157,31 @@ def test_add_digest_auth(self): password, ) + def test_add_digest_auth_bytes(self): + """ + Digest auth can be passed as `bytes` which will be encoded as utf-8. + """ + agent, requests = agent_spy() + username = b'spam' + password = b'eggs' + auth = HTTPDigestAuth(username, password) + authAgent = add_auth(agent, auth) + + authAgent.request(b'method', b'uri') + + self.assertEqual( + authAgent._auth, + auth, + ) + self.assertEqual( + authAgent._auth._username, + username.decode('utf-8'), + ) + self.assertEqual( + authAgent._auth._password, + password.decode('utf-8'), + ) + def test_add_unknown_auth(self): """ add_auth() raises UnknownAuthConfig when given anything other than @@ -171,11 +200,19 @@ def setUp(self): self._auth = HTTPDigestAuth('spam', 'eggs') def test_digest_unknown_algorithm(self): + """ + _DIGEST_ALGO('UNKNOWN') raises ValueError when the algorithm is unknown. + """ with self.assertRaises(ValueError) as e: _DIGEST_ALGO('UNKNOWN') self.assertIn("'UNKNOWN' is not a valid _DIGEST_ALGO", str(e.exception)) def test_build_authentication_header_md5_no_cache_no_qop(self): + """ + _build_authentication_header test vectors using the MD5 algo and without + qop parameter generate the expected digest header when cache is + uninitialized. + """ auth_header = self._auth._build_authentication_header( b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', @@ -191,6 +228,11 @@ def test_build_authentication_header_md5_no_cache_no_qop(self): ) def test_build_authentication_header_md5_sess_no_cache(self): + """ + _build_authentication_header test vectors using the MD5-SESS algo and + with qop parameter generate the expected digest header when cache is + uninitialized. + """ auth_header = self._auth._build_authentication_header( b'/spam/eggs?ham=bacon', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', @@ -208,6 +250,11 @@ def test_build_authentication_header_md5_sess_no_cache(self): ) def test_build_authentication_header_sha_no_cache_no_qop(self): + """ + _build_authentication_header test vectors using the SHA(SHA-1) algo and + without the qop parameter generate the expected digest header when cache + is uninitialized. + """ auth_header = self._auth._build_authentication_header( b'/spam/eggs', b'GET', False, 'b7f36bc385a662ed615f27bd9e94eecd', @@ -225,6 +272,11 @@ def test_build_authentication_header_sha_no_cache_no_qop(self): ) def test_build_authentication_header_sha512_cache(self): + """ + _build_authentication_header test vectors using the SHA-512 algo and + with the qop parameter generate the expected digest header when the + digest cache is used for the second request. + """ # Emulate 1st request self._auth._build_authentication_header( b'/spam/eggs', b'GET', False, diff --git a/src/treq/test/test_treq_integration.py b/src/treq/test/test_treq_integration.py index 2f9db9be..8ab79e7c 100644 --- a/src/treq/test/test_treq_integration.py +++ b/src/treq/test/test_treq_integration.py @@ -247,8 +247,7 @@ def test_failed_basic_auth(self): @inlineCallbacks def test_digest_auth(self): """ - Test successful Digest authentication - :return: + Digest authentication succeeds. """ response = yield self.get('/digest-auth/auth/treq/treq', auth=HTTPDigestAuth('treq', 'treq')) @@ -261,8 +260,7 @@ def test_digest_auth(self): @inlineCallbacks def test_digest_auth_multi_qop(self): """ - Test successful Digest authentication with multiple qop types - :return: + Digest authentication with alternative qop type. """ response = yield self.get('/digest-auth/undefined/treq/treq', auth=HTTPDigestAuth('treq', 'treq')) @@ -275,7 +273,8 @@ def test_digest_auth_multi_qop(self): @inlineCallbacks def test_digest_auth_multiple_calls(self): """ - Test proper Digest authentication credentials caching + Proper Digest authentication credentials caching works across + multiple requests. """ calls = 0 @@ -288,7 +287,7 @@ def agent_request_patched(*args, **kwargs): """ Patched Agent.request function, that increases call count on every HTTP request - and appends + and appends. """ nonlocal calls, headers_for_second_request response_deferred = agent_request_orig(*args, **kwargs) @@ -343,8 +342,7 @@ def agent_request_patched(*args, **kwargs): @inlineCallbacks def test_digest_auth_sha256(self): """ - Test successful Digest authentication with sha256 - :return: + Digest authentication with sha256 works. """ response = yield self.get('/digest-auth/auth/treq/treq/SHA-256', auth=HTTPDigestAuth('treq', 'treq')) @@ -357,8 +355,7 @@ def test_digest_auth_sha256(self): @inlineCallbacks def test_digest_auth_sha512(self): """ - Test successful Digest authentication with sha512 - :return: + Digest authentication with sha512 works. """ response = yield self.get('/digest-auth/auth/treq/treq/SHA-512', auth=HTTPDigestAuth('treq', 'treq')) @@ -371,7 +368,7 @@ def test_digest_auth_sha512(self): @inlineCallbacks def test_failed_digest_auth(self): """ - Test digest auth with invalid credentials + Digest auth with invalid credentials fails. """ response = yield self.get('/digest-auth/auth/treq/treq', auth=HTTPDigestAuth('not-treq', 'not-treq')) @@ -381,8 +378,8 @@ def test_failed_digest_auth(self): @inlineCallbacks def test_failed_digest_auth_int(self): """ - Test failed Digest authentication when qop type is unsupported - :return: + Digest authentication when qop type is unsupported fails with + UnknownQopForDigestAuth. """ with self.assertRaises(UnknownQopForDigestAuth): yield self.get('/digest-auth/auth-int/treq/treq', From f8335611cb50ed26eea40897c02cbe4504c57d38 Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 13:12:30 -0700 Subject: [PATCH 42/53] version bump on mypy basepython --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index c43dcf0b..13bf0436 100644 --- a/tox.ini +++ b/tox.ini @@ -26,7 +26,7 @@ commands = {envbindir}/trial {posargs:treq} [testenv:mypy] -basepython = python3.8 +basepython = python3.10 deps = mypy==1.0.1 mypy-zope==0.9.1 @@ -67,7 +67,7 @@ commands = [testenv:docs] extras = docs changedir = docs -basepython = python3.8 +basepython = python3.10 commands = sphinx-build -b html . html From 1c35956f104c915b0e55fa48e52c6a48028a96ae Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 13:19:54 -0700 Subject: [PATCH 43/53] current brotlipy doesn't build against 3.10 any more, mypy transitive deps aren't pinned, let's get unstuck --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 13bf0436..58a7ce6a 100644 --- a/tox.ini +++ b/tox.ini @@ -26,7 +26,7 @@ commands = {envbindir}/trial {posargs:treq} [testenv:mypy] -basepython = python3.10 +basepython = python3.12 deps = mypy==1.0.1 mypy-zope==0.9.1 @@ -67,7 +67,7 @@ commands = [testenv:docs] extras = docs changedir = docs -basepython = python3.10 +basepython = python3.12 commands = sphinx-build -b html . html From 9198250ccdb2625b233a401b7fec9873e6f511c0 Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 13:45:07 -0700 Subject: [PATCH 44/53] try to catch up this PR with the new, stricter requirements treq has inherited in the meanwhile --- src/treq/auth.py | 54 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 7f09d88a..00f62579 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -7,12 +7,14 @@ import binascii from enum import Enum -from typing import Union, Optional +from typing import Union, Optional, TypedDict from urllib.parse import urlparse from twisted.python.randbytes import secureRandom from twisted.web.http_headers import Headers from twisted.web.iweb import IAgent, IBodyProducer, IResponse +from twisted.internet.defer import Deferred + from zope.interface import implementer from requests.utils import parse_dict_header @@ -52,6 +54,22 @@ def _sha512_utf_digest(x: str) -> str: return hashlib.sha512(x.encode("utf-8")).hexdigest() +class _DigestAuthCacheParams(TypedDict): + path: bytes + method: bytes + cached: bool + nonce: str + realm: str + qop: str | None + algorithm: _DIGEST_ALGO + opaque: str | None + + +class _DigestAuthCacheEntry(TypedDict): + c: int + p: _DigestAuthCacheParams + + class HTTPDigestAuth(object): """ The container for HTTP Digest authentication credentials. @@ -61,17 +79,15 @@ class HTTPDigestAuth(object): """ def __init__(self, username: Union[str, bytes], password: Union[str, bytes]): - if isinstance(username, bytes): - self._username: str = username.decode("utf-8") - else: - self._username: str = username - if isinstance(password, bytes): - self._password: str = password.decode("utf-8") - else: - self._password: str = password + self._username: str = ( + username.decode("utf-8") if isinstance(username, bytes) else username + ) + self._password: str = ( + password.decode("utf-8") if isinstance(password, bytes) else password + ) # (method,uri) --> digest auth cache - self._digest_auth_cache = {} + self._digest_auth_cache: dict[tuple[bytes, bytes], _DigestAuthCacheEntry] = {} def _build_authentication_header( self, @@ -129,7 +145,7 @@ def _build_authentication_header( digest_hash_func = _sha512_utf_digest else: raise ValueError( - f"Unsupported Digest Auth algorithm identifier " f"passed: {algo.name}" + f"Unsupported Digest Auth algorithm identifier passed: {algo}" ) KD = lambda s, d: digest_hash_func(f"{s}:{d}") # noqa:E731 @@ -169,7 +185,7 @@ def _build_authentication_header( base += f', qop="auth", nc={ncvalue}, cnonce="{cnonce}"' if not cached: - cache_params = { + cache_params: _DigestAuthCacheParams = { "path": url, "method": method, "cached": cached, @@ -183,7 +199,9 @@ def _build_authentication_header( return f"Digest {base}" - def _cached_metadata_for(self, method: bytes, uri: bytes) -> Optional[dict]: + def _cached_metadata_for( + self, method: bytes, uri: bytes + ) -> Optional[_DigestAuthCacheEntry]: return self._digest_auth_cache.get((method, uri)) @@ -245,7 +263,7 @@ def _on_401_response( uri: bytes, headers: Optional[Headers], bodyProducer: Optional[IBodyProducer], - ): + ) -> Deferred[IResponse]: """ Handle the server`s 401 response, that is capable with authentication headers, build the Authorization header @@ -299,7 +317,7 @@ def _perform_request( uri: bytes, headers: Optional[Headers], bodyProducer: Optional[IBodyProducer], - ): + ) -> Deferred[IResponse]: """ Add Authorization header and perform the request with actual credentials @@ -316,7 +334,7 @@ def _perform_request( """ if not headers: headers = Headers( - {b"Authorization": digest_authentication_header.encode("utf-8")} + {b"Authorization": [digest_authentication_header.encode("utf-8")]} ) else: headers.addRawHeader( @@ -332,7 +350,7 @@ def request( uri: bytes, headers: Optional[Headers] = None, bodyProducer: Optional[IBodyProducer] = None, - ): + ) -> Deferred[IResponse]: """ Wrap the agent with HTTP Digest authentication. @@ -409,7 +427,7 @@ def add_digest_auth(agent: IAgent, http_digest_auth: HTTPDigestAuth) -> IAgent: return _RequestDigestAuthenticationAgent(agent, http_digest_auth) -def add_auth(agent: IAgent, auth_config: Union[tuple, HTTPDigestAuth]): +def add_auth(agent: IAgent, auth_config: Union[tuple, HTTPDigestAuth]) -> IAgent: """ Wrap an agent to perform authentication From e1cf128c62cb176ee4c48c7e69dfe4b97496a1dc Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 13:49:07 -0700 Subject: [PATCH 45/53] add test-case-name --- src/treq/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/treq/auth.py b/src/treq/auth.py index 00f62579..f71bbb9f 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -1,3 +1,4 @@ +# -*- test-case-name: treq.test.test_auth -*- # Copyright 2012-2020 The treq Authors. # See LICENSE for details. from __future__ import absolute_import, division, print_function From 580eb8a924254e1c2a5191c4c9186dd159a72c2c Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 13:49:27 -0700 Subject: [PATCH 46/53] now that we're using enums properly, format them properly --- src/treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index f71bbb9f..6d39f941 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -181,7 +181,7 @@ def _build_authentication_header( if opaque: base += f', opaque="{opaque}"' if algorithm: - base += f', algorithm="{algorithm}"' + base += f', algorithm="{algorithm.value}"' if qop: base += f', qop="auth", nc={ncvalue}, cnonce="{cnonce}"' From 92fd51c622b918e8542ba3319c5568daf493b2ee Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 13:54:18 -0700 Subject: [PATCH 47/53] upgrade setup-python to correspond to new mypy basepython --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5c8b6c80..aa7376f2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - uses: actions/cache@v4 with: From ae9d582c31b250fbf71bae83c00fedc310872e40 Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 13:58:05 -0700 Subject: [PATCH 48/53] same mistake again on the docs env --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aa7376f2..7a0f1fcc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - uses: actions/cache@v4 with: From ffa168e510b2223720967fb3c23eaf7c5c63c5f9 Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 13:59:34 -0700 Subject: [PATCH 49/53] annotations import --- src/treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 6d39f941..0fbc7e78 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -1,7 +1,7 @@ # -*- test-case-name: treq.test.test_auth -*- # Copyright 2012-2020 The treq Authors. # See LICENSE for details. -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, annotations import re import time import hashlib From 7ab492795e4b60c45342bd80812cb17f13e09da9 Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 14:41:18 -0700 Subject: [PATCH 50/53] extend test-case-name for the missed parts --- src/treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 0fbc7e78..bfe03dd5 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -1,4 +1,4 @@ -# -*- test-case-name: treq.test.test_auth -*- +# -*- test-case-name: treq.test.test_auth,treq.test.test_treq_integration -*- # Copyright 2012-2020 The treq Authors. # See LICENSE for details. from __future__ import absolute_import, division, print_function, annotations From fc145a4674ad017f770eabbcaae88e026b939cc4 Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 14:41:45 -0700 Subject: [PATCH 51/53] no peeking at private attributes --- src/treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index bfe03dd5..6f307d7c 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -286,7 +286,7 @@ def _on_401_response( does not support Digest auth """ www_authenticate_header_string = ( - www_authenticate_response.headers._rawHeaders.get( + www_authenticate_response.headers.getRawHeaders( b"www-authenticate", [b""] )[0] ) From c27e977e2909ecee63012ef4b232c6adf72f377a Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 17:14:13 -0700 Subject: [PATCH 52/53] Update src/treq/auth.py --- src/treq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 6f307d7c..0b475ee0 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -73,7 +73,7 @@ class _DigestAuthCacheEntry(TypedDict): class HTTPDigestAuth(object): """ - The container for HTTP Digest authentication credentials. + HTTP Digest authentication credentials. This container will cache digest auth parameters, in order not to recompute these for each request. From badf9fb8a2b158a6f9c291a877653435bce522f6 Mon Sep 17 00:00:00 2001 From: Glyph Date: Tue, 18 Jun 2024 17:15:42 -0700 Subject: [PATCH 53/53] remove unnecessary extra public API --- src/treq/auth.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/treq/auth.py b/src/treq/auth.py index 0b475ee0..88ac0fca 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -424,10 +424,6 @@ def add_basic_auth( ) -def add_digest_auth(agent: IAgent, http_digest_auth: HTTPDigestAuth) -> IAgent: - return _RequestDigestAuthenticationAgent(agent, http_digest_auth) - - def add_auth(agent: IAgent, auth_config: Union[tuple, HTTPDigestAuth]) -> IAgent: """ Wrap an agent to perform authentication @@ -445,6 +441,6 @@ def add_auth(agent: IAgent, auth_config: Union[tuple, HTTPDigestAuth]) -> IAgent if isinstance(auth_config, tuple): return add_basic_auth(agent, auth_config[0], auth_config[1]) elif isinstance(auth_config, HTTPDigestAuth): - return add_digest_auth(agent, auth_config) + return _RequestDigestAuthenticationAgent(agent, auth_config) raise UnknownAuthConfig(auth_config)