Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP Digest authentication support #131

Merged
merged 66 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
31197b8
Added HTTP Digest authentication support
mksh Mar 3, 2015
fffaaca
Added docstrings; got rid of assertDictEqual in test_digest_auth_mult…
mksh Mar 3, 2015
0c5d729
PEP8
mksh Mar 3, 2015
1d3b18c
Docstrings, typing fixes and small code improvements
mksh Mar 13, 2015
42d695d
Fix POST Request Digest Authentication
jameshilliard Jul 31, 2016
e749665
python 3 urlparse compatibility
jameshilliard Aug 1, 2016
a627f0b
Refactor build_digest_authentication_header as a private method of _R…
jameshilliard Sep 10, 2016
aa8e102
Make username and password private
jameshilliard Sep 10, 2016
2c1817e
Remove redundant agent arguement from _build_digest_authentication_he…
jameshilliard Sep 11, 2016
91a5c7b
Remove redundant None default from self.digest_auth_cache.get
jameshilliard Sep 11, 2016
19f6ad8
Use bytes instead of strings
jameshilliard Sep 11, 2016
1848e93
Fix python 3 digest hash encoding
jameshilliard Sep 12, 2016
5651fc3
Use byte encoding for MD5-SESS digest
jameshilliard Sep 12, 2016
2e2e6d8
Merge branch 'master' of https://github.com/twisted/treq into HTTPDig…
jameshilliard May 11, 2017
9e6e298
Fix python3 auth multiple calls test
jameshilliard May 11, 2017
ffdbfa7
Pass correct algorithm back for digest auth
jameshilliard May 11, 2017
4bd52ff
set default algorithm for digest auth
jameshilliard May 11, 2017
a4dd9f2
PEP8
jameshilliard May 11, 2017
05ecf65
sha256 digest support
jameshilliard May 11, 2017
4ae05dc
Merge branch 'master' into HTTPDigestAuth
jameshilliard Oct 29, 2017
b9f4452
Merge branch 'master' into HTTPDigestAuth
jameshilliard Nov 7, 2017
1283fea
Merge branch 'master' of https://github.com/twisted/treq into HTTPDig…
jameshilliard Sep 29, 2018
03b784b
bump httpbin version
jameshilliard Sep 29, 2018
3515008
Add more tests and sha512 digest support.
jameshilliard Sep 29, 2018
e0d7208
Set opaque header for digest auth
jameshilliard Sep 29, 2018
457c56a
Merge branch 'upstream-master' into HEAD
mksh Feb 7, 2020
f8c5e7f
Make treq.auth.generate_client_nonce() private
mksh Feb 7, 2020
dcc772d
Refactor digest auth parameter caching, add unit tests
mksh Feb 7, 2020
f1e3222
None is default ret value of .get()
mksh Feb 7, 2020
00af1b7
Fix flakes for test_auth.py
mksh Feb 7, 2020
8ca2274
Merge branch 'master' into feature/http-digest-auth
Mar 30, 2020
7dba100
Merge branch 'master' into feature/http-digest-auth
glyph Dec 1, 2020
8150f5d
Merge branch 'trunk' into HTTPDigestAuth
jameshilliard Nov 9, 2022
ec3e5c9
Use urlparse from urllib instead of six
jameshilliard Nov 23, 2022
259a059
Add type annotations
jameshilliard Nov 23, 2022
c5a6e86
Fix digest auth comment links
jameshilliard Nov 23, 2022
f5a30ea
Improve digest auth howto caching wording
jameshilliard Nov 23, 2022
b87b767
Add blank lines around param comments
jameshilliard Nov 23, 2022
d01bd08
Don't hardcode qop as auth
jameshilliard Nov 23, 2022
73a68d3
Use agent_spy instead of mock in test_add_digest_auth.
jameshilliard Nov 23, 2022
b1ade85
Use self.patch instead of MonkeyPatcher
jameshilliard Nov 23, 2022
d9a48a6
Use nonlocal for call counter variable
jameshilliard Nov 23, 2022
455605d
Cleanup test_digest_auth_multiple_calls nonlocal variable naming and …
jameshilliard Nov 23, 2022
12c0739
Make cached_metadata_for private
jameshilliard Nov 26, 2022
4a8e463
Fix treq integration test docstring indentation
jameshilliard Nov 26, 2022
99a0532
Refactor build_authentication_header based on current requests fstrin…
jameshilliard Nov 26, 2022
5269ff1
Make build_authentication_header private
jameshilliard Nov 27, 2022
4cbd556
Use _DIGEST_ALGO enum for digest auth algo
jameshilliard Nov 27, 2022
5359a2a
Improve and add additional digest auth test docstring
jameshilliard Nov 27, 2022
484b9ba
Merge commit '08bb4d3f878e79c6d0755fd80b74ffd9ccdc2726' into HTTPDige…
jameshilliard May 2, 2023
bcb946d
Merge branch 'trunk' into HTTPDigestAuth
glyph May 3, 2023
57769c9
Merge branch 'trunk' into HTTPDigestAuth
twm Apr 20, 2024
b5f5d95
Merge branch 'trunk' into HTTPDigestAuth
glyph Jun 18, 2024
f833561
version bump on mypy basepython
glyph Jun 18, 2024
1c35956
current brotlipy doesn't build against 3.10 any more, mypy transitive…
glyph Jun 18, 2024
9198250
try to catch up this PR with the new, stricter requirements treq has …
glyph Jun 18, 2024
e1cf128
add test-case-name
glyph Jun 18, 2024
580eb8a
now that we're using enums properly, format them properly
glyph Jun 18, 2024
92fd51c
upgrade setup-python to correspond to new mypy basepython
glyph Jun 18, 2024
ae9d582
same mistake again on the docs env
glyph Jun 18, 2024
ffa168e
annotations import
glyph Jun 18, 2024
7ab4927
extend test-case-name for the missed parts
glyph Jun 18, 2024
fc145a4
no peeking at private attributes
glyph Jun 18, 2024
c27e977
Update src/treq/auth.py
glyph Jun 19, 2024
badf9fb
remove unnecessary extra public API
glyph Jun 19, 2024
a688242
Merge branch 'trunk' into HTTPDigestAuth
glyph Jun 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/examples/digest_auth.py
Original file line number Diff line number Diff line change
@@ -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, [])
18 changes: 18 additions & 0 deletions docs/howto.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,25 @@ The ``auth`` argument should be a tuple of the form ``('username', 'password')``

