From 2753eaed39b3444f4c1d352834db712affb3f300 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 19 Aug 2017 18:15:41 -0700 Subject: [PATCH 01/16] Added a digest authentication helper --- aiohttp/helpers.py | 180 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 519b610d339..82b18ac8e97 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -12,11 +12,12 @@ import time import warnings import weakref +import hashlib from collections import namedtuple from math import ceil from pathlib import Path from time import gmtime -from urllib.parse import quote +from urllib.parse import quote, urlparse from async_timeout import timeout @@ -216,6 +217,183 @@ def encode(self): return 'Basic %s' % base64.b64encode(creds).decode(self.encoding) +def parse_pair(pair): + if '=' not in pair: + return pair, None + + key, value = pair.split('=', 1) + + # If it has a trailing comma, remove it. + if value[-1] == ',': + value = value[:-1] + + # If it is quoted, then remove them. + if value[0] == value[-1] == '"': + value = value[1:-1] + + return key, value + + +def parse_key_value_list(header): + return { + key: value for key, value in + map(parse_pair, header.split(' ')) + } + + +class DigestAuth(): + """HTTP digest authentication helper. + The work here is based off of https://github.com/requests/requests/blob/v2.18.4/requests/auth.py. + + :param str username: Username or login + :param str password: Password + :param ClientSession session: Session to use digest auth + """ + + def __init__(self, username, password, session): + self.username = username + self.password = password + self.last_nonce = '' + self.nonce_count = 0 + self.challenge = None + self.num_401 = 0 + self.args = {} + self.session = session + + @asyncio.coroutine + def request(self, method, url, *, headers=None, **kwargs): + if headers is None: + headers = {} + + # Save the args so we can re-run the request + self.args = { + 'method': method, + 'url': url, + 'headers': headers, + 'kwargs': kwargs + } + + if self.challenge: + headers[hdrs.AUTHORIZATION] = self.build_digest_header( + method.upper(), url + ) + + response = yield from self.session.request( + method, url, headers=headers, **kwargs + ) + + # If the response is not in the 400 range, do not try digest + # authentication. + if not 400 <= response.status < 500: + self.num_401 = 1 + return response + + return (yield from self.handle_401(response)) + + def build_digest_header(self, method, url): + """ + :rtype: str + """ + + realm = self.challenge['realm'] + nonce = self.challenge['nonce'] + qop = self.challenge.get('qop') + algorithm = self.challenge.get('algorithm', 'MD5').upper() + opaque = self.challenge.get('opaque') + + # lambdas assume digest modules are imported at the top level + if algorithm == 'MD5' or algorithm == 'MD5-SESS': + hash_fn = hashlib.md5 + elif algorithm == 'SHA': + hash_fn = hashlib.sha1 + else: + return '' + + def hash_utf8(x): + if isinstance(x, str): + x = x.encode('utf-8') + return hash_fn(x).hexdigest() + + KD = lambda s, d: hash_utf8('%s:%s' % (s, d)) + + parsed = urlparse(url) + #: path is request-uri defined in RFC 2616 which should not be empty + path = parsed.path or "/" + if parsed.query: + path += '?' + parsed.query + + A1 = '%s:%s:%s' % (self.username, realm, self.password) + A2 = '%s:%s' % (method, path) + + HA1 = hash_utf8(A1) + HA2 = hash_utf8(A2) + + if nonce == self.last_nonce: + self.nonce_count += 1 + else: + self.nonce_count = 1 + + self.last_nonce = nonce + + ncvalue = '%08x' % self.nonce_count + + k = str(self.nonce_count).encode('utf-8') + k += nonce.encode('utf-8') + k += time.ctime().encode('utf-8') + k += os.urandom(8) + cnonce = (hashlib.sha1(k).hexdigest()[:16]) + + if algorithm == 'MD5-SESS': + HA1 = hash_utf8('%s:%s:%s' % (HA1, nonce, cnonce)) + + if not qop: + respdig = KD(HA1, '%s:%s' % (nonce, HA2)) + elif qop == 'auth' or 'auth' in qop.split(','): + noncebit = '%s:%s:%s:%s:%s' % ( + nonce, ncvalue, cnonce, 'auth', HA2 + ) + respdig = KD(HA1, noncebit) + else: + return '' + + base = 'username="%s", realm="%s", nonce="%s", uri="%s", ' \ + 'response="%s"' % (self.username, realm, nonce, path, respdig) + if opaque: + base += ', opaque="%s"' % opaque + if algorithm: + base += ', algorithm="%s"' % algorithm + if qop: + base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) + + return 'Digest %s' % base + + @asyncio.coroutine + def handle_401(self, response): + """ + Takes the given response and tries digest-auth, if needed. + :rtype: ClientResponse + """ + auth_header = response.headers.get('www-authenticate', '') + + if 'digest' in auth_header.lower() and self.num_401 < 2: + + self.num_401 += 1 + pattern = re.compile(r'digest ', flags=re.IGNORECASE) + self.challenge = parse_key_value_list( + pattern.sub('', auth_header, count=1) + ) + + return (yield from self.request( + self.args['method'], + self.args['url'], + headers=self.args['headers'], + **self.args['kwargs'], + )) + + self.num_401 = 1 + return response + + if PY_352: def create_future(loop): return loop.create_future() From 393c2b911367b634ab4dae20fa4ee13739846361 Mon Sep 17 00:00:00 2001 From: jf Date: Mon, 13 Nov 2017 22:31:53 -0800 Subject: [PATCH 02/16] Added tests and documentation --- aiohttp/helpers.py | 34 +++--- docs/client_reference.rst | 99 ++++++++++++++++ tests/test_helpers.py | 241 +++++++++++++++++++++++++++++++++++++- 3 files changed, 357 insertions(+), 17 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 82b18ac8e97..53053f7bcfe 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -6,13 +6,13 @@ import cgi import datetime import functools +import hashlib import os import re import sys import time import warnings import weakref -import hashlib from collections import namedtuple from math import ceil from pathlib import Path @@ -40,7 +40,7 @@ from .backport_cookies import SimpleCookie # noqa -__all__ = ('BasicAuth', 'create_future', 'parse_mimetype', +__all__ = ('BasicAuth', 'DigestAuth', 'create_future', 'parse_mimetype', 'Timeout', 'ensure_future', 'noop', 'DummyCookieJar') @@ -243,7 +243,8 @@ def parse_key_value_list(header): class DigestAuth(): """HTTP digest authentication helper. - The work here is based off of https://github.com/requests/requests/blob/v2.18.4/requests/auth.py. + The work here is based off of + https://github.com/requests/requests/blob/v2.18.4/requests/auth.py. :param str username: Username or login :param str password: Password @@ -274,7 +275,7 @@ def request(self, method, url, *, headers=None, **kwargs): } if self.challenge: - headers[hdrs.AUTHORIZATION] = self.build_digest_header( + headers[hdrs.AUTHORIZATION] = self._build_digest_header( method.upper(), url ) @@ -288,9 +289,9 @@ def request(self, method, url, *, headers=None, **kwargs): self.num_401 = 1 return response - return (yield from self.handle_401(response)) + return (yield from self._handle_401(response)) - def build_digest_header(self, method, url): + def _build_digest_header(self, method, url): """ :rtype: str """ @@ -309,12 +310,13 @@ def build_digest_header(self, method, url): else: return '' - def hash_utf8(x): + def H(x): if isinstance(x, str): - x = x.encode('utf-8') + x = x.encode() return hash_fn(x).hexdigest() - KD = lambda s, d: hash_utf8('%s:%s' % (s, d)) + def KD(s, d): + return H('%s:%s' % (s, d)) parsed = urlparse(url) #: path is request-uri defined in RFC 2616 which should not be empty @@ -325,8 +327,8 @@ def hash_utf8(x): A1 = '%s:%s:%s' % (self.username, realm, self.password) A2 = '%s:%s' % (method, path) - HA1 = hash_utf8(A1) - HA2 = hash_utf8(A2) + HA1 = H(A1) + HA2 = H(A2) if nonce == self.last_nonce: self.nonce_count += 1 @@ -337,14 +339,14 @@ def hash_utf8(x): ncvalue = '%08x' % self.nonce_count - k = str(self.nonce_count).encode('utf-8') - k += nonce.encode('utf-8') - k += time.ctime().encode('utf-8') + k = str(self.nonce_count).encode() + k += nonce.encode() + k += time.ctime().encode() k += os.urandom(8) cnonce = (hashlib.sha1(k).hexdigest()[:16]) if algorithm == 'MD5-SESS': - HA1 = hash_utf8('%s:%s:%s' % (HA1, nonce, cnonce)) + HA1 = H('%s:%s:%s' % (HA1, nonce, cnonce)) if not qop: respdig = KD(HA1, '%s:%s' % (nonce, HA2)) @@ -368,7 +370,7 @@ def hash_utf8(x): return 'Digest %s' % base @asyncio.coroutine - def handle_401(self, response): + def _handle_401(self, response): """ Takes the given response and tries digest-auth, if needed. :rtype: ClientResponse diff --git a/docs/client_reference.rst b/docs/client_reference.rst index e6cb1313b3f..5c6033d5cc4 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -1341,6 +1341,105 @@ BasicAuth :return: encoded authentication data, :class:`str`. +DigestAuth +^^^^^^^^^^ + +.. class:: DigestAuth(login, password', session) + + HTTP digest authentication helper. Unlike :class:`DigestAuth`, this helper + CANNOT be passed to the *auth* parameter of a :meth:`ClientSession.request`. + + :param str login: login + :param str password: password + :param `ClientSession` session: underlying session that will use digest auth + + .. comethod:: request(method, url, *, params=None, data=None, \ + json=None,\ + headers=None, cookies=None, auth=None, \ + allow_redirects=True, max_redirects=10, \ + encoding='utf-8', \ + version=HttpVersion(major=1, minor=1), \ + compress=None, chunked=None, expect100=False, \ + connector=None, loop=None,\ + read_until_eof=True) + :coroutine: + + Perform an asynchronous HTTP request. Return a response object + (:class:`ClientResponse` or derived from). + + :param str method: HTTP method + + :param url: Requested URL, :class:`str` or :class:`~yarl.URL` + + :param dict params: Parameters to be sent in the query + string of the new request (optional) + + :param data: Dictionary, bytes, or file-like object to + send in the body of the request (optional) + + :param json: Any json compatible python object (optional). *json* and *data* + parameters could not be used at the same time. + + :param dict headers: HTTP Headers to send with the request (optional) + + :param dict cookies: Cookies to send with the request (optional) + + :param aiohttp.BasicAuth auth: an object that represents HTTP Basic + Authorization (optional) + + :param bool allow_redirects: If set to ``False``, do not follow redirects. + ``True`` by default (optional). + + :param aiohttp.protocol.HttpVersion version: Request HTTP version (optional) + + :param bool compress: Set to ``True`` if request has to be compressed + with deflate encoding. + ``False`` instructs aiohttp to not compress data. + ``None`` by default (optional). + + :param int chunked: Enables chunked transfer encoding. + ``None`` by default (optional). + + :param bool expect100: Expect 100-continue response from server. + ``False`` by default (optional). + + :param aiohttp.connector.BaseConnector connector: BaseConnector sub-class + instance to support connection pooling. + + :param bool read_until_eof: Read response until EOF if response + does not have Content-Length header. + ``True`` by default (optional). + + :param loop: :ref:`event loop` + used for processing HTTP requests. + If param is ``None``, :func:`asyncio.get_event_loop` + is used for getting default event loop. + + .. deprecated:: 2.0 + + :return ClientResponse: a :class:`client response ` object. + + Usage:: + + import aiohttp + import asyncio + + async def fetch(client): + auth = aiohttp.DigestAuth('usr', 'psswd', client) + resp = await auth.request('GET', 'http://httpbin.org/digest-auth/auth/usr/psswd/MD5/never') + assert resp.status == 200 + return await resp.text() + + async def main(): + async with aiohttp.ClientSession() as client: + text = await fetch(client) + print(text) + + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) + + + CookieJar ^^^^^^^^^ diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 1c06a24ce93..94c0da3dc8f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,13 +1,17 @@ import asyncio import datetime import gc +import hashlib +import os +import re import sys +import time from unittest import mock import pytest from yarl import URL -from aiohttp import helpers +from aiohttp import helpers, web # -------------------- coro guard -------------------------------- @@ -551,3 +555,238 @@ def test_dummy_cookie_jar(loop): next(iter(dummy_jar)) assert dummy_jar.filter_cookies(URL("http://example.com/")) is None dummy_jar.clear() + + +# -------------------------------- DigestAuth -------------------------- + +@asyncio.coroutine +def test_digest_auth_MD5(loop, test_client): + opaque = os.urandom(16).hex() + username = 'username' + password = 'password' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.md5(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + if 'Authorization' in request.headers: + pattern = re.compile(r'digest ', flags=re.IGNORECASE) + auth_data = helpers.parse_key_value_list( + pattern.sub('', request.headers['Authorization'], count=1) + ) + + A1 = '%s:%s:%s' % (username, realm, password) + A2 = '%s:%s' % (request.method, auth_data['uri']) + + secret = H(A1) + data = '%s:%s:%s:%s:%s' % ( + auth_data['nonce'], + auth_data['nc'], + auth_data['cnonce'], + auth_data['qop'], + H(A2) + ) + + assert H('%s:%s' % (secret, data)) == auth_data['response'] + + return web.Response() + + response = web.Response(status=401) + data = '%s:%s' % (time.ctime().encode(), 'secret') + nonce = hashlib.sha1(data.encode()).hexdigest() + pairs = ', '.join([ + 'realm="%s"' % realm, + 'qop="auth"', + 'algorithm=MD5', + 'nonce="%s"' % nonce, + 'opaque="%s"' % opaque, + ]) + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs + return response + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + auth = helpers.DigestAuth(username, password, client) + + resp = yield from auth.request('GET', '/') + assert 200 == resp.status + + +@asyncio.coroutine +def test_digest_auth_MD5_sess(loop, test_client): + opaque = os.urandom(16).hex() + username = 'usr' + password = 'psswd' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.md5(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + if 'Authorization' in request.headers: + pattern = re.compile(r'digest ', flags=re.IGNORECASE) + auth_data = helpers.parse_key_value_list( + pattern.sub('', request.headers['Authorization'], count=1) + ) + + A1 = '%s:%s:%s' % (username, realm, password) + A1 = '%s:%s:%s' % (H(A1), auth_data['nonce'], auth_data['cnonce']) + A2 = '%s:%s' % (request.method, auth_data['uri']) + + secret = H(A1) + data = '%s:%s:%s:%s:%s' % ( + auth_data['nonce'], + auth_data['nc'], + auth_data['cnonce'], + auth_data['qop'], + H(A2) + ) + + assert H('%s:%s' % (secret, data)) == auth_data['response'] + + return web.Response() + + response = web.Response(status=401) + data = '%s:%s' % (time.ctime().encode(), 'secret') + nonce = hashlib.sha1(data.encode()).hexdigest() + pairs = ', '.join([ + 'realm="%s"' % realm, + 'qop="auth"', + 'algorithm=MD5-sess', + 'nonce="%s"' % nonce, + 'opaque="%s"' % opaque, + ]) + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs + return response + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + auth = helpers.DigestAuth(username, password, client) + + resp = yield from auth.request('GET', '/') + assert 200 == resp.status + + +@asyncio.coroutine +def test_digest_auth_no_qop(loop, test_client): + opaque = os.urandom(16).hex() + username = 'root' + password = 'passphrase' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.md5(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + if 'Authorization' in request.headers: + pattern = re.compile(r'digest ', flags=re.IGNORECASE) + auth_data = helpers.parse_key_value_list( + pattern.sub('', request.headers['Authorization'], count=1) + ) + + A1 = '%s:%s:%s' % (username, realm, password) + A2 = '%s:%s' % (request.method, auth_data['uri']) + + secret = H(A1) + data = '%s:%s' % ( + auth_data['nonce'], + H(A2) + ) + + assert H('%s:%s' % (secret, data)) == auth_data['response'] + + return web.Response() + + response = web.Response(status=401) + data = '%s:%s' % (time.ctime().encode(), 'secret') + nonce = hashlib.sha1(data.encode()).hexdigest() + pairs = ', '.join([ + 'realm="%s"' % realm, + 'algorithm=MD5', + 'nonce="%s"' % nonce, + 'opaque="%s"' % opaque, + ]) + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs + return response + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + auth = helpers.DigestAuth(username, password, client) + + resp = yield from auth.request('GET', '/') + assert 200 == resp.status + + +@asyncio.coroutine +def test_digest_auth_sha(loop, test_client): + opaque = os.urandom(16).hex() + username = 'username' + password = 'password' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.sha1(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + if 'Authorization' in request.headers: + pattern = re.compile(r'digest ', flags=re.IGNORECASE) + auth_data = helpers.parse_key_value_list( + pattern.sub('', request.headers['Authorization'], count=1) + ) + + A1 = '%s:%s:%s' % (username, realm, password) + A2 = '%s:%s' % (request.method, auth_data['uri']) + + secret = H(A1) + data = '%s:%s:%s:%s:%s' % ( + auth_data['nonce'], + auth_data['nc'], + auth_data['cnonce'], + auth_data['qop'], + H(A2) + ) + + assert H('%s:%s' % (secret, data)) == auth_data['response'] + + return web.Response() + + response = web.Response(status=401) + data = '%s:%s' % (time.ctime().encode(), 'secret') + nonce = hashlib.sha1(data.encode()).hexdigest() + pairs = ', '.join([ + 'realm="%s"' % realm, + 'qop="auth"', + 'algorithm=SHA', + 'nonce="%s"' % nonce, + 'opaque="%s"' % opaque, + ]) + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs + return response + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + auth = helpers.DigestAuth(username, password, client) + + resp = yield from auth.request('GET', '/') + assert 200 == resp.status From a3352b8ab05e6fd3cd5b9831923ff8bc21c95280 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 13:11:44 -0800 Subject: [PATCH 03/16] Added previous param and fixed tests to get 100% coverage of digest auth --- aiohttp/helpers.py | 45 +++---- docs/client_reference.rst | 13 ++ tests/test_helpers.py | 275 +++++++++++++++++++++++++++++++++++++- 3 files changed, 304 insertions(+), 29 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 53053f7bcfe..72c7b9cf80f 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -21,7 +21,7 @@ from async_timeout import timeout -from . import hdrs +from . import hdrs, client_exceptions from .abc import AbstractCookieJar @@ -218,9 +218,6 @@ def encode(self): def parse_pair(pair): - if '=' not in pair: - return pair, None - key, value = pair.split('=', 1) # If it has a trailing comma, remove it. @@ -251,13 +248,15 @@ class DigestAuth(): :param ClientSession session: Session to use digest auth """ - def __init__(self, username, password, session): + def __init__(self, username, password, session, previous=None): + if previous is None: + previous = {} + self.username = username self.password = password - self.last_nonce = '' - self.nonce_count = 0 - self.challenge = None - self.num_401 = 0 + self.last_nonce = previous.get('last_nonce', '') + self.nonce_count = previous.get('nonce_count', 0) + self.challenge = previous.get('challenge') self.args = {} self.session = session @@ -286,7 +285,6 @@ def request(self, method, url, *, headers=None, **kwargs): # If the response is not in the 400 range, do not try digest # authentication. if not 400 <= response.status < 500: - self.num_401 = 1 return response return (yield from self._handle_401(response)) @@ -311,9 +309,7 @@ def _build_digest_header(self, method, url): return '' def H(x): - if isinstance(x, str): - x = x.encode() - return hash_fn(x).hexdigest() + return hash_fn(x.encode()).hexdigest() def KD(s, d): return H('%s:%s' % (s, d)) @@ -356,14 +352,20 @@ def KD(s, d): ) respdig = KD(HA1, noncebit) else: - return '' + raise client_exceptions.ClientError( + 'Unsupported qop value: %s' % qop + ) - base = 'username="%s", realm="%s", nonce="%s", uri="%s", ' \ - 'response="%s"' % (self.username, realm, nonce, path, respdig) + base = ', '.join([ + 'username="%s"' % self.username, + 'realm="%s"' % realm, + 'nonce="%s"' % nonce, + 'uri="%s"' % path, + 'response="%s"' % respdig, + 'algorithm="%s"' % algorithm, + ]) if opaque: base += ', opaque="%s"' % opaque - if algorithm: - base += ', algorithm="%s"' % algorithm if qop: base += ', qop="auth", nc=%s, cnonce="%s"' % (ncvalue, cnonce) @@ -377,9 +379,7 @@ def _handle_401(self, response): """ auth_header = response.headers.get('www-authenticate', '') - if 'digest' in auth_header.lower() and self.num_401 < 2: - - self.num_401 += 1 + if 'digest' in auth_header.lower(): pattern = re.compile(r'digest ', flags=re.IGNORECASE) self.challenge = parse_key_value_list( pattern.sub('', auth_header, count=1) @@ -389,10 +389,9 @@ def _handle_401(self, response): self.args['method'], self.args['url'], headers=self.args['headers'], - **self.args['kwargs'], + **self.args['kwargs'] )) - self.num_401 = 1 return response diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 5c6033d5cc4..7cde46a0489 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -1352,6 +1352,8 @@ DigestAuth :param str login: login :param str password: password :param `ClientSession` session: underlying session that will use digest auth + :param dict previous: dict containing previous auth data. ``None`` by + default (optional). .. comethod:: request(method, url, *, params=None, data=None, \ json=None,\ @@ -1428,6 +1430,17 @@ DigestAuth auth = aiohttp.DigestAuth('usr', 'psswd', client) resp = await auth.request('GET', 'http://httpbin.org/digest-auth/auth/usr/psswd/MD5/never') assert resp.status == 200 + # If you don't re-use the DigestAuth object you can store this data + # and pass it as the last argument the next time you instantiate a + # DigestAuth object. For example, + # aiohttp.DigestAuth('usr', 'psswd', client, previous). This will + # save a second request being launched to re-authenticate. + previous = { + 'nonce_count': auth.nonce_count, + 'last_nonce': auth.last_nonce, + 'challenge': auth.challenge, + } + return await resp.text() async def main(): diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 94c0da3dc8f..1687edf74b7 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,4 +1,5 @@ import asyncio +import binascii import datetime import gc import hashlib @@ -11,7 +12,7 @@ import pytest from yarl import URL -from aiohttp import helpers, web +from aiohttp import helpers, web, client_exceptions # -------------------- coro guard -------------------------------- @@ -561,7 +562,7 @@ def test_dummy_cookie_jar(loop): @asyncio.coroutine def test_digest_auth_MD5(loop, test_client): - opaque = os.urandom(16).hex() + opaque = binascii.hexlify(os.urandom(16)) username = 'username' password = 'password' @@ -614,13 +615,13 @@ def handler(request): auth = helpers.DigestAuth(username, password, client) - resp = yield from auth.request('GET', '/') + resp = yield from auth.request('GET', '/?test=true') assert 200 == resp.status @asyncio.coroutine def test_digest_auth_MD5_sess(loop, test_client): - opaque = os.urandom(16).hex() + opaque = binascii.hexlify(os.urandom(16)) username = 'usr' password = 'psswd' @@ -680,7 +681,7 @@ def handler(request): @asyncio.coroutine def test_digest_auth_no_qop(loop, test_client): - opaque = os.urandom(16).hex() + opaque = binascii.hexlify(os.urandom(16)) username = 'root' password = 'passphrase' @@ -733,9 +734,50 @@ def handler(request): assert 200 == resp.status +@asyncio.coroutine +def test_digest_auth_qop_aut_int(loop, test_client): + opaque = binascii.hexlify(os.urandom(16)) + username = 'username' + password = 'password' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.md5(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + response = web.Response(status=401) + data = '%s:%s' % (time.ctime().encode(), 'secret') + nonce = hashlib.sha1(data.encode()).hexdigest() + pairs = ', '.join([ + 'realm="%s"' % realm, + 'algorithm="MD5"', + 'qop="auth-int"', + 'nonce="%s"' % nonce, + 'opaque="%s"' % opaque, + ]) + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs + return response + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + auth = helpers.DigestAuth(username, password, client) + + try: + yield from auth.request('GET', '/') + except client_exceptions.ClientError as e: + assert e.args[0] == 'Unsupported qop value: auth-int' + else: + assert False + + @asyncio.coroutine def test_digest_auth_sha(loop, test_client): - opaque = os.urandom(16).hex() + opaque = binascii.hexlify(os.urandom(16)) username = 'username' password = 'password' @@ -790,3 +832,224 @@ def handler(request): resp = yield from auth.request('GET', '/') assert 200 == resp.status + + +@asyncio.coroutine +def test_digest_auth_previous(loop, test_client): + username = 'username' + password = 'password' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.md5(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + if 'Authorization' in request.headers: + pattern = re.compile(r'digest ', flags=re.IGNORECASE) + auth_data = helpers.parse_key_value_list( + pattern.sub('', request.headers['Authorization'], count=1) + ) + + A1 = '%s:%s:%s' % (username, realm, password) + A2 = '%s:%s' % (request.method, auth_data['uri']) + + secret = H(A1) + data = '%s:%s:%s:%s:%s' % ( + auth_data['nonce'], + auth_data['nc'], + auth_data['cnonce'], + auth_data['qop'], + H(A2) + ) + + assert H('%s:%s' % (secret, data)) == auth_data['response'] + + return web.Response() + + assert False + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + # previous param comes from test_digest_auth_MD5. + auth = helpers.DigestAuth( + username, + password, + client, + { + 'nonce_count': 1, + 'last_nonce': '769ca61b934d2d9f5330f6208d4a14b2185a2c15', + 'challenge': { + 'qop': 'auth', + 'algorithm': 'MD5', + 'realm': 'test@example.com', + 'nonce': '769ca61b934d2d9f5330f6208d4a14b2185a2c15', + 'opaque': 'c3701d7ade598835ed0861fc179ee3ea', + } + }) + + resp = yield from auth.request('GET', '/') + assert 200 == resp.status + + +@asyncio.coroutine +def test_digest_auth_no_opaque(loop, test_client): + opaque = binascii.hexlify(os.urandom(16)) + username = 'username' + password = 'password' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.md5(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + if 'Authorization' in request.headers: + pattern = re.compile(r'digest ', flags=re.IGNORECASE) + auth_data = helpers.parse_key_value_list( + pattern.sub('', request.headers['Authorization'], count=1) + ) + + A1 = '%s:%s:%s' % (username, realm, password) + A2 = '%s:%s' % (request.method, auth_data['uri']) + + secret = H(A1) + data = '%s:%s:%s:%s:%s' % ( + auth_data['nonce'], + auth_data['nc'], + auth_data['cnonce'], + auth_data['qop'], + H(A2) + ) + + assert H('%s:%s' % (secret, data)) == auth_data['response'] + + return web.Response() + + response = web.Response(status=401) + data = '%s:%s' % (time.ctime().encode(), 'secret') + nonce = hashlib.sha1(data.encode()).hexdigest() + pairs = ', '.join([ + 'realm="%s"' % realm, + 'qop="auth"', + 'algorithm=MD5', + 'nonce="%s"' % nonce, + ]) + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs + return response + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + auth = helpers.DigestAuth(username, password, client) + + resp = yield from auth.request('GET', '/') + assert 200 == resp.status + + +@asyncio.coroutine +def test_digest_auth_bad_algorithm(loop, test_client): + opaque = binascii.hexlify(os.urandom(16)) + username = 'username' + password = 'password' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.md5(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + if 'Authorization' in request.headers: + assert request.headers['Authorization'] == '' + + return web.Response(status=401) + + response = web.Response(status=401) + data = '%s:%s' % (time.ctime().encode(), 'secret') + nonce = hashlib.sha1(data.encode()).hexdigest() + pairs = ', '.join([ + 'realm="%s"' % realm, + 'qop="auth"', + 'algorithm="nonsense"', + 'nonce="%s"' % nonce, + 'opaque="%s"' % opaque, + ]) + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs + return response + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + auth = helpers.DigestAuth(username, password, client) + + resp = yield from auth.request('GET', '/') + assert 401 == resp.status + + + +@asyncio.coroutine +def test_digest_auth_no_algorithm(loop, test_client): + opaque = binascii.hexlify(os.urandom(16)) + username = 'username' + password = 'password' + + def H(x): + if isinstance(x, str): + x = x.encode() + return hashlib.md5(x).hexdigest() + + @asyncio.coroutine + def handler(request): + realm = 'test@example.com' + if 'Authorization' in request.headers: + pattern = re.compile(r'digest ', flags=re.IGNORECASE) + auth_data = helpers.parse_key_value_list( + pattern.sub('', request.headers['Authorization'], count=1) + ) + + A1 = '%s:%s:%s' % (username, realm, password) + A2 = '%s:%s' % (request.method, auth_data['uri']) + + secret = H(A1) + data = '%s:%s:%s:%s:%s' % ( + auth_data['nonce'], + auth_data['nc'], + auth_data['cnonce'], + auth_data['qop'], + H(A2) + ) + + assert H('%s:%s' % (secret, data)) == auth_data['response'] + + return web.Response() + + response = web.Response(status=401) + data = '%s:%s' % (time.ctime().encode(), 'secret') + nonce = hashlib.sha1(data.encode()).hexdigest() + pairs = ', '.join([ + 'realm="%s"' % realm, + 'qop="auth"', + 'nonce="%s"' % nonce, + 'opaque="%s"' % opaque, + ]) + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs + return response + + app = web.Application() + app.router.add_route('GET', '/', handler) + client = yield from test_client(app) + + auth = helpers.DigestAuth(username, password, client) + + resp = yield from auth.request('GET', '/') + assert 200 == resp.status From c688ee16aa357a23f147c85ff7979bff0300e862 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 13:31:13 -0800 Subject: [PATCH 04/16] Added myself to CONTRIBUTORS, added news fragment, and fixed indentation of method in docs --- CONTRIBUTORS.txt | 1 + changes/2213.feature | 1 + docs/client_reference.rst | 72 +++++++++++++++++++-------------------- 3 files changed, 38 insertions(+), 36 deletions(-) create mode 100644 changes/2213.feature diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 1f50b3224e2..dd943888fb2 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -93,6 +93,7 @@ Jeroen van der Heijden Jesus Cea Jinkyu Yi Joel Watts +John Feusi Joongi Kim Josep Cugat Julia Tsemusheva diff --git a/changes/2213.feature b/changes/2213.feature new file mode 100644 index 00000000000..51ee34f40e7 --- /dev/null +++ b/changes/2213.feature @@ -0,0 +1 @@ +Added a digest authentication helper class. \ No newline at end of file diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 7cde46a0489..b886afd0fb6 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -1366,60 +1366,60 @@ DigestAuth read_until_eof=True) :coroutine: - Perform an asynchronous HTTP request. Return a response object - (:class:`ClientResponse` or derived from). + Perform an asynchronous HTTP request. Return a response object + (:class:`ClientResponse` or derived from). - :param str method: HTTP method + :param str method: HTTP method - :param url: Requested URL, :class:`str` or :class:`~yarl.URL` + :param url: Requested URL, :class:`str` or :class:`~yarl.URL` - :param dict params: Parameters to be sent in the query - string of the new request (optional) + :param dict params: Parameters to be sent in the query + string of the new request (optional) - :param data: Dictionary, bytes, or file-like object to - send in the body of the request (optional) + :param data: Dictionary, bytes, or file-like object to + send in the body of the request (optional) - :param json: Any json compatible python object (optional). *json* and *data* - parameters could not be used at the same time. + :param json: Any json compatible python object (optional). *json* and *data* + parameters could not be used at the same time. - :param dict headers: HTTP Headers to send with the request (optional) + :param dict headers: HTTP Headers to send with the request (optional) - :param dict cookies: Cookies to send with the request (optional) + :param dict cookies: Cookies to send with the request (optional) - :param aiohttp.BasicAuth auth: an object that represents HTTP Basic - Authorization (optional) + :param aiohttp.BasicAuth auth: an object that represents HTTP Basic + Authorization (optional) - :param bool allow_redirects: If set to ``False``, do not follow redirects. - ``True`` by default (optional). + :param bool allow_redirects: If set to ``False``, do not follow redirects. + ``True`` by default (optional). - :param aiohttp.protocol.HttpVersion version: Request HTTP version (optional) + :param aiohttp.protocol.HttpVersion version: Request HTTP version (optional) - :param bool compress: Set to ``True`` if request has to be compressed - with deflate encoding. - ``False`` instructs aiohttp to not compress data. - ``None`` by default (optional). + :param bool compress: Set to ``True`` if request has to be compressed + with deflate encoding. + ``False`` instructs aiohttp to not compress data. + ``None`` by default (optional). - :param int chunked: Enables chunked transfer encoding. - ``None`` by default (optional). + :param int chunked: Enables chunked transfer encoding. + ``None`` by default (optional). - :param bool expect100: Expect 100-continue response from server. - ``False`` by default (optional). + :param bool expect100: Expect 100-continue response from server. + ``False`` by default (optional). - :param aiohttp.connector.BaseConnector connector: BaseConnector sub-class - instance to support connection pooling. + :param aiohttp.connector.BaseConnector connector: BaseConnector sub-class + instance to support connection pooling. - :param bool read_until_eof: Read response until EOF if response - does not have Content-Length header. - ``True`` by default (optional). + :param bool read_until_eof: Read response until EOF if response + does not have Content-Length header. + ``True`` by default (optional). - :param loop: :ref:`event loop` - used for processing HTTP requests. - If param is ``None``, :func:`asyncio.get_event_loop` - is used for getting default event loop. + :param loop: :ref:`event loop` + used for processing HTTP requests. + If param is ``None``, :func:`asyncio.get_event_loop` + is used for getting default event loop. - .. deprecated:: 2.0 + .. deprecated:: 2.0 - :return ClientResponse: a :class:`client response ` object. + :return ClientResponse: a :class:`client response ` object. Usage:: From 483239f8f79f921df413763c37e795c3f95d6a0f Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 13:58:16 -0800 Subject: [PATCH 05/16] Switched from @asyncio.coroutine/yield to async/await --- aiohttp/helpers.py | 27 ++++-------- tests/test_helpers.py | 95 +++++++++++++++++-------------------------- 2 files changed, 44 insertions(+), 78 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 7beccfb7617..7c8ffbe4293 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -18,14 +18,13 @@ from contextlib import suppress from math import ceil from pathlib import Path -from time import gmtime from urllib.parse import quote, urlparse from urllib.request import getproxies import async_timeout from yarl import URL -from . import hdrs, client_exceptions +from . import client_exceptions, hdrs from .abc import AbstractAccessLogger from .log import client_logger @@ -227,8 +226,7 @@ def __init__(self, username, password, session, previous=None): self.args = {} self.session = session - @asyncio.coroutine - def request(self, method, url, *, headers=None, **kwargs): + async def request(self, method, url, *, headers=None, **kwargs): if headers is None: headers = {} @@ -245,7 +243,7 @@ def request(self, method, url, *, headers=None, **kwargs): method.upper(), url ) - response = yield from self.session.request( + response = await self.session.request( method, url, headers=headers, **kwargs ) @@ -254,7 +252,7 @@ def request(self, method, url, *, headers=None, **kwargs): if not 400 <= response.status < 500: return response - return (yield from self._handle_401(response)) + return await self._handle_401(response) def _build_digest_header(self, method, url): """ @@ -338,8 +336,7 @@ def KD(s, d): return 'Digest %s' % base - @asyncio.coroutine - def _handle_401(self, response): + async def _handle_401(self, response): """ Takes the given response and tries digest-auth, if needed. :rtype: ClientResponse @@ -352,26 +349,16 @@ def _handle_401(self, response): pattern.sub('', auth_header, count=1) ) - return (yield from self.request( + return await self.request( self.args['method'], self.args['url'], headers=self.args['headers'], **self.args['kwargs'] - )) + ) return response -if PY_352: - def create_future(loop): - return loop.create_future() -else: - def create_future(loop): # pragma: no cover - """Compatibility wrapper for the loop.create_future() call introduced in - 3.5.2.""" - return asyncio.Future(loop=loop) - - def strip_auth_from_url(url): auth = BasicAuth.from_url(url) if auth is None: diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 2041163f182..bea6964f9e7 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -5,7 +5,6 @@ import hashlib import os import re -import sys import tempfile import time from unittest import mock @@ -13,7 +12,7 @@ import pytest from yarl import URL -from aiohttp import helpers, web, client_exceptions +from aiohttp import client_exceptions, helpers, web from aiohttp.abc import AbstractAccessLogger @@ -513,8 +512,7 @@ def test_dummy_cookie_jar(loop): # -------------------------------- DigestAuth -------------------------- -@asyncio.coroutine -def test_digest_auth_MD5(loop, test_client): +async def test_digest_auth_MD5(loop, test_client): opaque = binascii.hexlify(os.urandom(16)) username = 'username' password = 'password' @@ -524,8 +522,7 @@ def H(x): x = x.encode() return hashlib.md5(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: pattern = re.compile(r'digest ', flags=re.IGNORECASE) @@ -564,16 +561,15 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) auth = helpers.DigestAuth(username, password, client) - resp = yield from auth.request('GET', '/?test=true') + resp = await auth.request('GET', '/?test=true') assert 200 == resp.status -@asyncio.coroutine -def test_digest_auth_MD5_sess(loop, test_client): +async def test_digest_auth_MD5_sess(loop, test_client): opaque = binascii.hexlify(os.urandom(16)) username = 'usr' password = 'psswd' @@ -583,8 +579,7 @@ def H(x): x = x.encode() return hashlib.md5(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: pattern = re.compile(r'digest ', flags=re.IGNORECASE) @@ -624,16 +619,15 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) auth = helpers.DigestAuth(username, password, client) - resp = yield from auth.request('GET', '/') + resp = await auth.request('GET', '/') assert 200 == resp.status -@asyncio.coroutine -def test_digest_auth_no_qop(loop, test_client): +async def test_digest_auth_no_qop(loop, test_client): opaque = binascii.hexlify(os.urandom(16)) username = 'root' password = 'passphrase' @@ -643,8 +637,7 @@ def H(x): x = x.encode() return hashlib.md5(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: pattern = re.compile(r'digest ', flags=re.IGNORECASE) @@ -679,16 +672,15 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) auth = helpers.DigestAuth(username, password, client) - resp = yield from auth.request('GET', '/') + resp = await auth.request('GET', '/') assert 200 == resp.status -@asyncio.coroutine -def test_digest_auth_qop_aut_int(loop, test_client): +async def test_digest_auth_qop_aut_int(loop, test_client): opaque = binascii.hexlify(os.urandom(16)) username = 'username' password = 'password' @@ -698,8 +690,7 @@ def H(x): x = x.encode() return hashlib.md5(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' response = web.Response(status=401) data = '%s:%s' % (time.ctime().encode(), 'secret') @@ -716,20 +707,19 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) auth = helpers.DigestAuth(username, password, client) try: - yield from auth.request('GET', '/') + await auth.request('GET', '/') except client_exceptions.ClientError as e: assert e.args[0] == 'Unsupported qop value: auth-int' else: assert False -@asyncio.coroutine -def test_digest_auth_sha(loop, test_client): +async def test_digest_auth_sha(loop, test_client): opaque = binascii.hexlify(os.urandom(16)) username = 'username' password = 'password' @@ -739,8 +729,7 @@ def H(x): x = x.encode() return hashlib.sha1(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: pattern = re.compile(r'digest ', flags=re.IGNORECASE) @@ -779,16 +768,15 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) auth = helpers.DigestAuth(username, password, client) - resp = yield from auth.request('GET', '/') + resp = await auth.request('GET', '/') assert 200 == resp.status -@asyncio.coroutine -def test_digest_auth_previous(loop, test_client): +async def test_digest_auth_previous(loop, test_client): username = 'username' password = 'password' @@ -797,8 +785,7 @@ def H(x): x = x.encode() return hashlib.md5(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: pattern = re.compile(r'digest ', flags=re.IGNORECASE) @@ -826,7 +813,7 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) # previous param comes from test_digest_auth_MD5. auth = helpers.DigestAuth( @@ -845,13 +832,11 @@ def handler(request): } }) - resp = yield from auth.request('GET', '/') + resp = await auth.request('GET', '/') assert 200 == resp.status -@asyncio.coroutine -def test_digest_auth_no_opaque(loop, test_client): - opaque = binascii.hexlify(os.urandom(16)) +async def test_digest_auth_no_opaque(loop, test_client): username = 'username' password = 'password' @@ -860,8 +845,7 @@ def H(x): x = x.encode() return hashlib.md5(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: pattern = re.compile(r'digest ', flags=re.IGNORECASE) @@ -899,16 +883,15 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) auth = helpers.DigestAuth(username, password, client) - resp = yield from auth.request('GET', '/') + resp = await auth.request('GET', '/') assert 200 == resp.status -@asyncio.coroutine -def test_digest_auth_bad_algorithm(loop, test_client): +async def test_digest_auth_bad_algorithm(loop, test_client): opaque = binascii.hexlify(os.urandom(16)) username = 'username' password = 'password' @@ -918,8 +901,7 @@ def H(x): x = x.encode() return hashlib.md5(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: assert request.headers['Authorization'] == '' @@ -941,17 +923,15 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) auth = helpers.DigestAuth(username, password, client) - resp = yield from auth.request('GET', '/') + resp = await auth.request('GET', '/') assert 401 == resp.status - -@asyncio.coroutine -def test_digest_auth_no_algorithm(loop, test_client): +async def test_digest_auth_no_algorithm(loop, test_client): opaque = binascii.hexlify(os.urandom(16)) username = 'username' password = 'password' @@ -961,8 +941,7 @@ def H(x): x = x.encode() return hashlib.md5(x).hexdigest() - @asyncio.coroutine - def handler(request): + async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: pattern = re.compile(r'digest ', flags=re.IGNORECASE) @@ -1000,11 +979,11 @@ def handler(request): app = web.Application() app.router.add_route('GET', '/', handler) - client = yield from test_client(app) + client = await test_client(app) auth = helpers.DigestAuth(username, password, client) - resp = yield from auth.request('GET', '/') + resp = await auth.request('GET', '/') assert 200 == resp.status From 224174f3689c115914fb0c0e06e1d5f259cc6269 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 17:27:41 -0800 Subject: [PATCH 06/16] Got rid of some scratch variables and did a little renaming --- aiohttp/helpers.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 7c8ffbe4293..029fda88864 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -300,22 +300,24 @@ def KD(s, d): ncvalue = '%08x' % self.nonce_count - k = str(self.nonce_count).encode() - k += nonce.encode() - k += time.ctime().encode() - k += os.urandom(8) - cnonce = (hashlib.sha1(k).hexdigest()[:16]) + # cnonce is just an opaque string generated by the client. + cnonce = hashlib.sha1(''.join([ + str(self.nonce_count).encode(), + nonce.encode(), + time.ctime().encode(), + os.urandom(8), + ])).hexdigest()[:16] if algorithm == 'MD5-SESS': HA1 = H('%s:%s:%s' % (HA1, nonce, cnonce)) if not qop: - respdig = KD(HA1, '%s:%s' % (nonce, HA2)) + response_digest = KD(HA1, '%s:%s' % (nonce, HA2)) elif qop == 'auth' or 'auth' in qop.split(','): - noncebit = '%s:%s:%s:%s:%s' % ( + noncebit = ':'.join([ nonce, ncvalue, cnonce, 'auth', HA2 - ) - respdig = KD(HA1, noncebit) + ]) + response_digest = KD(HA1, noncebit) else: raise client_exceptions.ClientError( 'Unsupported qop value: %s' % qop @@ -326,7 +328,7 @@ def KD(s, d): 'realm="%s"' % realm, 'nonce="%s"' % nonce, 'uri="%s"' % path, - 'response="%s"' % respdig, + 'response="%s"' % response_digest, 'algorithm="%s"' % algorithm, ]) if opaque: From 22f2c9db72b6f59cf09392d024368f5e92110c47 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 17:30:33 -0800 Subject: [PATCH 07/16] Fail fast for qop --- aiohttp/helpers.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 029fda88864..9a9ca6f7b41 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -265,6 +265,11 @@ def _build_digest_header(self, method, url): algorithm = self.challenge.get('algorithm', 'MD5').upper() opaque = self.challenge.get('opaque') + if qop and not (qop == 'auth' or 'auth' in qop.split(',')): + raise client_exceptions.ClientError( + 'Unsupported qop value: %s' % qop + ) + # lambdas assume digest modules are imported at the top level if algorithm == 'MD5' or algorithm == 'MD5-SESS': hash_fn = hashlib.md5 @@ -311,17 +316,15 @@ def KD(s, d): if algorithm == 'MD5-SESS': HA1 = H('%s:%s:%s' % (HA1, nonce, cnonce)) - if not qop: - response_digest = KD(HA1, '%s:%s' % (nonce, HA2)) - elif qop == 'auth' or 'auth' in qop.split(','): + # This assumes qop was validated to be 'auth' above. If 'auth-int' + # support is added this will need to change. + if qop: noncebit = ':'.join([ nonce, ncvalue, cnonce, 'auth', HA2 ]) response_digest = KD(HA1, noncebit) else: - raise client_exceptions.ClientError( - 'Unsupported qop value: %s' % qop - ) + response_digest = KD(HA1, '%s:%s' % (nonce, HA2)) base = ', '.join([ 'username="%s"' % self.username, From cf9b33383e66e96b543b1eb41bb12a3d498c8ad6 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 22:47:17 -0800 Subject: [PATCH 08/16] Fixed a string encoding problem --- aiohttp/helpers.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 9a9ca6f7b41..e74eb1a5f4c 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -305,13 +305,14 @@ def KD(s, d): ncvalue = '%08x' % self.nonce_count - # cnonce is just an opaque string generated by the client. - cnonce = hashlib.sha1(''.join([ - str(self.nonce_count).encode(), - nonce.encode(), - time.ctime().encode(), - os.urandom(8), - ])).hexdigest()[:16] + # cnonce is just a random string generated by the client. + cnonce_data = ''.join([ + str(self.nonce_count), + nonce, + time.ctime(), + os.urandom(8).decode(errors='ignore'), + ]).encode() + cnonce = hashlib.sha1(cnonce_data).hexdigest()[:16] if algorithm == 'MD5-SESS': HA1 = H('%s:%s:%s' % (HA1, nonce, cnonce)) From c47672f3184ca93327f597b660bf7b7b3b5489aa Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 22:50:59 -0800 Subject: [PATCH 09/16] replaced regex --- aiohttp/helpers.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index e74eb1a5f4c..7d8e2799390 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -349,11 +349,9 @@ async def _handle_401(self, response): """ auth_header = response.headers.get('www-authenticate', '') - if 'digest' in auth_header.lower(): - pattern = re.compile(r'digest ', flags=re.IGNORECASE) - self.challenge = parse_key_value_list( - pattern.sub('', auth_header, count=1) - ) + first_word, data = auth_header.split(' ', 1) + if 'digest' == first_word.lower(): + self.challenge = parse_key_value_list(data) return await self.request( self.args['method'], From 6e0b534a8107489063741bd15268bef9d3840027 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 22:56:38 -0800 Subject: [PATCH 10/16] Updated docs as per PR comments --- docs/client_reference.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 264dcd2fe07..4492aa5d5b5 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -1430,7 +1430,7 @@ DigestAuth :param dict params: Parameters to be sent in the query string of the new request (optional) - :param data: Dictionary, bytes, or file-like object to + :param dict|bytes|file data: Dictionary, bytes, or file-like object to send in the body of the request (optional) :param json: Any json compatible python object (optional). *json* and *data* @@ -1471,9 +1471,9 @@ DigestAuth If param is ``None``, :func:`asyncio.get_event_loop` is used for getting default event loop. - .. deprecated:: 2.0 + .. deprecated:: 2.0 - :return ClientResponse: a :class:`client response ` object. + :rtype: :class:`client response ` Usage:: From bc15623951472ee0440c7896b3cf4138fbbbe437 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 23:00:41 -0800 Subject: [PATCH 11/16] Can't assume there will always be two parts --- aiohttp/helpers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 7d8e2799390..450be5e3b45 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -349,9 +349,9 @@ async def _handle_401(self, response): """ auth_header = response.headers.get('www-authenticate', '') - first_word, data = auth_header.split(' ', 1) - if 'digest' == first_word.lower(): - self.challenge = parse_key_value_list(data) + parts = auth_header.split(' ', 1) + if 'digest' == parts[0].lower() and len(parts) > 1: + self.challenge = parse_key_value_list(parts[1]) return await self.request( self.args['method'], From 847de7bebb0576260265cb6db01f047e7fd43246 Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 18 Nov 2017 23:05:55 -0800 Subject: [PATCH 12/16] Switched over to yarl --- aiohttp/helpers.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 450be5e3b45..92856c8bdda 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -18,7 +18,7 @@ from contextlib import suppress from math import ceil from pathlib import Path -from urllib.parse import quote, urlparse +from urllib.parse import quote from urllib.request import getproxies import async_timeout @@ -284,12 +284,7 @@ def H(x): def KD(s, d): return H('%s:%s' % (s, d)) - parsed = urlparse(url) - #: path is request-uri defined in RFC 2616 which should not be empty - path = parsed.path or "/" - if parsed.query: - path += '?' + parsed.query - + path = URL(url).path_qs A1 = '%s:%s:%s' % (self.username, realm, self.password) A2 = '%s:%s' % (method, path) From e45403263ba245c1b0400d98e197d50bf08510d8 Mon Sep 17 00:00:00 2001 From: jf Date: Sun, 19 Nov 2017 00:00:37 -0800 Subject: [PATCH 13/16] Tried to factor out repetition in digest auth tests --- tests/test_helpers.py | 462 +++++++----------------------------------- 1 file changed, 69 insertions(+), 393 deletions(-) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index bea6964f9e7..d3d5139069b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,10 +1,8 @@ import asyncio -import binascii import datetime import gc import hashlib import os -import re import tempfile import time from unittest import mock @@ -512,37 +510,48 @@ def test_dummy_cookie_jar(loop): # -------------------------------- DigestAuth -------------------------- -async def test_digest_auth_MD5(loop, test_client): - opaque = binascii.hexlify(os.urandom(16)) - username = 'username' - password = 'password' +digest_username = 'username' +digest_password = 'password' - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.md5(x).hexdigest() +def make_digest_handler(algorithm='MD5', qop='auth', opaque=True): async def handler(request): realm = 'test@example.com' if 'Authorization' in request.headers: - pattern = re.compile(r'digest ', flags=re.IGNORECASE) + if algorithm == 'invalid': + assert request.headers['Authorization'] == '' + return web.Response(status=401) + auth_data = helpers.parse_key_value_list( - pattern.sub('', request.headers['Authorization'], count=1) + request.headers['Authorization'].split(' ', 1)[1] ) - A1 = '%s:%s:%s' % (username, realm, password) - A2 = '%s:%s' % (request.method, auth_data['uri']) + request_algorithm = auth_data.get('algorithm', '').upper() + if request_algorithm == 'SHA': + def H(x): + return hashlib.sha1(x.encode()).hexdigest() + else: + def H(x): + return hashlib.md5(x.encode()).hexdigest() - secret = H(A1) - data = '%s:%s:%s:%s:%s' % ( - auth_data['nonce'], - auth_data['nc'], - auth_data['cnonce'], - auth_data['qop'], - H(A2) - ) + A1 = ':'.join([digest_username, realm, digest_password]) + if request_algorithm == 'MD5-SESS': + A1 = ':'.join([H(A1), auth_data['nonce'], auth_data['cnonce']]) + A2 = ':'.join([request.method, auth_data['uri']]) - assert H('%s:%s' % (secret, data)) == auth_data['response'] + secret = H(A1) + if not qop: + data = ':'.join([auth_data['nonce'], H(A2)]) + else: + data = ':'.join([ + auth_data['nonce'], + auth_data['nc'], + auth_data['cnonce'], + auth_data['qop'], + H(A2) + ]) + + assert H(':'.join([secret, data])) == auth_data['response'] return web.Response() @@ -551,165 +560,74 @@ async def handler(request): nonce = hashlib.sha1(data.encode()).hexdigest() pairs = ', '.join([ 'realm="%s"' % realm, - 'qop="auth"', - 'algorithm=MD5', - 'nonce="%s"' % nonce, - 'opaque="%s"' % opaque, + 'nonce="%s"' % nonce ]) + + if algorithm: + pairs += ', algorithm=%s' % algorithm + if qop: + pairs += ', qop="%s"' % qop + if opaque: + pairs += ', opaque="abcdef0123456789"' + response.headers['WWW-Authenticate'] = 'Digest %s' % pairs return response + return handler + + +async def run_digest_auth_test(test_client, **kwargs): app = web.Application() - app.router.add_route('GET', '/', handler) + app.router.add_route('GET', '/', make_digest_handler(**kwargs)) client = await test_client(app) - auth = helpers.DigestAuth(username, password, client) + auth = helpers.DigestAuth(digest_username, digest_password, client) resp = await auth.request('GET', '/?test=true') assert 200 == resp.status -async def test_digest_auth_MD5_sess(loop, test_client): - opaque = binascii.hexlify(os.urandom(16)) - username = 'usr' - password = 'psswd' - - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.md5(x).hexdigest() +async def test_digest_auth_MD5(loop, test_client): + await run_digest_auth_test(test_client) - async def handler(request): - realm = 'test@example.com' - if 'Authorization' in request.headers: - pattern = re.compile(r'digest ', flags=re.IGNORECASE) - auth_data = helpers.parse_key_value_list( - pattern.sub('', request.headers['Authorization'], count=1) - ) - A1 = '%s:%s:%s' % (username, realm, password) - A1 = '%s:%s:%s' % (H(A1), auth_data['nonce'], auth_data['cnonce']) - A2 = '%s:%s' % (request.method, auth_data['uri']) +async def test_digest_auth_MD5_sess(loop, test_client): + await run_digest_auth_test(test_client, algorithm='MD5-SESS') - secret = H(A1) - data = '%s:%s:%s:%s:%s' % ( - auth_data['nonce'], - auth_data['nc'], - auth_data['cnonce'], - auth_data['qop'], - H(A2) - ) - assert H('%s:%s' % (secret, data)) == auth_data['response'] +async def test_digest_auth_sha(loop, test_client): + await run_digest_auth_test(test_client, algorithm='SHA') - return web.Response() - - response = web.Response(status=401) - data = '%s:%s' % (time.ctime().encode(), 'secret') - nonce = hashlib.sha1(data.encode()).hexdigest() - pairs = ', '.join([ - 'realm="%s"' % realm, - 'qop="auth"', - 'algorithm=MD5-sess', - 'nonce="%s"' % nonce, - 'opaque="%s"' % opaque, - ]) - response.headers['WWW-Authenticate'] = 'Digest %s' % pairs - return response +async def test_digest_auth_bad_algorithm(loop, test_client): app = web.Application() - app.router.add_route('GET', '/', handler) + app.router.add_route('GET', '/', make_digest_handler(algorithm='invalid')) client = await test_client(app) - auth = helpers.DigestAuth(username, password, client) - - resp = await auth.request('GET', '/') - assert 200 == resp.status - - -async def test_digest_auth_no_qop(loop, test_client): - opaque = binascii.hexlify(os.urandom(16)) - username = 'root' - password = 'passphrase' - - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.md5(x).hexdigest() + auth = helpers.DigestAuth(digest_username, digest_password, client) - async def handler(request): - realm = 'test@example.com' - if 'Authorization' in request.headers: - pattern = re.compile(r'digest ', flags=re.IGNORECASE) - auth_data = helpers.parse_key_value_list( - pattern.sub('', request.headers['Authorization'], count=1) - ) - - A1 = '%s:%s:%s' % (username, realm, password) - A2 = '%s:%s' % (request.method, auth_data['uri']) - - secret = H(A1) - data = '%s:%s' % ( - auth_data['nonce'], - H(A2) - ) + resp = await auth.request('GET', '/?test=true') + assert 401 == resp.status - assert H('%s:%s' % (secret, data)) == auth_data['response'] - return web.Response() +async def test_digest_auth_no_algorithm(loop, test_client): + await run_digest_auth_test(test_client, algorithm=None) - response = web.Response(status=401) - data = '%s:%s' % (time.ctime().encode(), 'secret') - nonce = hashlib.sha1(data.encode()).hexdigest() - pairs = ', '.join([ - 'realm="%s"' % realm, - 'algorithm=MD5', - 'nonce="%s"' % nonce, - 'opaque="%s"' % opaque, - ]) - response.headers['WWW-Authenticate'] = 'Digest %s' % pairs - return response - app = web.Application() - app.router.add_route('GET', '/', handler) - client = await test_client(app) +async def test_digest_auth_no_opaque(loop, test_client): + await run_digest_auth_test(test_client, opaque=False) - auth = helpers.DigestAuth(username, password, client) - resp = await auth.request('GET', '/') - assert 200 == resp.status +async def test_digest_auth_no_qop(loop, test_client): + await run_digest_auth_test(test_client, qop=None) async def test_digest_auth_qop_aut_int(loop, test_client): - opaque = binascii.hexlify(os.urandom(16)) - username = 'username' - password = 'password' - - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.md5(x).hexdigest() - - async def handler(request): - realm = 'test@example.com' - response = web.Response(status=401) - data = '%s:%s' % (time.ctime().encode(), 'secret') - nonce = hashlib.sha1(data.encode()).hexdigest() - pairs = ', '.join([ - 'realm="%s"' % realm, - 'algorithm="MD5"', - 'qop="auth-int"', - 'nonce="%s"' % nonce, - 'opaque="%s"' % opaque, - ]) - response.headers['WWW-Authenticate'] = 'Digest %s' % pairs - return response - app = web.Application() - app.router.add_route('GET', '/', handler) + app.router.add_route('GET', '/', make_digest_handler(qop='auth-int')) client = await test_client(app) - auth = helpers.DigestAuth(username, password, client) + auth = helpers.DigestAuth(digest_username, digest_password, client) try: await auth.request('GET', '/') @@ -719,106 +637,15 @@ async def handler(request): assert False -async def test_digest_auth_sha(loop, test_client): - opaque = binascii.hexlify(os.urandom(16)) - username = 'username' - password = 'password' - - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.sha1(x).hexdigest() - - async def handler(request): - realm = 'test@example.com' - if 'Authorization' in request.headers: - pattern = re.compile(r'digest ', flags=re.IGNORECASE) - auth_data = helpers.parse_key_value_list( - pattern.sub('', request.headers['Authorization'], count=1) - ) - - A1 = '%s:%s:%s' % (username, realm, password) - A2 = '%s:%s' % (request.method, auth_data['uri']) - - secret = H(A1) - data = '%s:%s:%s:%s:%s' % ( - auth_data['nonce'], - auth_data['nc'], - auth_data['cnonce'], - auth_data['qop'], - H(A2) - ) - - assert H('%s:%s' % (secret, data)) == auth_data['response'] - - return web.Response() - - response = web.Response(status=401) - data = '%s:%s' % (time.ctime().encode(), 'secret') - nonce = hashlib.sha1(data.encode()).hexdigest() - pairs = ', '.join([ - 'realm="%s"' % realm, - 'qop="auth"', - 'algorithm=SHA', - 'nonce="%s"' % nonce, - 'opaque="%s"' % opaque, - ]) - response.headers['WWW-Authenticate'] = 'Digest %s' % pairs - return response - - app = web.Application() - app.router.add_route('GET', '/', handler) - client = await test_client(app) - - auth = helpers.DigestAuth(username, password, client) - - resp = await auth.request('GET', '/') - assert 200 == resp.status - - async def test_digest_auth_previous(loop, test_client): - username = 'username' - password = 'password' - - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.md5(x).hexdigest() - - async def handler(request): - realm = 'test@example.com' - if 'Authorization' in request.headers: - pattern = re.compile(r'digest ', flags=re.IGNORECASE) - auth_data = helpers.parse_key_value_list( - pattern.sub('', request.headers['Authorization'], count=1) - ) - - A1 = '%s:%s:%s' % (username, realm, password) - A2 = '%s:%s' % (request.method, auth_data['uri']) - - secret = H(A1) - data = '%s:%s:%s:%s:%s' % ( - auth_data['nonce'], - auth_data['nc'], - auth_data['cnonce'], - auth_data['qop'], - H(A2) - ) - - assert H('%s:%s' % (secret, data)) == auth_data['response'] - - return web.Response() - - assert False - app = web.Application() - app.router.add_route('GET', '/', handler) + app.router.add_route('GET', '/', make_digest_handler()) client = await test_client(app) # previous param comes from test_digest_auth_MD5. auth = helpers.DigestAuth( - username, - password, + digest_username, + digest_password, client, { 'nonce_count': 1, @@ -836,157 +663,6 @@ async def handler(request): assert 200 == resp.status -async def test_digest_auth_no_opaque(loop, test_client): - username = 'username' - password = 'password' - - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.md5(x).hexdigest() - - async def handler(request): - realm = 'test@example.com' - if 'Authorization' in request.headers: - pattern = re.compile(r'digest ', flags=re.IGNORECASE) - auth_data = helpers.parse_key_value_list( - pattern.sub('', request.headers['Authorization'], count=1) - ) - - A1 = '%s:%s:%s' % (username, realm, password) - A2 = '%s:%s' % (request.method, auth_data['uri']) - - secret = H(A1) - data = '%s:%s:%s:%s:%s' % ( - auth_data['nonce'], - auth_data['nc'], - auth_data['cnonce'], - auth_data['qop'], - H(A2) - ) - - assert H('%s:%s' % (secret, data)) == auth_data['response'] - - return web.Response() - - response = web.Response(status=401) - data = '%s:%s' % (time.ctime().encode(), 'secret') - nonce = hashlib.sha1(data.encode()).hexdigest() - pairs = ', '.join([ - 'realm="%s"' % realm, - 'qop="auth"', - 'algorithm=MD5', - 'nonce="%s"' % nonce, - ]) - response.headers['WWW-Authenticate'] = 'Digest %s' % pairs - return response - - app = web.Application() - app.router.add_route('GET', '/', handler) - client = await test_client(app) - - auth = helpers.DigestAuth(username, password, client) - - resp = await auth.request('GET', '/') - assert 200 == resp.status - - -async def test_digest_auth_bad_algorithm(loop, test_client): - opaque = binascii.hexlify(os.urandom(16)) - username = 'username' - password = 'password' - - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.md5(x).hexdigest() - - async def handler(request): - realm = 'test@example.com' - if 'Authorization' in request.headers: - assert request.headers['Authorization'] == '' - - return web.Response(status=401) - - response = web.Response(status=401) - data = '%s:%s' % (time.ctime().encode(), 'secret') - nonce = hashlib.sha1(data.encode()).hexdigest() - pairs = ', '.join([ - 'realm="%s"' % realm, - 'qop="auth"', - 'algorithm="nonsense"', - 'nonce="%s"' % nonce, - 'opaque="%s"' % opaque, - ]) - response.headers['WWW-Authenticate'] = 'Digest %s' % pairs - return response - - app = web.Application() - app.router.add_route('GET', '/', handler) - client = await test_client(app) - - auth = helpers.DigestAuth(username, password, client) - - resp = await auth.request('GET', '/') - assert 401 == resp.status - - -async def test_digest_auth_no_algorithm(loop, test_client): - opaque = binascii.hexlify(os.urandom(16)) - username = 'username' - password = 'password' - - def H(x): - if isinstance(x, str): - x = x.encode() - return hashlib.md5(x).hexdigest() - - async def handler(request): - realm = 'test@example.com' - if 'Authorization' in request.headers: - pattern = re.compile(r'digest ', flags=re.IGNORECASE) - auth_data = helpers.parse_key_value_list( - pattern.sub('', request.headers['Authorization'], count=1) - ) - - A1 = '%s:%s:%s' % (username, realm, password) - A2 = '%s:%s' % (request.method, auth_data['uri']) - - secret = H(A1) - data = '%s:%s:%s:%s:%s' % ( - auth_data['nonce'], - auth_data['nc'], - auth_data['cnonce'], - auth_data['qop'], - H(A2) - ) - - assert H('%s:%s' % (secret, data)) == auth_data['response'] - - return web.Response() - - response = web.Response(status=401) - data = '%s:%s' % (time.ctime().encode(), 'secret') - nonce = hashlib.sha1(data.encode()).hexdigest() - pairs = ', '.join([ - 'realm="%s"' % realm, - 'qop="auth"', - 'nonce="%s"' % nonce, - 'opaque="%s"' % opaque, - ]) - response.headers['WWW-Authenticate'] = 'Digest %s' % pairs - return response - - app = web.Application() - app.router.add_route('GET', '/', handler) - client = await test_client(app) - - auth = helpers.DigestAuth(username, password, client) - - resp = await auth.request('GET', '/') - assert 200 == resp.status - - # --------------------- proxies_from_env ------------------------------ def test_proxies_from_env_http(mocker): From a995be5f714b072476da5c8bddac68c5c98b7ed9 Mon Sep 17 00:00:00 2001 From: jf Date: Sun, 19 Nov 2017 11:52:19 -0800 Subject: [PATCH 14/16] Swapped some logic around --- aiohttp/helpers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 92856c8bdda..96281135f49 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -247,12 +247,12 @@ async def request(self, method, url, *, headers=None, **kwargs): method, url, headers=headers, **kwargs ) - # If the response is not in the 400 range, do not try digest - # authentication. - if not 400 <= response.status < 500: - return response + # Only try performing digest authentication if the response status is + # from 400 to 500. + if 400 <= response.status < 500: + return await self._handle_401(response) - return await self._handle_401(response) + return response def _build_digest_header(self, method, url): """ From 587a54bb7201f44df23ffbc9e929ee559746865f Mon Sep 17 00:00:00 2001 From: jf Date: Wed, 22 Nov 2017 16:52:18 -0800 Subject: [PATCH 15/16] Must have added this in when trying to fix merge conflicts --- tests/test_helpers.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d3d5139069b..1dfe8e708fd 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -496,18 +496,6 @@ def test_set_content_disposition_bad_param(): **{'foo\x00bar': 'baz'}) -def test_dummy_cookie_jar(loop): - cookie = helpers.SimpleCookie('foo=bar; Domain=example.com;') - dummy_jar = helpers.DummyCookieJar(loop=loop) - assert len(dummy_jar) == 0 - dummy_jar.update_cookies(cookie) - assert len(dummy_jar) == 0 - with pytest.raises(StopIteration): - next(iter(dummy_jar)) - assert dummy_jar.filter_cookies(URL("http://example.com/")) is None - dummy_jar.clear() - - # -------------------------------- DigestAuth -------------------------- digest_username = 'username' From 6eac2039cccc11104f258679d4f7873bc78f6c1b Mon Sep 17 00:00:00 2001 From: jf Date: Sat, 25 Nov 2017 10:49:54 -0800 Subject: [PATCH 16/16] Using comprehension instead of map, and removed autodoc markup --- aiohttp/helpers.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index d2c7b5ff589..8574b6ede15 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -121,7 +121,7 @@ def parse_pair(pair): def parse_key_value_list(header): return { key: value for key, value in - map(parse_pair, header.split(' ')) + [parse_pair(header_pair) for header_pair in header.split(' ')] } @@ -129,10 +129,6 @@ class DigestAuth(): """HTTP digest authentication helper. The work here is based off of https://github.com/requests/requests/blob/v2.18.4/requests/auth.py. - - :param str username: Username or login - :param str password: Password - :param ClientSession session: Session to use digest auth """ def __init__(self, username, password, session, previous=None):