Skip to content

Commit 2a8023d

Browse files
feus4177TimMenninger
authored andcommitted
Added a digest authentication helper
This is the initial work by jf from aio-libs#2213, which I rebased onto the tip of master. It is fully brought back to life in subsequent commits.
1 parent 473746d commit 2a8023d

File tree

5 files changed

+450
-3
lines changed

5 files changed

+450
-3
lines changed

Diff for: CHANGES/2218.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a digest authentication helper class.

Diff for: CONTRIBUTORS.txt

+1
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ Jesus Cea
193193
Jian Zeng
194194
Jinkyu Yi
195195
Joel Watts
196+
John Feusi
196197
John Parton
197198
Jon Nabozny
198199
Jonas Krüger Svensson

Diff for: aiohttp/helpers.py

+176-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import datetime
99
import enum
1010
import functools
11+
import hashlib
1112
import inspect
1213
import netrc
1314
import os
@@ -53,7 +54,7 @@
5354
from propcache.api import under_cached_property as reify
5455
from yarl import URL
5556

56-
from . import hdrs
57+
from . import client_exceptions, hdrs
5758
from .log import client_logger
5859
from .typedefs import PathLike # noqa
5960

@@ -71,7 +72,14 @@
7172
dataclasses.dataclass, frozen=True, slots=True
7273
)
7374

74-
__all__ = ("BasicAuth", "ChainMapProxy", "ETag", "frozen_dataclass_decorator", "reify")
75+
__all__ = (
76+
"BasicAuth",
77+
"ChainMapProxy",
78+
"ETag",
79+
"frozen_dataclass_decorator",
80+
"reify",
81+
"DigestAuth",
82+
)
7583

7684
PY_310 = sys.version_info >= (3, 10)
7785

@@ -279,6 +287,172 @@ def basicauth_from_netrc(netrc_obj: Optional[netrc.netrc], host: str) -> BasicAu
279287
return BasicAuth(username, password)
280288

281289