Full example: :download:`basic_auth.py <examples/basic_auth.py>`

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.
jameshilliard marked this conversation as resolved.
Show resolved Hide resolved

: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 <examples/digest_auth.py>`

.. _RFC 2617: http://www.ietf.org/rfc/rfc2617.txt
.. _RFC 2069: http://www.ietf.org/rfc/rfc2069.txt

Redirects
---------
Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

+----------------------------------+----------+----------+
| Elegant Key/Value Cookies | yes | yes |
+----------------------------------+----------+----------+
Expand Down
290 changes: 289 additions & 1 deletion treq/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
from __future__ import absolute_import, division, print_function
import re
import time
import base64
import hashlib

from six.moves.urllib.parse 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()


class HTTPDigestAuth(object):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""
The container for HTTP Digest authentication credentials
"""

def __init__(self, username, password):
self.username = username
self.password = password


class UnknownAuthConfig(Exception):
Expand All @@ -10,6 +49,25 @@ 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
Expand All @@ -26,6 +84,228 @@ def request(self, method, uri, headers=None, bodyProducer=None):
method, uri, headers=headers, bodyProducer=bodyProducer)


class _RequestDigestAuthenticationAgent(object):

digest_auth_cache = {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also shouldn't be public. Is this really a class variable? It looks like it's only accessed via the instance so it should probably be initialized in in __init__.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I put this inside of init it appeared to no longer cache the authentication across requests.

Copy link
Contributor

@dreid dreid Sep 11, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

treq tries to avoid having global mutable state like this cache.

The reason for this is that with a global cache like this it is very easy to implement it in such a way that different execution contexts can clobber the cached data.

For example, let's say we have a service that posts payloads to user configured URLs with digest authentication.

If two different users of this service configure it to post to same URI with different credentials they 'https://foo.bar/baz' they will end up sharing the cache entry for ('POST', 'https://foo.bar/baz') and one of them would never be able to authenticate.

Rather than add the credentials to cache key though it'd be better if the caller of the treq request methods had to opt into the cache.

The only other place where there is some expectation of the top-level treq request functions would reuse state is for cookie storage where the user has to explicitly pass a CookieJar instance (instead of a dict) to have it reused. Perhaps this cache should be on the credentials object instead then?

Such that

# Has one cache
treq.get(someURL, auth=HTTPDigestAuth('foo', 'bar'))

# Has a different cache
treq.get(someURL, auth=HTTPDigestAuth('baz', 'bax'))

# and share a cache.
creds = HTTPDigestAuth('foo', 'bar')
treq.get(someURL, auth=creds)
treq.get(someURL, auth=creds)

it would also make sense to augment treq.client.HTTPClient to take auth as a parameter to init such that:

client = treq.client.HTTPClient(baseAgent, auth=HTTPDigestAuth('foo', 'bar'))

# These would share auth credentials and a cache.
client.get(someURL)
client.get(someURL)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it would appear a shared cache would reduce the amount of requests needed for how I'm using this from 6 to 4. I'm already formatting it with a shared auth like your "and share a cache." example in my automation script.

I'm using this with automation code that hits 3 separate URL's comprising 2 GET's and 1 POST that all can share the same credentials and cache.

I'm a little unsure of how I should go about refactoring this though, got any hints on where I should start?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like requests also put the shared state on the HTTPDigestAuth object (https://github.com/kennethreitz/requests/blob/master/requests/auth.py#L79).

Probably start with a couple of test cases that exercise the desired sharing behavior, and then start moving the digest_auth_cache and to the HTTPDigestAuth instance. Probably reasonable to put the header construction code there too. And just have the _RequestDigestAuthenticationAgent ask the credentials for the header value.


def __init__(self, agent, username, password):
self._agent = agent
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' % (
self._username,
realm,
self._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"' % (
self._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):
"""
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
"""
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 = self._build_digest_authentication_header(
self,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This self (and the agent argument in _build_digest_authentication_header are redundant.

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
)

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:
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):
"""
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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None is the default default value of dict.get.

# Perform first request for getting the realm;
# the client awaits for 401 response code here
d = self._agent.request('GET', 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 = 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,
uri, headers, bodyProducer
)
return d


def add_basic_auth(agent, username, password):
creds = base64.b64encode(
'{0}:{1}'.format(username, password).encode('ascii'))
Expand All @@ -34,8 +314,16 @@ 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])
elif isinstance(auth_config, HTTPDigestAuth):
return add_digest_auth(agent, auth_config)

raise UnknownAuthConfig(auth_config)