From bf12f959bba24a2f3d7d799d1b57ef3a5f1001e8 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sat, 19 Sep 2015 16:05:08 -0700 Subject: [PATCH] Support custom authentication scheme and realm --- docs/index.rst | 18 ++++++++++++++---- flask_httpauth.py | 22 ++++++++++++---------- test_httpauth.py | 41 +++++++++++++++++++++++------------------ 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 8d7b111..8ac1448 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -95,7 +95,7 @@ The following example is similar to the previous one, but HTTP Digest authentica Security Concerns with Digest Authentication ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The digest authentication algorightm requires a *challenge* to be sent to the client for use in encrypting the password for transmission. This challenge needs to be used again when the password is decoded at the server, so the challenge information needs to be stored so that it can be recalled later. +The digest authentication algorihtm requires a *challenge* to be sent to the client for use in encrypting the password for transmission. This challenge needs to be used again when the password is decoded at the server, so the challenge information needs to be stored so that it can be recalled later. By default, Flask-HTTPAuth stores the challenge data in the Flask session. To make the authentication flow secure when using session storage, it is required that server-side sessions are used instead of the default Flask cookie based sessions, as this ensures that the challenge data is not at risk of being captured as it moves in a cookie between server and client. The Flask-Session and Flask-KVSession extensions are both very good options to implement server-side sessions. @@ -138,10 +138,14 @@ API Documentation This class that handles HTTP Basic authentication for Flask routes. - .. method:: __init__() + .. method:: __init__(scheme=None, realm=None) Create a basic authentication object. + If the optional ``scheme`` argument is provided, it will be used instead of the standard "Basic" scheme in the ``WWW-Authenticate`` response. A fairly common practice is to use a custom scheme to prevent browsers from prompting the user to login. + + The ``realm`` argument can be used to provide an application defined realm with the ``WWW-Authenticate`` header. + .. method:: get_password(password_callback) This callback function will be called by the framework to obtain the password for a given user. Example:: @@ -210,9 +214,15 @@ API Documentation This class that handles HTTP Digest authentication for Flask routes. The ``SECRET_KEY`` configuration must be set in the Flask application to enable the session to work. Flask by default stores user sessions in the client as secure cookies, so the client must be able to handle cookies. To support clients that are not web browsers or that cannot handle cookies a `session interface `_ that writes sessions in the server must be used. - .. method:: __init__(self, use_ha1_pw=False) + .. method:: __init__(self, scheme=None, realm=None, use_ha1_pw=False) + + Create a digest authentication object. + + If the optional ``scheme`` argument is provided, it will be used instead of the "Digest" scheme in the ``WWW-Authenticate`` response. A fairly common practice is to use a custom scheme to prevent browsers from prompting the user to login. + + The ``realm`` argument can be used to provide an application defined realm with the ``WWW-Authenticate`` header. - Create a digest authentication object. If ``use_ha1_pw`` is False, then the ``get_password`` callback needs to return the plain text password for the given user. If ``use_ha1_pw`` is True, the ``get_password`` callback needs to return the HA1 value for the given user. The advantage of setting ``use_ha1_pw`` to ``True`` is that it allows the application to store the HA1 hash of the password in the user database. + If ``use_ha1_pw`` is False, then the ``get_password`` callback needs to return the plain text password for the given user. If ``use_ha1_pw`` is True, the ``get_password`` callback needs to return the HA1 value for the given user. The advantage of setting ``use_ha1_pw`` to ``True`` is that it allows the application to store the HA1 hash of the password in the user database. .. method:: generate_ha1(username, password) diff --git a/flask_httpauth.py b/flask_httpauth.py index bf497c7..6e207f4 100644 --- a/flask_httpauth.py +++ b/flask_httpauth.py @@ -15,14 +15,15 @@ class HTTPAuth(object): - def __init__(self): + def __init__(self, scheme=None, realm=None): def default_get_password(username): return None def default_auth_error(): return "Unauthorized Access" - self.realm = "Authentication Required" + self.scheme = scheme + self.realm = realm or "Authentication Required" self.get_password(default_get_password) self.error_handler(default_auth_error) @@ -68,8 +69,8 @@ def username(self): class HTTPBasicAuth(HTTPAuth): - def __init__(self): - super(HTTPBasicAuth, self).__init__() + def __init__(self, scheme=None, realm=None): + super(HTTPBasicAuth, self).__init__(scheme, realm) self.hash_password(None) self.verify_password(None) @@ -82,7 +83,7 @@ def verify_password(self, f): return f def authenticate_header(self): - return 'Basic realm="{0}"'.format(self.realm) + return '{0} realm="{1}"'.format(self.scheme or 'Basic', self.realm) def authenticate(self, auth, stored_password): if auth: @@ -105,8 +106,8 @@ def authenticate(self, auth, stored_password): class HTTPDigestAuth(HTTPAuth): - def __init__(self, use_ha1_pw = False): - super(HTTPDigestAuth, self).__init__() + def __init__(self, scheme=None, realm=None, use_ha1_pw=False): + super(HTTPDigestAuth, self).__init__(scheme, realm) self.use_ha1_pw = use_ha1_pw self.random = SystemRandom() try: @@ -130,7 +131,7 @@ def default_generate_opaque(): def default_verify_opaque(opaque): return opaque == session.get("auth_opaque") - + self.generate_nonce(default_generate_nonce) self.generate_opaque(default_generate_opaque) self.verify_nonce(default_verify_nonce) @@ -166,8 +167,9 @@ def generate_ha1(self, username, password): def authenticate_header(self): session["auth_nonce"] = self.get_nonce() session["auth_opaque"] = self.get_opaque() - return 'Digest realm="{0}",nonce="{1}",opaque="{2}"'.format( - self.realm, session["auth_nonce"], session["auth_opaque"]) + return '{0} realm="{1}",nonce="{2}",opaque="{3}"'.format( + self.scheme or 'Digest', self.realm, session["auth_nonce"], + session["auth_opaque"]) def authenticate(self, auth, stored_password_or_ha1): if not auth or not auth.username or not auth.realm or not auth.uri \ diff --git a/test_httpauth.py b/test_httpauth.py index 1208e45..93ffe6f 100644 --- a/test_httpauth.py +++ b/test_httpauth.py @@ -12,24 +12,26 @@ def md5(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' basic_auth = HTTPBasicAuth() - basic_auth_my_realm = HTTPBasicAuth() - basic_auth_my_realm.realm = 'My Realm' + basic_auth_my_realm = HTTPBasicAuth(scheme='CustomBasic', + realm='My Realm') basic_custom_auth = HTTPBasicAuth() basic_verify_auth = HTTPBasicAuth() digest_auth = HTTPDigestAuth() - digest_auth_my_realm = HTTPDigestAuth() - digest_auth_my_realm.realm = 'My Realm' - digest_auth_ha1_pw = HTTPDigestAuth(use_ha1_pw = True) + digest_auth_my_realm = HTTPDigestAuth(scheme='CustomDigest', + realm='My Realm') + digest_auth_ha1_pw = HTTPDigestAuth(use_ha1_pw=True) @digest_auth_ha1_pw.get_password def get_digest_password(username): @@ -92,7 +94,7 @@ def basic_verify_auth_verify_password(username, password): return False @digest_auth.get_password - def get_digest_password(username): + def get_digest_password_2(username): if username == 'susan': return 'hello' elif username == 'john': @@ -101,7 +103,7 @@ def get_digest_password(username): return None @digest_auth_my_realm.get_password - def get_digest_password_2(username): + def get_digest_password_3(username): if username == 'susan': return 'hello' elif username == 'john': @@ -178,7 +180,7 @@ def test_basic_auth_prompt_with_custom_realm(self): self.assertEqual(response.status_code, 401) self.assertTrue('WWW-Authenticate' in response.headers) self.assertEqual(response.headers['WWW-Authenticate'], - 'Basic realm="My Realm"') + 'CustomBasic realm="My Realm"') self.assertEqual(response.data.decode('utf-8'), 'custom error') def test_basic_auth_login_valid(self): @@ -208,7 +210,7 @@ def test_basic_auth_login_invalid(self): self.assertEqual(response.status_code, 401) self.assertTrue('WWW-Authenticate' in response.headers) self.assertEqual(response.headers['WWW-Authenticate'], - 'Basic realm="My Realm"') + 'CustomBasic realm="My Realm"') def test_basic_custom_auth_login_valid(self): creds = base64.b64encode(b'john:hello').decode('utf-8') @@ -230,7 +232,6 @@ def test_verify_auth_login_valid(self): self.assertEqual(response.data, b'basic_verify_auth:susan anon:False') def test_verify_auth_login_empty(self): - creds = base64.b64encode(b'susan:bye').decode('utf-8') response = self.client.get('/basic-verify') self.assertEqual(response.data, b'basic_verify_auth: anon:True') @@ -258,8 +259,8 @@ def test_digest_auth_prompt_with_custom_realm(self): response = self.client.get('/digest-with-realm') self.assertEqual(response.status_code, 401) self.assertTrue('WWW-Authenticate' in response.headers) - self.assertTrue(re.match(r'^Digest realm="My Realm",nonce="[0-9a-f]+",' - r'opaque="[0-9a-f]+"$', + self.assertTrue(re.match(r'^CustomDigest realm="My Realm",' + 'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$', response.headers['WWW-Authenticate'])) def test_digest_auth_login_valid(self): @@ -303,7 +304,8 @@ def test_digest_ha1_pw_auth_login_valid(self): response = self.client.get( '/digest', headers={ 'Authorization': 'Digest username="john",realm="{0}",' - 'nonce="{1}",uri="/digest_ha1_pw",response="{2}",' + 'nonce="{1}",uri="/digest_ha1_pw",' + 'response="{2}",' 'opaque="{3}"'.format(d['realm'], d['nonce'], auth_response, @@ -341,15 +343,16 @@ def test_digest_auth_login_bad_realm(self): def test_digest_auth_login_invalid(self): response = self.client.get( '/digest-with-realm', headers={ - "Authorization": 'Digest username="susan",realm="My Realm",' + "Authorization": 'CustomDigest username="susan",' + 'realm="My Realm",' 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",' 'uri="/digest-with-realm",' 'response="ca306c361a9055b968810067a37fb8cb",' 'opaque="5ccc069c403ebaf9f0171e9517f40e41"'}) self.assertEqual(response.status_code, 401) self.assertTrue('WWW-Authenticate' in response.headers) - self.assertTrue(re.match(r'^Digest realm="My Realm",nonce="[0-9a-f]+",' - r'opaque="[0-9a-f]+"$', + self.assertTrue(re.match(r'^CustomDigest realm="My Realm",' + r'nonce="[0-9a-f]+",opaque="[0-9a-f]+"$', response.headers['WWW-Authenticate'])) def test_digest_auth_login_invalid2(self): @@ -395,12 +398,14 @@ 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) @@ -432,9 +437,9 @@ def verify_opaque(provided_opaque): d['opaque'])}) self.assertEqual(response.data, b'digest_auth:john') self.assertEqual(verify_nonce_called, ['not a good nonce'], - "Should have verified the nonce.") + "Should have verified the nonce.") self.assertEqual(verify_opaque_called, ['some opaque'], - "Should have verified the opaque.") + "Should have verified the opaque.") def suite():