Skip to content

Commit

Permalink
Support for digest auth with qop=auth
Browse files Browse the repository at this point in the history
  • Loading branch information
ztpaul authored and miguelgrinberg committed Apr 20, 2022
1 parent 1e9a98b commit d311fe5
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 18 deletions.
36 changes: 36 additions & 0 deletions examples/digest_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env python
"""Digest authentication example
This example demonstrates how to protect Flask endpoints with digest
authentication.
After running this example, visit http://localhost:5000 in your browser. To
gain access, you can use (username=john, password=hello) or
(username=susan, password=bye).
"""
from flask import Flask
from flask_httpauth import HTTPDigestAuth

app = Flask(__name__)
app.secret_key = 'this-is-a-secret-key'
auth = HTTPDigestAuth(qop='auth')

users = {
"john": "hello",
"susan": "bye",
}


@auth.get_password
def get_password(username):
return users.get(username)


@app.route('/')
@auth.login_required
def index():
return "Hello, %s!" % auth.current_user()


if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')
25 changes: 20 additions & 5 deletions src/flask_httpauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,13 @@ def authenticate(self, auth, stored_password):


class HTTPDigestAuth(HTTPAuth):
def __init__(self, scheme=None, realm=None, use_ha1_pw=False):
def __init__(self, scheme=None, realm=None, use_ha1_pw=False, qop='auth'):
super(HTTPDigestAuth, self).__init__(scheme or 'Digest', realm)
self.use_ha1_pw = use_ha1_pw
if isinstance(qop, str):
self.qop = [v.strip() for v in qop.split(',')]
else:
self.qop = qop
self.random = SystemRandom()
try:
self.random.random()
Expand Down Expand Up @@ -326,9 +330,14 @@ def generate_ha1(self, username, password):
def authenticate_header(self):
nonce = self.get_nonce()
opaque = self.get_opaque()
return '{0} realm="{1}",nonce="{2}",opaque="{3}"'.format(
self.scheme, self.realm, nonce,
opaque)
if self.qop:
return '{0} realm="{1}",nonce="{2}",opaque="{3}",qop="{4}"'.format(
self.scheme, self.realm, nonce,
opaque, ','.join(self.qop))
else:
return '{0} realm="{1}",nonce="{2}",opaque="{3}"'.format(
self.scheme, self.realm, nonce,
opaque)

def authenticate(self, auth, stored_password_or_ha1):
if not auth or not auth.username or not auth.realm or not auth.uri \
Expand All @@ -338,6 +347,8 @@ def authenticate(self, auth, stored_password_or_ha1):
if not(self.verify_nonce_callback(auth.nonce)) or \
not(self.verify_opaque_callback(auth.opaque)):
return False
if auth.qop and auth.qop not in self.qop: # pragma: no cover
return False
if self.use_ha1_pw:
ha1 = stored_password_or_ha1
else:
Expand All @@ -346,7 +357,11 @@ def authenticate(self, auth, stored_password_or_ha1):
ha1 = md5(a1.encode('utf-8')).hexdigest()
a2 = request.method + ":" + auth.uri
ha2 = md5(a2.encode('utf-8')).hexdigest()
a3 = ha1 + ":" + auth.nonce + ":" + ha2
if auth.qop == 'auth':
a3 = ha1 + ":" + auth.nonce + ":" + auth.nc + ":" + \
auth.cnonce + ":auth:" + ha2
else:
a3 = ha1 + ":" + auth.nonce + ":" + ha2
response = md5(a3.encode('utf-8')).hexdigest()
return hmac.compare_digest(response, auth.response)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_digest_custom_realm.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def setUp(self):
app = Flask(__name__)
app.config['SECRET_KEY'] = 'my secret'

digest_auth_my_realm = HTTPDigestAuth(realm='My Realm')
digest_auth_my_realm = HTTPDigestAuth(realm='My Realm', qop=None)

@digest_auth_my_realm.get_password
def get_digest_password_3(username):
Expand Down
31 changes: 19 additions & 12 deletions tests/test_digest_get_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ def test_digest_auth_prompt(self):
self.assertEqual(response.status_code, 401)
self.assertTrue('WWW-Authenticate' in response.headers)
self.assertTrue(re.match(r'^Digest realm="Authentication Required",'
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$',
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+",'
r'qop="auth"$',
response.headers['WWW-Authenticate']))

def test_digest_auth_ignore_options(self):
Expand All @@ -70,13 +71,14 @@ def test_digest_auth_login_valid(self):
ha1 = md5(a1).hexdigest()
a2 = 'GET:/digest'
ha2 = md5(a2).hexdigest()
a3 = ha1 + ':' + d['nonce'] + ':' + ha2
a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2
auth_response = md5(a3).hexdigest()