290+
def parse_pair(pair):
291+
key, value = pair.split("=", 1)
292+
293+
# If it has a trailing comma, remove it.
294+
if value[-1] == ",":
295+
value = value[:-1]
296+
297+
# If it is quoted, then remove them.
298+
if value[0] == value[-1] == '"':
299+
value = value[1:-1]
300+
301+
return key, value
302+
303+
304+
def parse_key_value_list(header):
305+
return {
306+
key: value
307+
for key, value in [parse_pair(header_pair) for header_pair in header.split(" ")]
308+
}
309+
310+
311+
class DigestAuth:
312+
"""
313+
HTTP digest authentication helper.
314+
315+
The work here is based off of
316+
https://github.com/requests/requests/blob/v2.18.4/requests/auth.py.
317+
"""
318+
319+
def __init__(self, username, password, session, previous=None):
320+
if previous is None:
321+
previous = {}
322+
323+
self.username = username
324+
self.password = password
325+
self.last_nonce = previous.get("last_nonce", "")
326+
self.nonce_count = previous.get("nonce_count", 0)
327+
self.challenge = previous.get("challenge")
328+
self.args = {}
329+
self.session = session
330+
331+
async def request(self, method, url, *, headers=None, **kwargs):
332+
if headers is None:
333+
headers = {}
334+
335+
# Save the args so we can re-run the request
336+
self.args = {"method": method, "url": url, "headers": headers, "kwargs": kwargs}
337+
338+
if self.challenge:
339+
headers[hdrs.AUTHORIZATION] = self._build_digest_header(method.upper(), url)
340+
341+
response = await self.session.request(method, url, headers=headers, **kwargs)
342+
343+
# Only try performing digest authentication if the response status is
344+
# from 400 to 500.
345+
if 400 <= response.status < 500:
346+
return await self._handle_401(response)
347+
348+
return response
349+
350+
def _build_digest_header(self, method, url):
351+
"""
352+
Build digest header
353+
354+
:rtype: str
355+
"""
356+
realm = self.challenge["realm"]
357+
nonce = self.challenge["nonce"]
358+
qop = self.challenge.get("qop")
359+
algorithm = self.challenge.get("algorithm", "MD5").upper()
360+
opaque = self.challenge.get("opaque")
361+
362+
if qop and not (qop == "auth" or "auth" in qop.split(",")):
363+
raise client_exceptions.ClientError("Unsupported qop value: %s" % qop)
364+
365+
# lambdas assume digest modules are imported at the top level
366+
if algorithm == "MD5" or algorithm == "MD5-SESS":
367+
hash_fn = hashlib.md5
368+
elif algorithm == "SHA":
369+
hash_fn = hashlib.sha1
370+
else:
371+
return ""
372+
373+
def H(x):
374+
return hash_fn(x.encode()).hexdigest()
375+
376+
def KD(s, d):
377+
return H(f"{s}:{d}")
378+
379+
path = URL(url).path_qs
380+
A1 = f"{self.username}:{realm}:{self.password}"
381+
A2 = f"{method}:{path}"
382+
383+
HA1 = H(A1)
384+
HA2 = H(A2)
385+
386+
if nonce == self.last_nonce:
387+
self.nonce_count += 1
388+
else:
389+
self.nonce_count = 1
390+
391+
self.last_nonce = nonce
392+
393+
ncvalue = "%08x" % self.nonce_count
394+
395+
# cnonce is just a random string generated by the client.
396+
cnonce_data = "".join(
397+
[
398+
str(self.nonce_count),
399+
nonce,
400+
time.ctime(),
401+
os.urandom(8).decode(errors="ignore"),
402+
]
403+
).encode()
404+
cnonce = hashlib.sha1(cnonce_data).hexdigest()[:16]
405+
406+
if algorithm == "MD5-SESS":
407+
HA1 = H(f"{HA1}:{nonce}:{cnonce}")
408+
409+
# This assumes qop was validated to be 'auth' above. If 'auth-int'
410+
# support is added this will need to change.
411+
if qop:
412+
noncebit = ":".join([nonce, ncvalue, cnonce, "auth", HA2])
413+
response_digest = KD(HA1, noncebit)
414+
else:
415+
response_digest = KD(HA1, f"{nonce}:{HA2}")
416+
417+
base = ", ".join(
418+
[
419+
'username="%s"' % self.username,
420+
'realm="%s"' % realm,
421+
'nonce="%s"' % nonce,
422+
'uri="%s"' % path,
423+
'response="%s"' % response_digest,
424+
'algorithm="%s"' % algorithm,
425+
]
426+
)
427+
if opaque:
428+
base += ', opaque="%s"' % opaque
429+
if qop:
430+
base += f', qop="auth", nc={ncvalue}, cnonce="{cnonce}"'
431+
432+
return "Digest %s" % base
433+
434+
async def _handle_401(self, response):
435+
"""
436+
Takes the given response and tries digest-auth, if needed.
437+
438+
:rtype: ClientResponse
439+
"""
440+
auth_header = response.headers.get("www-authenticate", "")
441+
442+
parts = auth_header.split(" ", 1)
443+
if "digest" == parts[0].lower() and len(parts) > 1:
444+
self.challenge = parse_key_value_list(parts[1])
445+
446+
return await self.request(
447+
self.args["method"],
448+
self.args["url"],
449+
headers=self.args["headers"],
450+
**self.args["kwargs"],
451+
)
452+
453+
return response
454+
455+
282456
def proxies_from_env() -> Dict[str, ProxyInfo]:
283457
proxy_urls = {
284458
k: URL(v)

Diff for: docs/client_reference.rst

+111
Original file line numberDiff line numberDiff line change
@@ -2011,6 +2011,117 @@ Utilities
20112011
:return: encoded authentication data, :class:`str`.
20122012

20132013

2014+
DigestAuth
2015+
^^^^^^^^^^
2016+
2017+
.. class:: DigestAuth(login, password', session)
2018+
2019+
HTTP digest authentication helper. Unlike :class:`DigestAuth`, this helper
2020+
CANNOT be passed to the *auth* parameter of a :meth:`ClientSession.request`.
2021+
2022+
:param str login: login
2023+
:param str password: password
2024+
:param `ClientSession` session: underlying session that will use digest auth
2025+
:param dict previous: dict containing previous auth data. ``None`` by
2026+
default (optional).
2027+
2028+
.. comethod:: request(method, url, *, params=None, data=None, \
2029+
json=None,\
2030+
headers=None, cookies=None, auth=None, \
2031+
allow_redirects=True, max_redirects=10, \
2032+
encoding='utf-8', \
2033+
version=HttpVersion(major=1, minor=1), \
2034+
compress=None, chunked=None, expect100=False, \
2035+
connector=None, loop=None,\
2036+
read_until_eof=True)
2037+
:coroutine:
2038+
2039+
Perform an asynchronous HTTP request. Return a response object
2040+
(:class:`ClientResponse` or derived from).
2041+
2042+
:param str method: HTTP method
2043+
2044+
:param url: Requested URL, :class:`str` or :class:`~yarl.URL`
2045+
2046+
:param dict params: Parameters to be sent in the query
2047+
string of the new request (optional)
2048+
2049+
:param dict|bytes|file data: Dictionary, bytes, or file-like object to
2050+
send in the body of the request (optional)
2051+
2052+
:param json: Any json compatible python object (optional). *json* and *data*
2053+
parameters could not be used at the same time.
2054+
2055+
:param dict headers: HTTP Headers to send with the request (optional)
2056+
2057+
:param dict cookies: Cookies to send with the request (optional)
2058+
2059+
:param aiohttp.BasicAuth auth: an object that represents HTTP Basic
2060+
Authorization (optional)
2061+
2062+
:param bool allow_redirects: If set to ``False``, do not follow redirects.
2063+
``True`` by default (optional).
2064+
2065+
:param aiohttp.protocol.HttpVersion version: Request HTTP version (optional)
2066+
2067+
:param bool compress: Set to ``True`` if request has to be compressed
2068+
with deflate encoding.
2069+
``False`` instructs aiohttp to not compress data.
2070+
``None`` by default (optional).
2071+
2072+
:param int chunked: Enables chunked transfer encoding.
2073+
``None`` by default (optional).
2074+
2075+
:param bool expect100: Expect 100-continue response from server.
2076+
``False`` by default (optional).
2077+
2078+
:param aiohttp.connector.BaseConnector connector: BaseConnector sub-class
2079+
instance to support connection pooling.
2080+
2081+
:param bool read_until_eof: Read response until EOF if response
2082+
does not have Content-Length header.
2083+
``True`` by default (optional).
2084+
2085+
:param loop: :ref:`event loop<asyncio-event-loop>`
2086+
used for processing HTTP requests.
2087+
If param is ``None``, :func:`asyncio.get_event_loop`
2088+
is used for getting default event loop.
2089+
2090+
.. deprecated:: 2.0
2091+
2092+
:rtype: :class:`client response <ClientResponse>`
2093+
2094+
Usage::
2095+
2096+
import aiohttp
2097+
import asyncio
2098+
2099+
async def fetch(client):
2100+
auth = aiohttp.DigestAuth('usr', 'psswd', client)
2101+
resp = await auth.request('GET', 'http://httpbin.org/digest-auth/auth/usr/psswd/MD5/never')
2102+
assert resp.status == 200
2103+
# If you don't reuse the DigestAuth object you can store this data
2104+
# and pass it as the last argument the next time you instantiate a
2105+
# DigestAuth object. For example,
2106+
# aiohttp.DigestAuth('usr', 'psswd', client, previous). This will
2107+
# save a second request being launched to re-authenticate.
2108+
previous = {
2109+
'nonce_count': auth.nonce_count,
2110+
'last_nonce': auth.last_nonce,
2111+
'challenge': auth.challenge,
2112+
}
2113+
2114+
return await resp.text()
2115+
2116+
async def main():
2117+
async with aiohttp.ClientSession() as client:
2118+
text = await fetch(client)
2119+
print(text)
2120+
2121+
loop = asyncio.get_event_loop()
2122+
loop.run_until_complete(main())
2123+
2124+
20142125
.. class:: CookieJar(*, unsafe=False, quote_cookie=True, treat_as_secure_origin = [])
20152126

20162127
The cookie jar instance is available as :attr:`ClientSession.cookie_jar`.

0 commit comments

Comments
 (0)