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

Added a digest authentication helper #2213

Open
wants to merge 19 commits into
base: master
Choose a base branch
from

Conversation

feus4177
Copy link

@feus4177 feus4177 commented Aug 20, 2017

What do these changes do?

Added a digest authentication helper.

Are there changes in behavior for the user?

None

Related issue number

Resolves #4939

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt
    • The format is <Name> <Surname>.
    • Please keep alphabetical order, the file is sorted by names.
  • Add a new news fragment into the changes folder
    • name it <issue_id>.<type> for example (588.bug)
    • if you don't have an issue_id change it to the pr id after creating the pr
    • ensure type is one of the following:
      • .feature: Signifying a new feature.
      • .bugfix: Signifying a bug fix.
      • .doc: Signifying a documentation improvement.
      • .removal: Signifying a deprecation or removal of public API.
      • .misc: A ticket has been closed, but it is not of interest to users.
    • Make sure to use full sentences with correct case and punctuation, for example: "Fix issue with non-ascii contents in doctest text files."

I didn't go through the checklist. I just wanted to see if this is something you are interested in incorporating into the aiohttp package. To be able to use this DigestAuth helper like the aiohttp BasicAuth helper would take some restructuring so I decided to keep it as isolated as possible right now. Let me know what you think and I can make any changes as necessary.

@asvetlov
Copy link
Member

Sorry, I don't follow how to use the helper.
Full test coverage is required also.

@feus4177
Copy link
Author

Sorry, I meant to add this snippet as well.

import aiohttp
import asyncio

client = aiohttp.ClientSession()
auth = aiohttp.auth.DigestAuth('usr', 'psswd', client)

async def fetch(url, **kwargs):
    return await auth.request('GET', url, **kwargs)


loop = asyncio.get_event_loop()
response = loop.run_until_complete(fetch('http://httpbin.org/digest-auth/auth/usr/psswd/MD5/never'))

@feus4177
Copy link
Author

feus4177 commented Aug 20, 2017

Obviously, I can add tests, documentation, etc. Before I go through all that though, I just wanted to see if there is any interest in it.

@asvetlov
Copy link
Member

Well, idea is good but it should be proved by test suite.

@oleksandr-kuzmenko
Copy link
Contributor

oleksandr-kuzmenko commented Oct 11, 2017

This is a good idea!
But I think it would be better to add digest authentication support to standard client, something like:

                                     |
                                     v
async with aiohttp.ClientSession(digest_auth=...) as session:
    async with session.get('https://api.github.com/events') as resp:
        print(resp.status)

@kxepal
Copy link
Member

kxepal commented Oct 12, 2017

@Alxpy
Yeap, that's looks simpler for users. But it will overload ClientSession signature too much. Because digest and basic auths are not the only around. Having sort-of decorators / middlewares instead gives you more control: you can implement own auth method like this without touching aiohttp sources. And aiohttp developers wouldn't have to maintain such a jack of all trades object.

@oleksandr-kuzmenko
Copy link
Contributor

@kxepal yes, that makes sense, I agree

@feus4177
Copy link
Author

That was the kind of discussion I was looking for. It sounds like you guys would prefer to keep it separated like it currently is. I'll go ahead and add the tests and documentation and then update the pull request. Thanks.

self.args = {}
self.session = session

@asyncio.coroutine
Copy link
Contributor

Choose a reason for hiding this comment

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

Nobody uses this syntax here anymore.

@feus4177
Copy link
Author

So I updated the pull request to following the contributing guidelines (test, docs, etc.). Since it has been a while I also went ahead and merged the latest from aio-libs/aiohttp:master into the pull request. It looks like you guys switched over to the async/await syntax so I did that as well. However, half of all the tests are failing now. It looks like it's that way for all the tests though, so I'm assuming it isn't an issue. Let me know if there is anything else you want me to do.

def KD(s, d):
return H('%s:%s' % (s, d))

parsed = urlparse(url)
Copy link
Member

Choose a reason for hiding this comment

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

We have yarl. Why url parse?

Copy link
Author

Choose a reason for hiding this comment

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

I was unfamiliar with yarl; I will switch it over.

if nonce == self.last_nonce:
self.nonce_count += 1
else:
self.nonce_count = 1
Copy link
Member

Choose a reason for hiding this comment

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

This branch is redundant. 0 +1 equals 1.

Copy link
Author

Choose a reason for hiding this comment

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

If you re-use the instance, the nonce_count count can grow without bound. The nonce count is important in preventing replay attacks. There probably aren't too many cases where the nonce will need to reset but perhaps in the event of some failed messages.

