From 98ec9061ccc40245fa3c09e453f6eab4594900ab Mon Sep 17 00:00:00 2001 From: Matt Proetsch Date: Sat, 3 Aug 2024 11:33:16 -0400 Subject: [PATCH 1/2] add HTTPCookieAuth for token auth in req cookies --- src/flask_httpauth.py | 44 +++++++++ tests/test_cookie.py | 192 +++++++++++++++++++++++++++++++++++++ tests/test_cookie_async.py | 192 +++++++++++++++++++++++++++++++++++++ 3 files changed, 428 insertions(+) create mode 100644 tests/test_cookie.py create mode 100644 tests/test_cookie_async.py diff --git a/src/flask_httpauth.py b/src/flask_httpauth.py index 146da9a..b6fe6ab 100644 --- a/src/flask_httpauth.py +++ b/src/flask_httpauth.py @@ -195,6 +195,50 @@ def ensure_sync(self, f): return f +class HTTPCookieAuth(HTTPAuth): + def __init__(self, scheme=None, realm=None, cookie_name=None): + super(HTTPCookieAuth, self).__init__(scheme or 'Bearer', realm, cookie_name) + + self.verify_cookie_callback = None + self.cookie_name = cookie_name + + def verify_cookie(self, f): + self.verify_cookie_callback = f + return f + + def authenticate(self, auth, _): + cookie = getattr(auth, 'token', '') + if self.verify_cookie_callback: + return self.ensure_sync(self.verify_cookie_callback)(cookie) + + def get_auth(self): + expected_cookie_name = self.cookie_name or 'Authorization' + cookie_val = request.cookies.get(expected_cookie_name, '') + token = '' + if self.scheme != 'ApiKey': + # if scheme is Bearer or anything else besides ApiKey, split on scheme name + if isinstance(cookie_val, str) and len(cookie_val) > 0: + try: + scheme, token = cookie_val.split(' ') + except ValueError: + # not enough values to unpack + return None + # ensure scheme names match (case insensitive) + if scheme.lower() != (self.scheme or "Bearer").lower(): + return None + else: + # for ApiKey scheme, use whole cookie value + token = cookie_val + auth = Authorization(self.scheme, token=token) + return auth + + def get_auth_password(self, auth): + try: + return getattr(auth, 'token', '') + except KeyError: + return "" + + class HTTPBasicAuth(HTTPAuth): def __init__(self, scheme=None, realm=None): super(HTTPBasicAuth, self).__init__(scheme or 'Basic', realm) diff --git a/tests/test_cookie.py b/tests/test_cookie.py new file mode 100644 index 0000000..ade270e --- /dev/null +++ b/tests/test_cookie.py @@ -0,0 +1,192 @@ +import base64 +import unittest +from flask import Flask +from flask_httpauth import HTTPCookieAuth + + +class HTTPAuthTestCase(unittest.TestCase): + def setUp(self): + app = Flask(__name__) + app.config['SECRET_KEY'] = 'my secret' + + cookie_auth = HTTPCookieAuth('MyToken') + cookie_auth2 = HTTPCookieAuth('Token', realm='foo') + cookie_auth3 = HTTPCookieAuth(scheme='ApiKey', cookie_name='X-API-Key') + cookie_default = HTTPCookieAuth() + + @cookie_auth.verify_cookie + def verify_cookie(token): + if token == 'this-is-the-token!': + return 'user' + + @cookie_auth3.verify_cookie + def verify_cookie3(token): + if token == 'this-is-the-token!': + return 'user' + + @cookie_default.verify_cookie + def verify_cookie_default(token): + if token == 'this-is-the-token!': + return 'user' + + @cookie_auth.error_handler + def error_handler(): + return 'error', 401, {'WWW-Authenticate': 'MyToken realm="Foo"'} + + @app.route('/') + def index(): + return 'index' + + @app.route('/protected') + @cookie_auth.login_required + def cookie_auth_route(): + return 'cookie_auth:' + cookie_auth.current_user() + + @app.route('/protected-optional') + @cookie_auth.login_required(optional=True) + def cookie_auth_optional_route(): + return 'cookie_auth:' + str(cookie_auth.current_user()) + + @app.route('/protected2') + @cookie_auth2.login_required + def cookie_auth_route2(): + return 'cookie_auth2' + + @app.route('/protected3') + @cookie_auth3.login_required + def cookie_auth_route3(): + return 'cookie_auth3:' + cookie_auth3.current_user() + + @app.route('/protected-default') + @cookie_default.login_required + def cookie_default_auth_route(): + return 'cookie_default:' + cookie_default.current_user() + + self.app = app + self.cookie_auth = cookie_auth + self.client = app.test_client() + + def tearDown(self) -> None: + self.client._cookies.clear() + + def test_cookie_auth_prompt(self): + response = self.client.get('/protected') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'MyToken realm="Foo"') + + def test_cookie_auth_ignore_options(self): + response = self.client.options('/protected') + self.assertEqual(response.status_code, 200) + self.assertTrue('WWW-Authenticate' not in response.headers) + + def test_cookie_auth_login_valid(self): + self.client.set_cookie("Authorization", "MyToken this-is-the-token!") + response = self.client.get('/protected') + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth:user') + + def test_cookie_auth_login_valid_different_case(self): + self.client.set_cookie("Authorization", "mytoken this-is-the-token!") + response = self.client.get('/protected') + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth:user') + + def test_cookie_auth_login_optional(self): + response = self.client.get('/protected-optional') + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth:None') + + def test_cookie_auth_login_invalid_token(self): + self.client.set_cookie("Authorization", "MyToken this-is-not-the-token!") + response = self.client.get('/protected') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'MyToken realm="Foo"') + + def test_cookie_auth_login_invalid_scheme(self): + self.client.set_cookie("Authorization", "Foo this-is-the-token!") + response = self.client.get('/protected') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'MyToken realm="Foo"') + + def test_cookie_auth_login_invalid_header(self): + self.client.set_cookie("Authorization", "this-is-a-bad-cookie") + response = self.client.get('/protected') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'MyToken realm="Foo"') + + def test_cookie_auth_login_invalid_no_callback(self): + self.client.set_cookie("Authorization", "Token this-is-the-token!") + response = self.client.get('/protected2') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'Token realm="foo"') + + def test_cookie_auth_custom_header_valid_token(self): + self.client.set_cookie("X-API-Key", "this-is-the-token!") + response = self.client.get('/protected3') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth3:user') + + def test_cookie_auth_custom_header_invalid_token(self): + self.client.set_cookie("X-API-Key", "invalid-token-should-fail") + response = self.client.get('/protected3') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + + def test_cookie_auth_custom_header_invalid_header(self): + self.client.set_cookie("API-Key", "this-is-the-token!") + response = self.client.get('/protected3') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'ApiKey realm="Authentication Required"') + + def test_cookie_auth_header_precedence(self): + self.client.set_cookie("X-API-Key", "this-is-the-token!") + basic_creds = base64.b64encode(b'susan:bye').decode('utf-8') + response = self.client.get( + '/protected3', headers={'Authorization': 'Basic ' + basic_creds,}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth3:user') + + def test_cookie_auth_default_bearer(self): + self.client.set_cookie("Authorization", "Bearer this-is-the-token!") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'cookie_default:user') + + def test_cookie_auth_default_bearer_valid_token(self): + self.client.set_cookie("Authorization", "Bearer this-is-the-token!") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'cookie_default:user') + + def test_cookie_auth_default_bearer_invalid_token(self): + self.client.set_cookie("Authorization", "Bearer Invalid-token!") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'Bearer realm="Authentication Required"') + + def test_cookie_auth_default_bearer_malformed_value(self): + self.client.set_cookie("Authorization", "this-shouldn't-parse") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'Bearer realm="Authentication Required"') + + def test_cookie_auth_default_bearer_missing_cookie(self): + self.client.set_cookie("Otterization", "Bearer this-is-the-token!") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'Bearer realm="Authentication Required"') \ No newline at end of file diff --git a/tests/test_cookie_async.py b/tests/test_cookie_async.py new file mode 100644 index 0000000..af319a4 --- /dev/null +++ b/tests/test_cookie_async.py @@ -0,0 +1,192 @@ +import base64 +import unittest +from flask import Flask +from flask_httpauth import HTTPCookieAuth + + +class HTTPAuthTestCase(unittest.TestCase): + def setUp(self): + app = Flask(__name__) + app.config['SECRET_KEY'] = 'my secret' + + cookie_auth = HTTPCookieAuth('MyToken') + cookie_auth2 = HTTPCookieAuth('Token', realm='foo') + cookie_auth3 = HTTPCookieAuth(scheme='ApiKey', cookie_name='X-API-Key') + cookie_default = HTTPCookieAuth() + + @cookie_auth.verify_cookie + async def verify_cookie(token): + if token == 'this-is-the-token!': + return 'user' + + @cookie_auth3.verify_cookie + async def verify_cookie3(token): + if token == 'this-is-the-token!': + return 'user' + + @cookie_default.verify_cookie + async def verify_cookie_default(token): + if token == 'this-is-the-token!': + return 'user' + + @cookie_auth.error_handler + async def error_handler(): + return 'error', 401, {'WWW-Authenticate': 'MyToken realm="Foo"'} + + @app.route('/') + async def index(): + return 'index' + + @app.route('/protected') + @cookie_auth.login_required + async def cookie_auth_route(): + return 'cookie_auth:' + cookie_auth.current_user() + + @app.route('/protected-optional') + @cookie_auth.login_required(optional=True) + async def cookie_auth_optional_route(): + return 'cookie_auth:' + str(cookie_auth.current_user()) + + @app.route('/protected2') + @cookie_auth2.login_required + async def cookie_auth_route2(): + return 'cookie_auth2' + + @app.route('/protected3') + @cookie_auth3.login_required + async def cookie_auth_route3(): + return 'cookie_auth3:' + cookie_auth3.current_user() + + @app.route('/protected-default') + @cookie_default.login_required + async def cookie_default_auth_route(): + return 'cookie_default:' + cookie_default.current_user() + + self.app = app + self.cookie_auth = cookie_auth + self.client = app.test_client() + + def tearDown(self) -> None: + self.client._cookies.clear() + + def test_cookie_auth_prompt(self): + response = self.client.get('/protected') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'MyToken realm="Foo"') + + def test_cookie_auth_ignore_options(self): + response = self.client.options('/protected') + self.assertEqual(response.status_code, 200) + self.assertTrue('WWW-Authenticate' not in response.headers) + + def test_cookie_auth_login_valid(self): + self.client.set_cookie("Authorization", "MyToken this-is-the-token!") + response = self.client.get('/protected') + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth:user') + + def test_cookie_auth_login_valid_different_case(self): + self.client.set_cookie("Authorization", "mytoken this-is-the-token!") + response = self.client.get('/protected') + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth:user') + + def test_cookie_auth_login_optional(self): + response = self.client.get('/protected-optional') + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth:None') + + def test_cookie_auth_login_invalid_token(self): + self.client.set_cookie("Authorization", "MyToken this-is-not-the-token!") + response = self.client.get('/protected') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'MyToken realm="Foo"') + + def test_cookie_auth_login_invalid_scheme(self): + self.client.set_cookie("Authorization", "Foo this-is-the-token!") + response = self.client.get('/protected') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'MyToken realm="Foo"') + + def test_cookie_auth_login_invalid_header(self): + self.client.set_cookie("Authorization", "this-is-a-bad-cookie") + response = self.client.get('/protected') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'MyToken realm="Foo"') + + def test_cookie_auth_login_invalid_no_callback(self): + self.client.set_cookie("Authorization", "Token this-is-the-token!") + response = self.client.get('/protected2') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'Token realm="foo"') + + def test_cookie_auth_custom_header_valid_token(self): + self.client.set_cookie("X-API-Key", "this-is-the-token!") + response = self.client.get('/protected3') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth3:user') + + def test_cookie_auth_custom_header_invalid_token(self): + self.client.set_cookie("X-API-Key", "invalid-token-should-fail") + response = self.client.get('/protected3') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + + def test_cookie_auth_custom_header_invalid_header(self): + self.client.set_cookie("API-Key", "this-is-the-token!") + response = self.client.get('/protected3') + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'ApiKey realm="Authentication Required"') + + def test_cookie_auth_header_precedence(self): + self.client.set_cookie("X-API-Key", "this-is-the-token!") + basic_creds = base64.b64encode(b'susan:bye').decode('utf-8') + response = self.client.get( + '/protected3', headers={'Authorization': 'Basic ' + basic_creds,}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'cookie_auth3:user') + + def test_cookie_auth_default_bearer(self): + self.client.set_cookie("Authorization", "Bearer this-is-the-token!") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'cookie_default:user') + + def test_cookie_auth_default_bearer_valid_token(self): + self.client.set_cookie("Authorization", "Bearer this-is-the-token!") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'cookie_default:user') + + def test_cookie_auth_default_bearer_invalid_token(self): + self.client.set_cookie("Authorization", "Bearer Invalid-token!") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'Bearer realm="Authentication Required"') + + def test_cookie_auth_default_bearer_malformed_value(self): + self.client.set_cookie("Authorization", "this-shouldn't-parse") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'Bearer realm="Authentication Required"') + + def test_cookie_auth_default_bearer_missing_cookie(self): + self.client.set_cookie("Otterization", "Bearer this-is-the-token!") + response = self.client.get("/protected-default") + self.assertEqual(response.status_code, 401) + self.assertTrue('WWW-Authenticate' in response.headers) + self.assertEqual(response.headers['WWW-Authenticate'], + 'Bearer realm="Authentication Required"') \ No newline at end of file From bfaccfeef42ac18676cbc904806a66f537335e58 Mon Sep 17 00:00:00 2001 From: Matt Proetsch Date: Sat, 3 Aug 2024 12:35:34 -0400 Subject: [PATCH 2/2] lint + remove unnecessary get_auth_password --- src/flask_httpauth.py | 15 +++++++-------- tests/test_cookie.py | 9 +++++---- tests/test_cookie_async.py | 9 +++++---- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/flask_httpauth.py b/src/flask_httpauth.py index b6fe6ab..f3a01d9 100644 --- a/src/flask_httpauth.py +++ b/src/flask_httpauth.py @@ -197,7 +197,11 @@ def ensure_sync(self, f): class HTTPCookieAuth(HTTPAuth): def __init__(self, scheme=None, realm=None, cookie_name=None): - super(HTTPCookieAuth, self).__init__(scheme or 'Bearer', realm, cookie_name) + super(HTTPCookieAuth, self).__init__( + scheme or 'Bearer', + realm, + cookie_name + ) self.verify_cookie_callback = None self.cookie_name = cookie_name @@ -216,7 +220,8 @@ def get_auth(self): cookie_val = request.cookies.get(expected_cookie_name, '') token = '' if self.scheme != 'ApiKey': - # if scheme is Bearer or anything else besides ApiKey, split on scheme name + # if scheme is Bearer or anything else besides ApiKey, + # split on scheme name if isinstance(cookie_val, str) and len(cookie_val) > 0: try: scheme, token = cookie_val.split(' ') @@ -232,12 +237,6 @@ def get_auth(self): auth = Authorization(self.scheme, token=token) return auth - def get_auth_password(self, auth): - try: - return getattr(auth, 'token', '') - except KeyError: - return "" - class HTTPBasicAuth(HTTPAuth): def __init__(self, scheme=None, realm=None): diff --git a/tests/test_cookie.py b/tests/test_cookie.py index ade270e..2d42492 100644 --- a/tests/test_cookie.py +++ b/tests/test_cookie.py @@ -65,7 +65,7 @@ def cookie_default_auth_route(): self.app = app self.cookie_auth = cookie_auth self.client = app.test_client() - + def tearDown(self) -> None: self.client._cookies.clear() @@ -96,7 +96,8 @@ def test_cookie_auth_login_optional(self): self.assertEqual(response.data.decode('utf-8'), 'cookie_auth:None') def test_cookie_auth_login_invalid_token(self): - self.client.set_cookie("Authorization", "MyToken this-is-not-the-token!") + self.client.set_cookie("Authorization", + "MyToken this-is-not-the-token!") response = self.client.get('/protected') self.assertEqual(response.status_code, 401) self.assertTrue('WWW-Authenticate' in response.headers) @@ -151,7 +152,7 @@ def test_cookie_auth_header_precedence(self): self.client.set_cookie("X-API-Key", "this-is-the-token!") basic_creds = base64.b64encode(b'susan:bye').decode('utf-8') response = self.client.get( - '/protected3', headers={'Authorization': 'Basic ' + basic_creds,}) + '/protected3', headers={'Authorization': 'Basic ' + basic_creds}) self.assertEqual(response.status_code, 200) self.assertEqual(response.data.decode('utf-8'), 'cookie_auth3:user') @@ -189,4 +190,4 @@ def test_cookie_auth_default_bearer_missing_cookie(self): self.assertEqual(response.status_code, 401) self.assertTrue('WWW-Authenticate' in response.headers) self.assertEqual(response.headers['WWW-Authenticate'], - 'Bearer realm="Authentication Required"') \ No newline at end of file + 'Bearer realm="Authentication Required"') diff --git a/tests/test_cookie_async.py b/tests/test_cookie_async.py index af319a4..a85df86 100644 --- a/tests/test_cookie_async.py +++ b/tests/test_cookie_async.py @@ -65,7 +65,7 @@ async def cookie_default_auth_route(): self.app = app self.cookie_auth = cookie_auth self.client = app.test_client() - + def tearDown(self) -> None: self.client._cookies.clear() @@ -96,7 +96,8 @@ def test_cookie_auth_login_optional(self): self.assertEqual(response.data.decode('utf-8'), 'cookie_auth:None') def test_cookie_auth_login_invalid_token(self): - self.client.set_cookie("Authorization", "MyToken this-is-not-the-token!") + self.client.set_cookie("Authorization", + "MyToken this-is-not-the-token!") response = self.client.get('/protected') self.assertEqual(response.status_code, 401) self.assertTrue('WWW-Authenticate' in response.headers) @@ -151,7 +152,7 @@ def test_cookie_auth_header_precedence(self): self.client.set_cookie("X-API-Key", "this-is-the-token!") basic_creds = base64.b64encode(b'susan:bye').decode('utf-8') response = self.client.get( - '/protected3', headers={'Authorization': 'Basic ' + basic_creds,}) + '/protected3', headers={'Authorization': 'Basic ' + basic_creds}) self.assertEqual(response.status_code, 200) self.assertEqual(response.data.decode('utf-8'), 'cookie_auth3:user') @@ -189,4 +190,4 @@ def test_cookie_auth_default_bearer_missing_cookie(self): self.assertEqual(response.status_code, 401) self.assertTrue('WWW-Authenticate' in response.headers) self.assertEqual(response.headers['WWW-Authenticate'], - 'Bearer realm="Authentication Required"') \ No newline at end of file + 'Bearer realm="Authentication Required"')