response = self.client.get(
'/digest', headers={
'Authorization': 'Digest username="john",realm="{0}",'
'nonce="{1}",uri="/digest",response="{2}",'
'nonce="{1}",uri="/digest",qop=auth,'
'nc=00000001,cnonce="foobar",response="{2}",'
'opaque="{3}"'.format(d['realm'],
d['nonce'],
auth_response,
Expand All @@ -94,21 +96,23 @@ def test_digest_auth_login_bad_realm(self):
ha1 = md5(a1).hexdigest()
a2 = 'GET:/digest'
ha2 = md5(a2).hexdigest()
a3 = ha1 + ':' + d['nonce'] + ':' + ha2
a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2
auth_response = md5(a3).hexdigest()

response = self.client.get(
'/digest', headers={
'Authorization': 'Digest username="john",realm="{0}",'
'nonce="{1}",uri="/digest",response="{2}",'
'nonce="{1}",uri="/digest",qop=auth,'
'nc=00000001,cnonce="foobar",response="{2}",'
'opaque="{3}"'.format(d['realm'],
d['nonce'],
auth_response,
d['opaque'])})
self.assertEqual(response.status_code, 401)
self.assertTrue('WWW-Authenticate' in response.headers)
self.assertTrue(re.match(r'^Digest realm="Authentication Required",'
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$',
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+",'
r'qop="auth"$',
response.headers['WWW-Authenticate']))

def test_digest_auth_login_invalid2(self):
Expand All @@ -122,21 +126,23 @@ def test_digest_auth_login_invalid2(self):
ha1 = md5(a1).hexdigest()
a2 = 'GET:/digest'
ha2 = md5(a2).hexdigest()
a3 = ha1 + ':' + d['nonce'] + ':' + ha2
a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2
auth_response = md5(a3).hexdigest()

response = self.client.get(
'/digest', headers={
'Authorization': 'Digest username="david",realm="{0}",'
'nonce="{1}",uri="/digest",response="{2}",'
'Authorization': 'Digest username="john",realm="{0}",'
'nonce="{1}",uri="/digest",qop=auth,'
'nc=00000001,cnonce="foobar",response="{2}",'
'opaque="{3}"'.format(d['realm'],
d['nonce'],
auth_response,
d['opaque'])})
self.assertEqual(response.status_code, 401)
self.assertTrue('WWW-Authenticate' in response.headers)
self.assertTrue(re.match(r'^Digest realm="Authentication Required",'
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$',
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+",'
r'qop="auth"$',
response.headers['WWW-Authenticate']))

def test_digest_generate_ha1(self):
Expand Down Expand Up @@ -180,13 +186,14 @@ def verify_opaque(provided_opaque):
ha1 = md5(a1).hexdigest()
a2 = 'GET:/digest'
ha2 = md5(a2).hexdigest()
a3 = ha1 + ':' + d['nonce'] + ':' + ha2
a3 = ha1 + ':' + d['nonce'] + ':00000001:foobar:auth:' + ha2
auth_response = md5(a3).hexdigest()

response = self.client.get(
'/digest', headers={
'Authorization': 'Digest username="john",realm="{0}",'
'nonce="{1}",uri="/digest",response="{2}",'
'nonce="{1}",uri="/digest",qop=auth,'
'nc=00000001,cnonce="foobar",response="{2}",'
'opaque="{3}"'.format(d['realm'],
d['nonce'],
auth_response,
Expand Down
198 changes: 198 additions & 0 deletions tests/test_digest_no_qop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import unittest
import re
from hashlib import md5 as basic_md5
from flask import Flask
from flask_httpauth import HTTPDigestAuth
from werkzeug.http import parse_dict_header


def md5(str):
if type(str).__name__ == 'str':
str = str.encode('utf-8')
return basic_md5(str)


def get_ha1(user, pw, realm):
a1 = user + ":" + realm + ":" + pw
return md5(a1).hexdigest()


class HTTPAuthTestCase(unittest.TestCase):
def setUp(self):
app = Flask(__name__)
app.config['SECRET_KEY'] = 'my secret'

digest_auth = HTTPDigestAuth(qop=None)

@digest_auth.get_password
def get_digest_password_2(username):
if username == 'susan':
return 'hello'
elif username == 'john':
return 'bye'
else:
return None

@app.route('/')
def index():
return 'index'

@app.route('/digest')
@digest_auth.login_required
def digest_auth_route():
return 'digest_auth:' + digest_auth.username()

self.app = app
self.digest_auth = digest_auth
self.client = app.test_client()

def test_digest_auth_prompt(self):
response = self.client.get('/digest')
self.assertEqual(response.status_code, 401)
self.assertTrue('WWW-Authenticate' in response.headers)
self.assertTrue(re.match(r'^Digest realm="Authentication Required",'
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$',
response.headers['WWW-Authenticate']))

def test_digest_auth_ignore_options(self):
response = self.client.options('/digest')
self.assertEqual(response.status_code, 200)
self.assertTrue('WWW-Authenticate' not in response.headers)

def test_digest_auth_login_valid(self):
response = self.client.get('/digest')
self.assertTrue(response.status_code == 401)
header = response.headers.get('WWW-Authenticate')
auth_type, auth_info = header.split(None, 1)
d = parse_dict_header(auth_info)

a1 = 'john:' + d['realm'] + ':bye'
ha1 = md5(a1).hexdigest()
a2 = 'GET:/digest'
ha2 = md5(a2).hexdigest()
a3 = ha1 + ':' + d['nonce'] + ':' + ha2
auth_response = md5(a3).hexdigest()

response = self.client.get(
'/digest', headers={
'Authorization': 'Digest username="john",realm="{0}",'
'nonce="{1}",uri="/digest",response="{2}",'
'opaque="{3}"'.format(d['realm'],
d['nonce'],
auth_response,
d['opaque'])})
self.assertEqual(response.data, b'digest_auth:john')

def test_digest_auth_login_bad_realm(self):
response = self.client.get('/digest')
self.assertTrue(response.status_code == 401)
header = response.headers.get('WWW-Authenticate')
auth_type, auth_info = header.split(None, 1)
d = parse_dict_header(auth_info)

a1 = 'john:' + 'Wrong Realm' + ':bye'
ha1 = md5(a1).hexdigest()
a2 = 'GET:/digest'
ha2 = md5(a2).hexdigest()
a3 = ha1 + ':' + d['nonce'] + ':' + ha2
auth_response = md5(a3).hexdigest()

response = self.client.get(
'/digest', headers={
'Authorization': 'Digest username="john",realm="{0}",'
'nonce="{1}",uri="/digest",response="{2}",'
'opaque="{3}"'.format(d['realm'],
d['nonce'],
auth_response,
d['opaque'])})
self.assertEqual(response.status_code, 401)
self.assertTrue('WWW-Authenticate' in response.headers)
self.assertTrue(re.match(r'^Digest realm="Authentication Required",'
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$',
response.headers['WWW-Authenticate']))

def test_digest_auth_login_invalid2(self):
response = self.client.get('/digest')
self.assertEqual(response.status_code, 401)
header = response.headers.get('WWW-Authenticate')
auth_type, auth_info = header.split(None, 1)
d = parse_dict_header(auth_info)

a1 = 'david:' + 'Authentication Required' + ':bye'
ha1 = md5(a1).hexdigest()
a2 = 'GET:/digest'
ha2 = md5(a2).hexdigest()
a3 = ha1 + ':' + d['nonce'] + ':' + ha2
auth_response = md5(a3).hexdigest()

response = self.client.get(
'/digest', headers={
'Authorization': 'Digest username="david",realm="{0}",'
'nonce="{1}",uri="/digest",response="{2}",'
'opaque="{3}"'.format(d['realm'],
d['nonce'],
auth_response,
d['opaque'])})
self.assertEqual(response.status_code, 401)
self.assertTrue('WWW-Authenticate' in response.headers)
self.assertTrue(re.match(r'^Digest realm="Authentication Required",'
r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$',
response.headers['WWW-Authenticate']))

def test_digest_generate_ha1(self):
ha1 = self.digest_auth.generate_ha1('pawel', 'test')
ha1_expected = get_ha1('pawel', 'test', self.digest_auth.realm)
self.assertEqual(ha1, ha1_expected)

def test_digest_custom_nonce_checker(self):
@self.digest_auth.generate_nonce
def noncemaker():
return 'not a good nonce'

@self.digest_auth.generate_opaque
def opaquemaker():
return 'some opaque'

verify_nonce_called = []

@self.digest_auth.verify_nonce
def verify_nonce(provided_nonce):
verify_nonce_called.append(provided_nonce)
return True

verify_opaque_called = []

@self.digest_auth.verify_opaque
def verify_opaque(provided_opaque):
verify_opaque_called.append(provided_opaque)
return True

response = self.client.get('/digest')
self.assertEqual(response.status_code, 401)
header = response.headers.get('WWW-Authenticate')
auth_type, auth_info = header.split(None, 1)
d = parse_dict_header(auth_info)

self.assertEqual(d['nonce'], 'not a good nonce')
self.assertEqual(d['opaque'], 'some opaque')

a1 = 'john:' + d['realm'] + ':bye'
ha1 = md5(a1).hexdigest()
a2 = 'GET:/digest'
ha2 = md5(a2).hexdigest()
a3 = ha1 + ':' + d['nonce'] + ':' + ha2
auth_response = md5(a3).hexdigest()

response = self.client.get(
'/digest', headers={
'Authorization': 'Digest username="john",realm="{0}",'
'nonce="{1}",uri="/digest",response="{2}",'
'opaque="{3}"'.format(d['realm'],
d['nonce'],
auth_response,
d['opaque'])})
self.assertEqual(response.data, b'digest_auth:john')
self.assertEqual(verify_nonce_called, ['not a good nonce'],
"Should have verified the nonce.")
self.assertEqual(verify_opaque_called, ['some opaque'],
"Should have verified the opaque.")

0 comments on commit d311fe5

Please sign in to comment.