From cc2fc5ea14528372aee6b0a6197125819d9338d7 Mon Sep 17 00:00:00 2001 From: "Felix Palta (office)" Date: Wed, 19 Oct 2016 15:35:21 +0300 Subject: [PATCH 1/8] Pass algorithm parameter to H() This fixes error 500 on digest-auth test with qop='auth-int' (#307) --- httpbin/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httpbin/helpers.py b/httpbin/helpers.py index 0946dea..5d59b2d 100644 --- a/httpbin/helpers.py +++ b/httpbin/helpers.py @@ -294,7 +294,7 @@ def HA2(credentails, request, algorithm): raise ValueError("%s required" % k) return H("%s:%s:%s" % (request['method'], request['uri'], - H(request['body'])), algorithm) + H(request['body'], algorithm)), algorithm) raise ValueError From f198dab947032095e937074bb193f339ba3918b9 Mon Sep 17 00:00:00 2001 From: Felix Palta Date: Sat, 3 Dec 2016 23:05:24 +0300 Subject: [PATCH 2/8] Add test implementation for Digest authentication It can be used for testing of all available combinations of qop (auth/auth-int/not specified) and algorithms (MD5, SHA-256). --- test_httpbin.py | 101 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 18 deletions(-) diff --git a/test_httpbin.py b/test_httpbin.py index 4f6a322..93bdfa8 100755 --- a/test_httpbin.py +++ b/test_httpbin.py @@ -7,7 +7,7 @@ import contextlib import six import json from werkzeug.http import parse_dict_header -from hashlib import md5 +from hashlib import md5, sha256 from six import BytesIO import httpbin @@ -36,6 +36,74 @@ def _string_to_base64(string): utf8_encoded = string.encode('utf-8') return base64.urlsafe_b64encode(utf8_encoded) +def _hash(data, algorithm): + """Encode binary data according to specified algorithm""" + if algorithm == 'SHA-256': + return sha256(data).hexdigest() + else: + return md5(data).hexdigest() + +def _make_digest_auth_header(username, password, method, uri, nonce, + realm=None, opaque=None, algorithm=None, + qop=None, cnonce=None, nc=None, body=''): + """Compile a digest authentication header string + + Arguments: + - `nonce': nonce string, received within "WWW-Authenticate" header + - `realm`: realm string, received within "WWW-Authenticate" header + - `opaque`: opaque string, received within "WWW-Authenticate" header + - `algorithm`: type of hashing algorithm, used by the client + - `qop`: type of quality-of-protection, used by the client + - `cnonce`: client nonce, required if qop is "auth" or "auth-int" + - `nc`: client nonce count, required if qop is "auth" or "auth-int" + - `body`: body of the outgoing request + """ + + assert username + assert password + assert nonce + assert method + assert uri + assert algorithm in ('MD5', 'SHA-256', None) + + a1 = b':'.join([username.encode('utf-8'), + (realm or '').encode('utf-8'), + password.encode('utf-8')]) + ha1 = _hash(a1, algorithm) + + a2 = b':'.join([method.encode('utf-8'), uri.encode('utf-8')]) + if qop == 'auth-int': + a2 = b':'.join(a2, _hash(body, algorithm)) + ha2 = _hash(a2, algorithm) + + a3 = b':'.join([ha1, nonce.encode('utf-8')]) + if qop in ('auth', 'auth-int'): + assert cnonce + assert nc + a3 = b':'.join([a3, nc.encode('utf-8'), cnonce.encode('utf-8'), qop.encode('utf-8')]) + + a3 = b':'.join([a3, ha2]) + auth_response = _hash(a3, algorithm) + + auth_header = \ + 'Digest username="{0}", response="{1}", uri="{2}", nonce="{3}"'\ + .format(username, auth_response, uri, nonce) + + if realm != None: + auth_header += ', realm="{0}"'.format(realm) + if opaque != None: + auth_header += ', opaque="{0}"'.format(opaque) + + if algorithm: + auth_header += ', algorithm="{0}"'.format(algorithm) + if cnonce: + auth_header += ', cnonce="{0}"'.format(cnonce) + if nc: + auth_header += ', nc={0}'.format(nc) + if qop: + auth_header += ', qop={0}'.format(qop) + + return auth_header class HttpbinTestCase(unittest.TestCase): """Httpbin tests""" @@ -180,8 +248,9 @@ class HttpbinTestCase(unittest.TestCase): def test_digest_auth(self): # make first request + uri = '/digest-auth/auth/user/passwd/MD5' unauthorized_response = self.app.get( - '/digest-auth/auth/user/passwd/MD5', + uri, environ_base={ # digest auth uses the remote addr to build the nonce 'REMOTE_ADDR': '127.0.0.1', @@ -191,27 +260,23 @@ class HttpbinTestCase(unittest.TestCase): self.assertEqual(unauthorized_response.status_code, 401) header = unauthorized_response.headers.get('WWW-Authenticate') auth_type, auth_info = header.split(None, 1) + self.assertEqual(auth_type, 'Digest') - # Begin crappy digest-auth implementation d = parse_dict_header(auth_info) - a1 = b'user:' + d['realm'].encode('utf-8') + b':passwd' - ha1 = md5(a1).hexdigest().encode('utf-8') - a2 = b'GET:/digest-auth/auth/user/passwd/MD5' - ha2 = md5(a2).hexdigest().encode('utf-8') - a3 = ha1 + b':' + d['nonce'].encode('utf-8') + b':' + ha2 - auth_response = md5(a3).hexdigest() - auth_header = 'Digest username="user",realm="' + \ - d['realm'] + \ - '",nonce="' + \ - d['nonce'] + \ - '",uri="/digest-auth/auth/user/passwd/MD5",response="' + \ - auth_response + \ - '",opaque="' + \ - d['opaque'] + '"' + + username = 'user' + password = 'passwd' + method = 'GET' + nonce = d['nonce'] + realm = d['realm'] + opaque = d['opaque'] + + auth_header = _make_digest_auth_header( + username, password, method, uri, nonce, realm, opaque) # make second request authorized_response = self.app.get( - '/digest-auth/auth/user/passwd/MD5', + uri, environ_base={ # httpbin's digest auth implementation uses the remote addr to # build the nonce From ada6bd1508acab1ef98f0aee6883b99cb9cad2fd Mon Sep 17 00:00:00 2001 From: Felix Palta Date: Sat, 3 Dec 2016 23:34:00 +0300 Subject: [PATCH 3/8] Check response status code in test_digest_auth_with_wrong_password() --- test_httpbin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test_httpbin.py b/test_httpbin.py index 93bdfa8..d8aade8 100755 --- a/test_httpbin.py +++ b/test_httpbin.py @@ -244,7 +244,8 @@ class HttpbinTestCase(unittest.TestCase): 'Authorization': auth_header, } ) - assert 'Digest' in response.headers.get('WWW-Authenticate') + self.assertTrue('Digest' in response.headers.get('WWW-Authenticate')) + self.assertEqual(response.status_code, 401) def test_digest_auth(self): # make first request From 9816125df33528ece3a706382e3b36a5192bc679 Mon Sep 17 00:00:00 2001 From: Felix Palta Date: Sun, 4 Dec 2016 00:17:03 +0300 Subject: [PATCH 4/8] Add test for different combinations of digest auth parameters --- test_httpbin.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/test_httpbin.py b/test_httpbin.py index d8aade8..0e142f1 100755 --- a/test_httpbin.py +++ b/test_httpbin.py @@ -73,7 +73,7 @@ def _make_digest_auth_header(username, password, method, uri, nonce, a2 = b':'.join([method.encode('utf-8'), uri.encode('utf-8')]) if qop == 'auth-int': - a2 = b':'.join(a2, _hash(body, algorithm)) + a2 = b':'.join([a2, _hash(body, algorithm)]) ha2 = _hash(a2, algorithm) a3 = b':'.join([ha1, nonce.encode('utf-8')]) @@ -248,8 +248,18 @@ class HttpbinTestCase(unittest.TestCase): self.assertEqual(response.status_code, 401) def test_digest_auth(self): - # make first request - uri = '/digest-auth/auth/user/passwd/MD5' + """Test different combinations of digest auth parameters""" + username = 'user' + password = 'passwd' + for qop in None, 'auth', 'auth-int',: + for algorithm in None, 'MD5', 'SHA-256': + self._test_digest_auth(username, password, qop, algorithm) + + def _test_digest_auth(self, username, password, qop=None, algorithm=None): + uri = '/digest-auth/{0}/{1}/{2}'.format(qop or 'wrong-qop', username, password) + if algorithm: + uri += '/' + algorithm + unauthorized_response = self.app.get( uri, environ_base={ @@ -265,15 +275,13 @@ class HttpbinTestCase(unittest.TestCase): d = parse_dict_header(auth_info) - username = 'user' - password = 'passwd' - method = 'GET' nonce = d['nonce'] realm = d['realm'] opaque = d['opaque'] + cnonce, nc = (_hash(os.urandom(10), "MD5"), '00000001') if qop in ('auth', 'auth-int') else (None, None) auth_header = _make_digest_auth_header( - username, password, method, uri, nonce, realm, opaque) + username, password, 'GET', uri, nonce, realm, opaque, algorithm, qop, cnonce, nc) # make second request authorized_response = self.app.get( From ec17414e947d7b7b963e772a598f63e83cb0fcf8 Mon Sep 17 00:00:00 2001 From: Felix Palta Date: Sun, 4 Dec 2016 00:25:36 +0300 Subject: [PATCH 5/8] Enable test cases for Digest auth requests with or without body When quality-of-protection (qop) is 'auth-int', the hash of request body is used to create digest response. --- test_httpbin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test_httpbin.py b/test_httpbin.py index 0e142f1..8f955aa 100755 --- a/test_httpbin.py +++ b/test_httpbin.py @@ -45,7 +45,7 @@ def _hash(data, algorithm): def _make_digest_auth_header(username, password, method, uri, nonce, realm=None, opaque=None, algorithm=None, - qop=None, cnonce=None, nc=None, body=''): + qop=None, cnonce=None, nc=None, body=None): """Compile a digest authentication header string Arguments: @@ -73,7 +73,7 @@ def _make_digest_auth_header(username, password, method, uri, nonce, a2 = b':'.join([method.encode('utf-8'), uri.encode('utf-8')]) if qop == 'auth-int': - a2 = b':'.join([a2, _hash(body, algorithm)]) + a2 = b':'.join([a2, _hash(body or '', algorithm)]) ha2 = _hash(a2, algorithm) a3 = b':'.join([ha1, nonce.encode('utf-8')]) @@ -253,9 +253,10 @@ class HttpbinTestCase(unittest.TestCase): password = 'passwd' for qop in None, 'auth', 'auth-int',: for algorithm in None, 'MD5', 'SHA-256': - self._test_digest_auth(username, password, qop, algorithm) + for body in None, '', 'request payload': + self._test_digest_auth(username, password, qop, algorithm, body) - def _test_digest_auth(self, username, password, qop=None, algorithm=None): + def _test_digest_auth(self, username, password, qop=None, algorithm=None, body=None): uri = '/digest-auth/{0}/{1}/{2}'.format(qop or 'wrong-qop', username, password) if algorithm: uri += '/' + algorithm @@ -281,7 +282,7 @@ class HttpbinTestCase(unittest.TestCase): cnonce, nc = (_hash(os.urandom(10), "MD5"), '00000001') if qop in ('auth', 'auth-int') else (None, None) auth_header = _make_digest_auth_header( - username, password, 'GET', uri, nonce, realm, opaque, algorithm, qop, cnonce, nc) + username, password, 'GET', uri, nonce, realm, opaque, algorithm, qop, cnonce, nc, body) # make second request authorized_response = self.app.get( @@ -293,7 +294,8 @@ class HttpbinTestCase(unittest.TestCase): }, headers={ 'Authorization': auth_header, - } + }, + data=body ) # done! From 562a60bc233724da42879116eeee22aac3063b12 Mon Sep 17 00:00:00 2001 From: Felix Palta Date: Sun, 4 Dec 2016 00:41:02 +0300 Subject: [PATCH 6/8] Update comments, minor fixes --- test_httpbin.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test_httpbin.py b/test_httpbin.py index 8f955aa..dde3b71 100755 --- a/test_httpbin.py +++ b/test_httpbin.py @@ -37,7 +37,7 @@ def _string_to_base64(string): return base64.urlsafe_b64encode(utf8_encoded) def _hash(data, algorithm): - """Encode binary data according to specified algorithm""" + """Encode binary data according to specified algorithm, use MD5 by default""" if algorithm == 'SHA-256': return sha256(data).hexdigest() else: @@ -46,17 +46,17 @@ def _hash(data, algorithm): def _make_digest_auth_header(username, password, method, uri, nonce, realm=None, opaque=None, algorithm=None, qop=None, cnonce=None, nc=None, body=None): - """Compile a digest authentication header string + """Compile a digest authentication header string. Arguments: - - `nonce': nonce string, received within "WWW-Authenticate" header + - `nonce`: nonce string, received within "WWW-Authenticate" header - `realm`: realm string, received within "WWW-Authenticate" header - `opaque`: opaque string, received within "WWW-Authenticate" header - `algorithm`: type of hashing algorithm, used by the client - `qop`: type of quality-of-protection, used by the client - `cnonce`: client nonce, required if qop is "auth" or "auth-int" - `nc`: client nonce count, required if qop is "auth" or "auth-int" - - `body`: body of the outgoing request + - `body`: body of the outgoing request, used if qop is "auth-int" """ assert username @@ -89,6 +89,7 @@ def _make_digest_auth_header(username, password, method, uri, nonce, 'Digest username="{0}", response="{1}", uri="{2}", nonce="{3}"'\ .format(username, auth_response, uri, nonce) + # 'realm' and 'opaque' should be returned unchanged, even if empty if realm != None: auth_header += ', realm="{0}"'.format(realm) if opaque != None: From 45058da87298122ffaafdb2f37d0f966157d9c57 Mon Sep 17 00:00:00 2001 From: Felix Palta Date: Sun, 4 Dec 2016 21:31:52 +0300 Subject: [PATCH 7/8] Minor refactoring of _test_digest_auth Instead of joining encoded (binary) strings, join unicode strings first, then encode result, which leads to shorter and clearer code. --- test_httpbin.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/test_httpbin.py b/test_httpbin.py index dde3b71..321a818 100755 --- a/test_httpbin.py +++ b/test_httpbin.py @@ -56,7 +56,7 @@ def _make_digest_auth_header(username, password, method, uri, nonce, - `qop`: type of quality-of-protection, used by the client - `cnonce`: client nonce, required if qop is "auth" or "auth-int" - `nc`: client nonce count, required if qop is "auth" or "auth-int" - - `body`: body of the outgoing request, used if qop is "auth-int" + - `body`: body of the outgoing request (bytes), used if qop is "auth-int" """ assert username @@ -66,24 +66,22 @@ def _make_digest_auth_header(username, password, method, uri, nonce, assert uri assert algorithm in ('MD5', 'SHA-256', None) - a1 = b':'.join([username.encode('utf-8'), - (realm or '').encode('utf-8'), - password.encode('utf-8')]) - ha1 = _hash(a1, algorithm) + a1 = ':'.join([username, realm or '', password]) + ha1 = _hash(a1.encode('utf-8'), algorithm) - a2 = b':'.join([method.encode('utf-8'), uri.encode('utf-8')]) + a2 = ':'.join([method, uri]) if qop == 'auth-int': - a2 = b':'.join([a2, _hash(body or '', algorithm)]) - ha2 = _hash(a2, algorithm) + a2 = ':'.join([a2, _hash(body or b'', algorithm)]) + ha2 = _hash(a2.encode('utf-8'), algorithm) - a3 = b':'.join([ha1, nonce.encode('utf-8')]) + a3 = ':'.join([ha1, nonce]) if qop in ('auth', 'auth-int'): assert cnonce assert nc - a3 = b':'.join([a3, nc.encode('utf-8'), cnonce.encode('utf-8'), qop.encode('utf-8')]) + a3 = ':'.join([a3, nc, cnonce, qop]) - a3 = b':'.join([a3, ha2]) - auth_response = _hash(a3, algorithm) + a3 = ':'.join([a3, ha2]) + auth_response = _hash(a3.encode('utf-8'), algorithm) auth_header = \ 'Digest username="{0}", response="{1}", uri="{2}", nonce="{3}"'\ @@ -254,7 +252,7 @@ class HttpbinTestCase(unittest.TestCase): password = 'passwd' for qop in None, 'auth', 'auth-int',: for algorithm in None, 'MD5', 'SHA-256': - for body in None, '', 'request payload': + for body in None, b'', b'request payload': self._test_digest_auth(username, password, qop, algorithm, body) def _test_digest_auth(self, username, password, qop=None, algorithm=None, body=None): From e7e9163625670d750540deaa9764b6a1ac51c6ab Mon Sep 17 00:00:00 2001 From: Felix Palta Date: Sun, 4 Dec 2016 21:33:54 +0300 Subject: [PATCH 8/8] Encode HA2() string parameters 'method', 'uri' and 'H(entityBody)' This fixes error 500, when httpbin is run with python3 and digest-auth is attempted with qop='auth-int'. --- httpbin/helpers.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/httpbin/helpers.py b/httpbin/helpers.py index 5d59b2d..560ce13 100644 --- a/httpbin/helpers.py +++ b/httpbin/helpers.py @@ -292,9 +292,10 @@ def HA2(credentails, request, algorithm): for k in 'method', 'uri', 'body': if k not in request: raise ValueError("%s required" % k) - return H("%s:%s:%s" % (request['method'], - request['uri'], - H(request['body'], algorithm)), algorithm) + A2 = b":".join([request['method'].encode('utf-8'), + request['uri'].encode('utf-8'), + H(request['body'], algorithm).encode('utf-8')]) + return H(A2, algorithm) raise ValueError