From 1f657568bb24752aeb1515bd5aa1ccd2a5bcce77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Thu, 5 Oct 2023 08:37:54 +0800 Subject: [PATCH] Replaced the "Flask-Testing" package with the "httpx" package for testing, and retired the unused "flask_digest_auth.test" module. "Flask-Testing" is not maintained for more than 3 years, and is not compatible with Flask 3. --- pyproject.toml | 2 +- src/flask_digest_auth/__init__.py | 3 +- src/flask_digest_auth/test.py | 155 --------------------------- tests/test_auth.py | 128 +++++++++++----------- tests/test_flask_login.py | 172 +++++++++++++++--------------- tests/testlib.py | 87 +++++++++++++++ 6 files changed, 241 insertions(+), 306 deletions(-) delete mode 100644 src/flask_digest_auth/test.py create mode 100644 tests/testlib.py diff --git a/pyproject.toml b/pyproject.toml index bdedc74..f1bc2ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ [project.optional-dependencies] test = [ "unittest", - "flask-testing", + "httpx", ] [project.urls] diff --git a/src/flask_digest_auth/__init__.py b/src/flask_digest_auth/__init__.py index bbc5aa4..feca91e 100644 --- a/src/flask_digest_auth/__init__.py +++ b/src/flask_digest_auth/__init__.py @@ -1,7 +1,7 @@ # The Flask HTTP Digest Authentication Project. # Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/6 -# Copyright (c) 2022 imacat. +# Copyright (c) 2022-2023 imacat. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ """ from flask_digest_auth.algo import make_password_hash, calc_response from flask_digest_auth.auth import DigestAuth -from flask_digest_auth.test import Client VERSION: str = "0.6.2" """The package version.""" diff --git a/src/flask_digest_auth/test.py b/src/flask_digest_auth/test.py deleted file mode 100644 index 744239f..0000000 --- a/src/flask_digest_auth/test.py +++ /dev/null @@ -1,155 +0,0 @@ -# The Flask HTTP Digest Authentication Project. -# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/3 - -# Copyright (c) 2022 imacat. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The test client with HTTP digest authentication enabled. - -""" -from secrets import token_urlsafe -from typing import Optional, Literal, Tuple, Dict - -from flask import g -from werkzeug.datastructures import Authorization, WWWAuthenticate -from werkzeug.http import parse_set_header -from werkzeug.test import TestResponse, Client as WerkzeugClient - -from flask_digest_auth.algo import calc_response, make_password_hash - - -class Client(WerkzeugClient): - """The test client with HTTP digest authentication enabled. - - :Example: - - For unittest_: - - :: - - class MyTestCase(flask_testing.TestCase): - - def create_app(self): - app: Flask = create_app({ - "SECRET_KEY": token_urlsafe(32), - "TESTING": True - }) - app.test_client_class = Client - return app - - def test_admin(self): - response = self.client.get("/admin") - self.assertEqual(response.status_code, 401) - response = self.client.get( - "/admin", digest_auth=(USERNAME, PASSWORD)) - self.assertEqual(response.status_code, 200) - - For pytest_: - - :: - - @pytest.fixture() - def app(): - app: Flask = create_app({ - "SECRET_KEY": token_urlsafe(32), - "TESTING": True - }) - app.test_client_class = Client - yield app - - @pytest.fixture() - def client(app): - return app.test_client() - - def test_admin(app: Flask, client: Client): - with app.app_context(): - response = client.get("/admin") - assert response.status_code == 401 - response = client.get( - "/admin", digest_auth=(USERNAME, PASSWORD)) - assert response.status_code == 200 - - .. _unittest: https://docs.python.org/3/library/unittest.html - .. _pytest: https://pytest.org - """ - - def open(self, *args, digest_auth: Optional[Tuple[str, str]] = None, - **kwargs) -> TestResponse: - """Opens a request. - - :param args: The arguments. - :param digest_auth: The (*username*, *password*) tuple for the HTTP - digest authentication. - :param kwargs: The keyword arguments. - :return: The response. - """ - response: TestResponse = super(Client, self).open(*args, **kwargs) - www_authenticate: WWWAuthenticate = response.www_authenticate - if not (response.status_code == 401 - and www_authenticate.type == "digest" - and digest_auth is not None): - return response - if hasattr(g, "_login_user"): - delattr(g, "_login_user") - auth_data: Authorization = self.__class__.make_authorization( - www_authenticate, args[0], digest_auth[0], digest_auth[1]) - response = super(Client, self).open(*args, auth=auth_data, **kwargs) - return response - - @staticmethod - def make_authorization(www_authenticate: WWWAuthenticate, uri: str, - username: str, password: str) -> Authorization: - """Composes and returns the request authorization. - - :param www_authenticate: The ``WWW-Authenticate`` response. - :param uri: The request URI. - :param username: The username. - :param password: The password. - :return: The request authorization. - """ - qop: Optional[Literal["auth", "auth-int"]] = None - if "auth" in parse_set_header(www_authenticate.get("qop")): - qop = "auth" - - cnonce: Optional[str] = None - if qop is not None or www_authenticate.algorithm == "MD5-sess": - cnonce = token_urlsafe(8) - nc: Optional[str] = None - count: int = 1 - if qop is not None: - nc: str = hex(count)[2:].zfill(8) - - expected: str = calc_response( - method="GET", uri=uri, - password_hash=make_password_hash(www_authenticate.realm, - username, password), - nonce=www_authenticate.nonce, qop=qop, - algorithm=www_authenticate.algorithm, cnonce=cnonce, nc=nc, - body=None) - - data: Dict[str, str] = { - "username": username, "realm": www_authenticate.realm, - "nonce": www_authenticate.nonce, "uri": uri, "response": expected} - if www_authenticate.algorithm is not None: - data["algorithm"] = www_authenticate.algorithm - if cnonce is not None: - data["cnonce"] = cnonce - if www_authenticate.opaque is not None: - data["opaque"] = www_authenticate.opaque - if qop is not None: - data["qop"] = qop - if nc is not None: - data["nc"] = nc - - return Authorization("digest", data=data) diff --git a/tests/test_auth.py b/tests/test_auth.py index c566a41..f5c1491 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -19,21 +19,17 @@ """ import logging +import unittest from secrets import token_urlsafe from typing import Any, Optional, Dict -from flask import Response, Flask, g, redirect, request -from flask_testing import TestCase -from werkzeug.datastructures import WWWAuthenticate, Authorization +import httpx +from flask import Flask, g, redirect, request +from werkzeug.datastructures import WWWAuthenticate -from flask_digest_auth import DigestAuth, make_password_hash, Client - -_REALM: str = "testrealm@host.com" -"""The realm.""" -_USERNAME: str = "Mufasa" -"""The username.""" -_PASSWORD: str = "Circle Of Life" -"""The password.""" +from flask_digest_auth import DigestAuth, make_password_hash +from testlib import REALM, USERNAME, PASSWORD, ADMIN_1_URI, ADMIN_2_URI, \ + LOGOUT_URI, make_authorization class User: @@ -47,34 +43,37 @@ class User: """ self.username: str = username """The username.""" - self.password_hash: str = make_password_hash(_REALM, username, password) + self.password_hash: str = make_password_hash(REALM, username, password) """The password hash.""" self.visits: int = 0 """The number of visits.""" -class AuthenticationTestCase(TestCase): +class AuthenticationTestCase(unittest.TestCase): """The test case for the HTTP digest authentication.""" - def create_app(self): - """Creates the Flask application. + def setUp(self) -> None: + """Sets up the test. + This is run once per test. - :return: The Flask application. + :return: None. """ logging.getLogger("test_auth").addHandler(logging.NullHandler()) app: Flask = Flask(__name__) app.config.from_mapping({ "TESTING": True, "SECRET_KEY": token_urlsafe(32), - "DIGEST_AUTH_REALM": _REALM, + "DIGEST_AUTH_REALM": REALM, }) - app.test_client_class = Client + self.__client: httpx.Client = httpx.Client( + app=app, base_url="https://testserver") + """The testing client.""" auth: DigestAuth = DigestAuth() auth.init_app(app) - self.__user: User = User(_USERNAME, _PASSWORD) + self.__user: User = User(USERNAME, PASSWORD) """The user account.""" - user_db: Dict[str, User] = {_USERNAME: self.__user} + user_db: Dict[str, User] = {USERNAME: self.__user} @auth.register_get_password def get_password_hash(username: str) -> Optional[str]: @@ -104,7 +103,7 @@ class AuthenticationTestCase(TestCase): """ user.visits = user.visits + 1 - @app.get("/admin-1/auth", endpoint="admin-1") + @app.get(ADMIN_1_URI, endpoint="admin-1") @auth.login_required def admin_1() -> str: """The first administration section. @@ -113,7 +112,7 @@ class AuthenticationTestCase(TestCase): """ return f"Hello, {g.user.username}! #1" - @app.get("/admin-2/auth", endpoint="admin-2") + @app.get(ADMIN_2_URI, endpoint="admin-2") @auth.login_required def admin_2() -> str: """The second administration section. @@ -122,7 +121,7 @@ class AuthenticationTestCase(TestCase): """ return f"Hello, {g.user.username}! #2" - @app.post("/logout", endpoint="logout") + @app.post(LOGOUT_URI, endpoint="logout") @auth.login_required def logout() -> redirect: """Logs out the user. @@ -132,24 +131,21 @@ class AuthenticationTestCase(TestCase): auth.logout() return redirect(request.form.get("next")) - return app - def test_auth(self) -> None: """Tests the authentication. :return: None. """ - response: Response = self.client.get(self.app.url_for("admin-1")) + response: httpx.Response + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - response = self.client.get( - self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.decode("UTF-8"), - f"Hello, {_USERNAME}! #1") - response: Response = self.client.get(self.app.url_for("admin-2")) + self.assertEqual(response.text, f"Hello, {USERNAME}! #1") + response = self.__client.get(ADMIN_2_URI) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.decode("UTF-8"), - f"Hello, {_USERNAME}! #2") + self.assertEqual(response.text, f"Hello, {USERNAME}! #2") self.assertEqual(self.__user.visits, 1) def test_stale_opaque(self) -> None: @@ -157,38 +153,43 @@ class AuthenticationTestCase(TestCase): :return: None. """ - admin_uri: str = self.app.url_for("admin-1") - response: Response + response: httpx.Response www_authenticate: WWWAuthenticate - auth_data: Authorization + auth_header: str - response = super(Client, self.client).get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - www_authenticate = response.www_authenticate + www_authenticate = WWWAuthenticate.from_header( + response.headers["WWW-Authenticate"]) self.assertEqual(www_authenticate.type, "digest") self.assertIsNone(www_authenticate.get("stale")) opaque: str = www_authenticate.opaque www_authenticate.nonce = "bad" - auth_data = Client.make_authorization( - www_authenticate, admin_uri, _USERNAME, _PASSWORD) - response = super(Client, self.client).get(admin_uri, auth=auth_data) + auth_header = make_authorization( + www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD) + response = self.__client.get(ADMIN_1_URI, + headers={"Authorization": auth_header}) self.assertEqual(response.status_code, 401) - www_authenticate = response.www_authenticate + www_authenticate = WWWAuthenticate.from_header( + response.headers["WWW-Authenticate"]) self.assertEqual(www_authenticate.get("stale"), "TRUE") self.assertEqual(www_authenticate.opaque, opaque) - auth_data = Client.make_authorization( - www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2") - response = super(Client, self.client).get(admin_uri, auth=auth_data) + auth_header = make_authorization( + www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD + "2") + response = self.__client.get(ADMIN_1_URI, + headers={"Authorization": auth_header}) self.assertEqual(response.status_code, 401) - www_authenticate = response.www_authenticate + www_authenticate = WWWAuthenticate.from_header( + response.headers["WWW-Authenticate"]) self.assertEqual(www_authenticate.get("stale"), "FALSE") self.assertEqual(www_authenticate.opaque, opaque) - auth_data = Client.make_authorization( - www_authenticate, admin_uri, _USERNAME, _PASSWORD) - response = super(Client, self.client).get(admin_uri, auth=auth_data) + auth_header = make_authorization( + www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD) + response = self.__client.get(ADMIN_1_URI, + headers={"Authorization": auth_header}) self.assertEqual(response.status_code, 200) def test_logout(self) -> None: @@ -196,35 +197,34 @@ class AuthenticationTestCase(TestCase): :return: None. """ - admin_uri: str = self.app.url_for("admin-1") - logout_uri: str = self.app.url_for("logout") - response: Response + logout_uri: str = LOGOUT_URI + response: httpx.Response - response = self.client.get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - response = self.client.get(admin_uri, - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 200) - response = self.client.get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 200) - response = self.client.post(logout_uri, data={"next": admin_uri}) + response = self.__client.post(logout_uri, data={"next": ADMIN_1_URI}) self.assertEqual(response.status_code, 302) - self.assertEqual(response.location, admin_uri) + self.assertEqual(response.headers["Location"], ADMIN_1_URI) - response = self.client.get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - response = self.client.get(admin_uri, - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 401) - response = self.client.get(admin_uri, - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 200) - response = self.client.get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 200) self.assertEqual(self.__user.visits, 2) diff --git a/tests/test_flask_login.py b/tests/test_flask_login.py index 4998cd7..3902543 100644 --- a/tests/test_flask_login.py +++ b/tests/test_flask_login.py @@ -19,21 +19,18 @@ """ import logging +import unittest from secrets import token_urlsafe from typing import Optional, Dict -from flask import Response, Flask, g, redirect, request -from flask_testing import TestCase -from werkzeug.datastructures import WWWAuthenticate, Authorization +import httpx +from flask import Flask, g, redirect, request +from werkzeug.datastructures import WWWAuthenticate -from flask_digest_auth import DigestAuth, make_password_hash, Client +from flask_digest_auth import DigestAuth, make_password_hash +from testlib import REALM, USERNAME, PASSWORD, ADMIN_1_URI, ADMIN_2_URI, \ + LOGOUT_URI, make_authorization -_REALM: str = "testrealm@host.com" -"""The realm.""" -_USERNAME: str = "Mufasa" -"""The username.""" -_PASSWORD: str = "Circle Of Life" -"""The password.""" SKIPPED_NO_FLASK_LOGIN: str = "Skipped without Flask-Login." """The message that a test is skipped when Flask-Login is not installed.""" @@ -49,7 +46,7 @@ class User: """ self.username: str = username """The username.""" - self.password_hash: str = make_password_hash(_REALM, username, password) + self.password_hash: str = make_password_hash(REALM, username, password) """The password hash.""" self.visits: int = 0 """The number of visits.""" @@ -77,22 +74,25 @@ class User: return self.is_active -class FlaskLoginTestCase(TestCase): +class FlaskLoginTestCase(unittest.TestCase): """The test case with the Flask-Login integration.""" - def create_app(self) -> Flask: - """Creates the Flask application. + def setUp(self) -> None: + """Sets up the test. + This is run once per test. - :return: The Flask application. + :return: None. """ logging.getLogger("test_flask_login").addHandler(logging.NullHandler()) - app: Flask = Flask(__name__) - app.config.from_mapping({ + self.app: Flask = Flask(__name__) + self.app.config.from_mapping({ "TESTING": True, "SECRET_KEY": token_urlsafe(32), - "DIGEST_AUTH_REALM": _REALM, + "DIGEST_AUTH_REALM": REALM, }) - app.test_client_class = Client + self.__client: httpx.Client = httpx.Client( + app=self.app, base_url="https://testserver") + """The testing client.""" self.__has_flask_login: bool = True """Whether the Flask-Login package is installed.""" @@ -100,17 +100,17 @@ class FlaskLoginTestCase(TestCase): import flask_login except ModuleNotFoundError: self.__has_flask_login = False - return app + return login_manager: flask_login.LoginManager = flask_login.LoginManager() - login_manager.init_app(app) + login_manager.init_app(self.app) auth: DigestAuth = DigestAuth() - auth.init_app(app) + auth.init_app(self.app) - self.__user: User = User(_USERNAME, _PASSWORD) + self.__user: User = User(USERNAME, PASSWORD) """The user account.""" - user_db: Dict[str, User] = {_USERNAME: self.__user} + user_db: Dict[str, User] = {USERNAME: self.__user} @auth.register_get_password def get_password_hash(username: str) -> Optional[str]: @@ -140,7 +140,7 @@ class FlaskLoginTestCase(TestCase): """ return user_db[user_id] if user_id in user_db else None - @app.get("/admin-1/auth", endpoint="admin-1") + @self.app.get(ADMIN_1_URI) @flask_login.login_required def admin_1() -> str: """The first administration section. @@ -149,7 +149,7 @@ class FlaskLoginTestCase(TestCase): """ return f"Hello, {flask_login.current_user.get_id()}! #1" - @app.get("/admin-2/auth", endpoint="admin-2") + @self.app.get(ADMIN_2_URI) @flask_login.login_required def admin_2() -> str: """The second administration section. @@ -158,7 +158,7 @@ class FlaskLoginTestCase(TestCase): """ return f"Hello, {flask_login.current_user.get_id()}! #2" - @app.post("/logout", endpoint="logout") + @self.app.post(LOGOUT_URI) @flask_login.login_required def logout() -> redirect: """Logs out the user. @@ -168,8 +168,6 @@ class FlaskLoginTestCase(TestCase): auth.logout() return redirect(request.form.get("next")) - return app - def test_auth(self) -> None: """Tests the authentication. @@ -178,17 +176,17 @@ class FlaskLoginTestCase(TestCase): if not self.__has_flask_login: self.skipTest(SKIPPED_NO_FLASK_LOGIN) - response: Response = self.client.get(self.app.url_for("admin-1")) + response: httpx.Response + + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - response = self.client.get( - self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.decode("UTF-8"), - f"Hello, {_USERNAME}! #1") - response: Response = self.client.get(self.app.url_for("admin-2")) + self.assertEqual(response.text, f"Hello, {USERNAME}! #1") + response = self.__client.get(ADMIN_2_URI) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.decode("UTF-8"), - f"Hello, {_USERNAME}! #2") + self.assertEqual(response.text, f"Hello, {USERNAME}! #2") self.assertEqual(self.__user.visits, 1) def test_stale_opaque(self) -> None: @@ -199,44 +197,52 @@ class FlaskLoginTestCase(TestCase): if not self.__has_flask_login: self.skipTest(SKIPPED_NO_FLASK_LOGIN) - admin_uri: str = self.app.url_for("admin-1") - response: Response + response: httpx.Response www_authenticate: WWWAuthenticate - auth_data: Authorization + auth_header: str - response = super(Client, self.client).get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - www_authenticate = response.www_authenticate + www_authenticate = WWWAuthenticate.from_header( + response.headers["WWW-Authenticate"]) self.assertEqual(www_authenticate.type, "digest") self.assertIsNone(www_authenticate.get("stale")) opaque: str = www_authenticate.opaque - if hasattr(g, "_login_user"): - delattr(g, "_login_user") + with self.app.app_context(): + if hasattr(g, "_login_user"): + delattr(g, "_login_user") www_authenticate.nonce = "bad" - auth_data = Client.make_authorization( - www_authenticate, admin_uri, _USERNAME, _PASSWORD) - response = super(Client, self.client).get(admin_uri, auth=auth_data) + auth_header = make_authorization( + www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD) + response = self.__client.get(ADMIN_1_URI, + headers={"Authorization": auth_header}) self.assertEqual(response.status_code, 401) - www_authenticate = response.www_authenticate + www_authenticate = WWWAuthenticate.from_header( + response.headers["WWW-Authenticate"]) self.assertEqual(www_authenticate.get("stale"), "TRUE") self.assertEqual(www_authenticate.opaque, opaque) - if hasattr(g, "_login_user"): - delattr(g, "_login_user") - auth_data = Client.make_authorization( - www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2") - response = super(Client, self.client).get(admin_uri, auth=auth_data) + with self.app.app_context(): + if hasattr(g, "_login_user"): + delattr(g, "_login_user") + auth_header = make_authorization( + www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD + "2") + response = self.__client.get(ADMIN_1_URI, + headers={"Authorization": auth_header}) self.assertEqual(response.status_code, 401) - www_authenticate = response.www_authenticate + www_authenticate = WWWAuthenticate.from_header( + response.headers["WWW-Authenticate"]) self.assertEqual(www_authenticate.get("stale"), "FALSE") self.assertEqual(www_authenticate.opaque, opaque) - if hasattr(g, "_login_user"): - delattr(g, "_login_user") - auth_data = Client.make_authorization( - www_authenticate, admin_uri, _USERNAME, _PASSWORD) - response = super(Client, self.client).get(admin_uri, auth=auth_data) + with self.app.app_context(): + if hasattr(g, "_login_user"): + delattr(g, "_login_user") + auth_header = make_authorization( + www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD) + response = self.__client.get(ADMIN_1_URI, + headers={"Authorization": auth_header}) self.assertEqual(response.status_code, 200) def test_logout(self) -> None: @@ -247,36 +253,34 @@ class FlaskLoginTestCase(TestCase): if not self.__has_flask_login: self.skipTest(SKIPPED_NO_FLASK_LOGIN) - admin_uri: str = self.app.url_for("admin-1") - logout_uri: str = self.app.url_for("logout") - response: Response + response: httpx.Response - response = self.client.get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - response = self.client.get(admin_uri, - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 200) - response = self.client.get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 200) - response = self.client.post(logout_uri, data={"next": admin_uri}) + response = self.__client.post(LOGOUT_URI, data={"next": ADMIN_1_URI}) self.assertEqual(response.status_code, 302) - self.assertEqual(response.location, admin_uri) + self.assertEqual(response.headers["Location"], ADMIN_1_URI) - response = self.client.get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - response = self.client.get(admin_uri, - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 401) - response = self.client.get(admin_uri, - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 200) - response = self.client.get(admin_uri) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 200) self.assertEqual(self.__user.visits, 2) @@ -288,25 +292,25 @@ class FlaskLoginTestCase(TestCase): if not self.__has_flask_login: self.skipTest(SKIPPED_NO_FLASK_LOGIN) - response: Response + response: httpx.Response self.__user.is_active = False - response = self.client.get(self.app.url_for("admin-1")) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - response = self.client.get(self.app.url_for("admin-1"), - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 401) self.__user.is_active = True - response = self.client.get(self.app.url_for("admin-1"), - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 200) - response = self.client.get(self.app.url_for("admin-1")) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 200) self.__user.is_active = False - response = self.client.get(self.app.url_for("admin-1")) + response = self.__client.get(ADMIN_1_URI) self.assertEqual(response.status_code, 401) - response = self.client.get(self.app.url_for("admin-1"), - digest_auth=(_USERNAME, _PASSWORD)) + response = self.__client.get(ADMIN_1_URI, + auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.assertEqual(response.status_code, 401) diff --git a/tests/testlib.py b/tests/testlib.py new file mode 100644 index 0000000..d22b55f --- /dev/null +++ b/tests/testlib.py @@ -0,0 +1,87 @@ +# The Flask HTTP Digest Authentication Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/10/5 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The common test libraries. + +""" +from secrets import token_urlsafe +from typing import Optional, Literal, Dict + +from werkzeug.datastructures import Authorization, WWWAuthenticate +from werkzeug.http import parse_set_header + +from flask_digest_auth import calc_response, make_password_hash + +REALM: str = "testrealm@host.com" +"""The realm.""" +USERNAME: str = "Mufasa" +"""The username.""" +PASSWORD: str = "Circle Of Life" +"""The password.""" +ADMIN_1_URI: str = "/admin-1/auth" +"""The first administration URI.""" +ADMIN_2_URI: str = "/admin-2/auth" +"""The first administration URI.""" +LOGOUT_URI: str = "/logout" +"""The log out URI.""" + + +def make_authorization(www_authenticate: WWWAuthenticate, uri: str, + username: str, password: str) -> str: + """Composes and returns the request authorization. + + :param www_authenticate: The ``WWW-Authenticate`` response. + :param uri: The request URI. + :param username: The username. + :param password: The password. + :return: The request authorization header. + """ + qop: Optional[Literal["auth", "auth-int"]] = None + if "auth" in parse_set_header(www_authenticate.get("qop")): + qop = "auth" + + cnonce: Optional[str] = None + if qop is not None or www_authenticate.algorithm == "MD5-sess": + cnonce = token_urlsafe(8) + nc: Optional[str] = None + count: int = 1 + if qop is not None: + nc: str = hex(count)[2:].zfill(8) + + expected: str = calc_response( + method="GET", uri=uri, + password_hash=make_password_hash(www_authenticate.realm, + username, password), + nonce=www_authenticate.nonce, qop=qop, + algorithm=www_authenticate.algorithm, cnonce=cnonce, nc=nc, + body=None) + + data: Dict[str, str] = { + "username": username, "realm": www_authenticate.realm, + "nonce": www_authenticate.nonce, "uri": uri, "response": expected} + if www_authenticate.algorithm is not None: + data["algorithm"] = www_authenticate.algorithm + if cnonce is not None: + data["cnonce"] = cnonce + if www_authenticate.opaque is not None: + data["opaque"] = www_authenticate.opaque + if qop is not None: + data["qop"] = qop + if nc is not None: + data["nc"] = nc + + return str(Authorization("digest", data=data))