Copy link
Author

Choose a reason for hiding this comment

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

I should clarify, if you re-use the instance of DigestAuth for performing multiple requests.

k = str(self.nonce_count).encode()
k += nonce.encode()
k += time.ctime().encode()
k += os.urandom(8)
Copy link
Member

Choose a reason for hiding this comment

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

How about join?

)
respdig = KD(HA1, noncebit)
else:
raise client_exceptions.ClientError(
Copy link
Member

Choose a reason for hiding this comment

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

qop is known quite early above. How about to fail fast?

Copy link
Author

Choose a reason for hiding this comment

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

There would have to be some duplication of logic around qop and I didn't think that the performance was that big of an issue. I can move it though.

return response


if PY_352:
Copy link
Member

Choose a reason for hiding this comment

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

No longer actual. We require 3.5.3+ on master.

def handler(request):
realm = '[email protected]'
if 'Authorization' in request.headers:
pattern = re.compile(r'digest ', flags=re.IGNORECASE)
Copy link
Member

Choose a reason for hiding this comment

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

triple times.

def handler(request):
realm = '[email protected]'
if 'Authorization' in request.headers:
pattern = re.compile(r'digest ', flags=re.IGNORECASE)
Copy link
Member

Choose a reason for hiding this comment

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

ok, multiple ones.

@asyncio.coroutine
def test_digest_auth_MD5_sess(loop, test_client):
opaque = binascii.hexlify(os.urandom(16))
username = 'usr'
Copy link
Member

Choose a reason for hiding this comment

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

wh usr nd psswd whl username nd password wrwhr ls?

Copy link
Author

Choose a reason for hiding this comment

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

I was trying to demonstrate that the helper isn't just hardcoded to work with one username and password. Although, this really doesn't challenge it at all. Once you decide what you want to do about the string escaping I can change this to include something more difficult like spaces and quotes.

def handler(request):
realm = '[email protected]'
if 'Authorization' in request.headers:
pattern = re.compile(r'digest ', flags=re.IGNORECASE)
Copy link
Member

Choose a reason for hiding this comment

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

again

def handler(request):
realm = '[email protected]'
if 'Authorization' in request.headers:
pattern = re.compile(r'digest ', flags=re.IGNORECASE)
Copy link
Member

Choose a reason for hiding this comment

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

ok, tests are the same as the regular code - please, show it some love in the name of maintenance.

@asvetlov
Copy link
Member

Tests still are red :(

headers = {}

# Save the args so we can re-run the request
self.args = {
Copy link

Choose a reason for hiding this comment

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

Why do you store last request as a class attribute? Won't it cause everything to break when DigestAuth instance is used by multiple coroutines simultaneously?

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, that's a good point. We can just store it as a local variable and then pass it to _handle_401.

Copy link

Choose a reason for hiding this comment

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

I think this applies to self.challenge as well.


# Only try performing digest authentication if the response status is
# from 400 to 500.
if 400 <= response.status < 500:
Copy link

Choose a reason for hiding this comment

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

In one of my tests, when username-password pair was incorrect, this led to infinite recursion. I think the fix is to call _handle_401 only if self.challenge is None.

nonce,
time.ctime(),
os.urandom(8).decode(errors='ignore'),
]).encode()
Copy link

Choose a reason for hiding this comment

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

os.urandom(8).decode(errors='ignore') looks really odd. It basically discards characters that cannot be decoded as UTF-8, so the string is usually shorter than 8 characters. This would be better, I think:

cnonce_data = ''.join([
    str(self.nonce_count),
    nonce,
    time.ctime(),
]).encode()
cnonce_data += os.urandom(8)

@RouquinBlanc
Copy link

RouquinBlanc commented Sep 6, 2018

Hi guys,

What do you think the status of this pull request is and do you have a rough idea if it is going to be ready any time soon-ish? It seems quite close from an external eye...

At the moment AFAIK there is no pure asyncio framework supporting digest authentication, you have to either go back to tornado or to give up on asyncio and look at requests...

Thanks!

@asvetlov
Copy link
Member

I like the idea but have a feeling that we need to use slightly different and not existing yet API for it.

@DavHau
Copy link

DavHau commented Sep 13, 2018

At the moment AFAIK there is no pure asyncio framework supporting digest authentication, you have to either go back to tornado or to give up on asyncio and look at requests...

@RouquinBlanc
This is not true. There is:
Pulsar: https://github.com/quantmind/pulsar (file upload in combination with DigestAuth is broken at the time of writing)
or
yieldfromrequests: https://github.com/rdbhost/yieldfromrequests (not updated since 4 years, but works for me. Only usable with old generator style async syntax. write yourself a wrapper ;) )

I'm also really looking forward to have DigestAuth available in aiohttp since it is my favorite async http lib.

@sonicblind
Copy link

@asvetlov
I found a small bug related to Digest Auth Header in helpers.py.

The Digest header can include spaces in realm name, for example:
Digest realm="iMC RESTful Web Services", qop="auth", nonce="MTU0....=="

My suggestion as per below:

def parse_key_value_list(header):
    return {
        key: value for key, value in
#       [parse_pair(header_pair) for header_pair in header.split(' ')]
        [parse_pair(header_pair) for header_pair in header.split(', ')]
    }

But this is probably also not bulletproof as the realm could include also comma.
So maybe header.split('", ') would work even better.

And if the above make sense, then the "remove trailing comma" section in parse_pair(pair) should also be updated accordingly:

def parse_pair(pair):
    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

@asvetlov
Copy link
Member

@sonicblind thanks for the comment.
The PR doesn't go to merge but I'd like to keep it open as a reference for eventual new implementation built from scratch.

@bdraco
Copy link
Member

bdraco commented Mar 4, 2020

Thanks for working on this. @feus4177 @asvetlov Is there a shot that support for digest auth is forthcoming any time soon?

@kiknaio
Copy link

kiknaio commented Jul 24, 2020

Still nothing new. I think it would be amazing if you complete this feature request.


.. class:: DigestAuth(login, password', session)

HTTP digest authentication helper. Unlike :class:`DigestAuth`, this helper
Copy link

Choose a reason for hiding this comment

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

Unlike BasicAuth ?

@CLAassistant
Copy link

CLAassistant commented Nov 16, 2020

CLA assistant check
All committers have signed the CLA.

def parse_key_value_list(header):
return {
key: value for key, value in
[parse_pair(header_pair) for header_pair in header.split(' ')]

Choose a reason for hiding this comment

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

Splitting on space does not work because values can be quoted strings that contain spaces.

E.g. 'realm="Login to AMC072A731527963121B", qop="auth", nonce="839506863", opaque="f5a15b51387c97add984d4d948213e563fe3eede"'

@skewty
Copy link

skewty commented Mar 14, 2022

I still don't see aiohttp.helpers.DigestAuth so it would seem this never got merged. Can we get this merged?

@flashnuke
Copy link

This would be of tremendous value... any chance of merging?

@webknjaz
Copy link
Member

Hey @feus4177, are you planning to resume this effort? It looks like a lot of reviews accumulated over the years. Plus, there's merge conflicts that would have to be addressed.

@bdraco
Copy link
Member

bdraco commented Jan 29, 2024

It would be great to get this one implemented as its one of the key drivers for Home Assistant devs choosing alternate (usually slower) libraries because the device they are integrating needs digest auth.

@feus4177
Copy link
Author

I have no intention of continuing this work. I tried to get feedback on the overall approach of the work before I invested a bunch of time and effort into finalizing the details: #2213 (comment). After some small feedback I went through and cleaned everything up, made sure all the tests were passing and everything. Then I was hit with this: #2213 (comment). I didn't enjoy jumping through all the hoops just to then revisit the initial feedback I was trying to solicit. I have no intention of going through all that work again knowing that it will all be in vain if the owners decide that they still don't like the architecture of the solution.

@bdraco
Copy link
Member

bdraco commented Jan 29, 2024

I have no intention of continuing this work

The current authentication interface design needs to accommodate additional future methods, and a redesign is required. Unfortunately, this conclusion was realized late in the review process, and I'm sure you wanted to do something other than sign up to do that redesign. I can understand how that is frustrating.

Thank you for your contributions thus far and for following up on this PR.

@feus4177
Copy link
Author

@bdraco, I appreciate your understanding. I also think a redesign is completely reasonable. I just think it's up to the maintainers at this point.

@Dreamsorcerer
Copy link
Member

The current authentication interface design needs to accommodate additional future methods, and a redesign is required.

I think there's a discussion elsewhere, but I noted that auth in other libraries often allows arbitrary functions to run and modify the request. I suggested we could just call this a middleware, and make it work very similarly to the server-side middlewares. Then people can use it for various auth methods or whatever else they might want to do.

@bdraco bdraco added this to the 3.11 milestone Aug 10, 2024
@bdraco bdraco modified the milestones: 3.11, 3.12 Oct 28, 2024
@bdraco
Copy link
Member

bdraco commented Nov 9, 2024

I started thinking about this in #9732

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Development

Successfully merging this pull request may close these issues.

support for digest authentication