Compare commits

...

9 Commits
v0.6.2 ... main

12 changed files with 267 additions and 401 deletions

View File

@ -38,3 +38,4 @@ python:
install: install:
- method: pip - method: pip
path: . path: .
- requirements: docs/requirements.txt

1
docs/requirements.txt Normal file
View File

@ -0,0 +1 @@
sphinx_rtd_theme

View File

@ -2,6 +2,17 @@ Change Log
========== ==========
Version 0.7.0
-------------
Released 2023/10/8
* Removed the test client. You should use httpx instead of Flask-Testing
when writing automatic tests. Flask-Testing is not maintained for more
than 3 years, and is not compatible with Flask 3 now.
* Revised to skip the tests when Flask-Login is not compatible with Werkzeug.
Version 0.6.2 Version 0.6.2
------------- -------------

View File

@ -199,70 +199,3 @@ In your ``my_app/views.py``:
The views only depend on Flask-Login, but not the actual The views only depend on Flask-Login, but not the actual
authentication mechanism. You can change the actual authentication authentication mechanism. You can change the actual authentication
mechanism without changing the views. mechanism without changing the views.
.. _example-unittest:
A unittest Test Case
--------------------
::
from flask import Flask
from flask_digest_auth import Client
from flask_testing import TestCase
from my_app import create_app
class MyTestCase(TestCase):
def create_app(self):
app: Flask = create_app({
"TESTING": True,
"SECRET_KEY": token_urlsafe(32),
"DIGEST_AUTH_REALM": "admin",
})
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)
.. _example-pytest:
A pytest Test
-------------
::
import pytest
from flask import Flask
from flask_digest_auth import Client
from my_app import create_app
@pytest.fixture()
def app():
app: Flask = create_app({
"TESTING": True,
"SECRET_KEY": token_urlsafe(32),
"DIGEST_AUTH_REALM": "admin",
})
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

View File

@ -20,14 +20,6 @@ flask\_digest\_auth.auth module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
flask\_digest\_auth.test module
-------------------------------
.. automodule:: flask_digest_auth.test
:members:
:undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@ -137,17 +137,6 @@ new username and password.
See :meth:`flask_digest_auth.auth.DigestAuth.logout`. See :meth:`flask_digest_auth.auth.DigestAuth.logout`.
Test Client
-----------
Flask-DigestAuth comes with a test client that supports HTTP digest
authentication.
See :class:`flask_digest_auth.test.Client`.
Also see :ref:`example-unittest` and :ref:`example-pytest`.
.. _HTTP Digest Authentication: https://en.wikipedia.org/wiki/Digest_access_authentication .. _HTTP Digest Authentication: https://en.wikipedia.org/wiki/Digest_access_authentication
.. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617 .. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617
.. _Flask: https://flask.palletsprojects.com .. _Flask: https://flask.palletsprojects.com

View File

@ -1,7 +1,7 @@
# The Flask HTTP Digest Authentication Project. # The Flask HTTP Digest Authentication Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/23 # Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/23
# Copyright (c) 2022-2023 imacat. # Copyright (c) 2022-2024 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -34,13 +34,12 @@ classifiers = [
"Intended Audience :: Developers", "Intended Audience :: Developers",
] ]
dependencies = [ dependencies = [
"flask", "Flask",
] ]
[project.optional-dependencies] [project.optional-dependencies]
test = [ devel = [
"unittest", "httpx",
"flask-testing",
] ]
[project.urls] [project.urls]

View File

@ -1,7 +1,7 @@
# The Flask HTTP Digest Authentication Project. # The Flask HTTP Digest Authentication Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/6 # 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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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.algo import make_password_hash, calc_response
from flask_digest_auth.auth import DigestAuth from flask_digest_auth.auth import DigestAuth
from flask_digest_auth.test import Client
VERSION: str = "0.6.2" VERSION: str = "0.7.0"
"""The package version.""" """The package version."""

View File

@ -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)

View File

@ -19,21 +19,17 @@
""" """
import logging import logging
import unittest
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Any, Optional, Dict from typing import Any, Optional, Dict
from flask import Response, Flask, g, redirect, request import httpx
from flask_testing import TestCase from flask import Flask, g, redirect, request
from werkzeug.datastructures import WWWAuthenticate, Authorization 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, \
_REALM: str = "testrealm@host.com" LOGOUT_URI, make_authorization
"""The realm."""
_USERNAME: str = "Mufasa"
"""The username."""
_PASSWORD: str = "Circle Of Life"
"""The password."""
class User: class User:
@ -47,34 +43,37 @@ class User:
""" """
self.username: str = username self.username: str = username
"""The 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.""" """The password hash."""
self.visits: int = 0 self.visits: int = 0
"""The number of visits.""" """The number of visits."""
class AuthenticationTestCase(TestCase): class AuthenticationTestCase(unittest.TestCase):
"""The test case for the HTTP digest authentication.""" """The test case for the HTTP digest authentication."""
def create_app(self): def setUp(self) -> None:
"""Creates the Flask application. """Sets up the test.
This is run once per test.
:return: The Flask application. :return: None.
""" """
logging.getLogger("test_auth").addHandler(logging.NullHandler()) logging.getLogger("test_auth").addHandler(logging.NullHandler())
app: Flask = Flask(__name__) app: Flask = Flask(__name__)
app.config.from_mapping({ app.config.from_mapping({
"TESTING": True, "TESTING": True,
"SECRET_KEY": token_urlsafe(32), "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: DigestAuth = DigestAuth()
auth.init_app(app) auth.init_app(app)
self.__user: User = User(_USERNAME, _PASSWORD) self.__user: User = User(USERNAME, PASSWORD)
"""The user account.""" """The user account."""
user_db: Dict[str, User] = {_USERNAME: self.__user} user_db: Dict[str, User] = {USERNAME: self.__user}
@auth.register_get_password @auth.register_get_password
def get_password_hash(username: str) -> Optional[str]: def get_password_hash(username: str) -> Optional[str]:
@ -104,7 +103,7 @@ class AuthenticationTestCase(TestCase):
""" """
user.visits = user.visits + 1 user.visits = user.visits + 1
@app.get("/admin-1/auth", endpoint="admin-1") @app.get(ADMIN_1_URI, endpoint="admin-1")
@auth.login_required @auth.login_required
def admin_1() -> str: def admin_1() -> str:
"""The first administration section. """The first administration section.
@ -113,7 +112,7 @@ class AuthenticationTestCase(TestCase):
""" """
return f"Hello, {g.user.username}! #1" 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 @auth.login_required
def admin_2() -> str: def admin_2() -> str:
"""The second administration section. """The second administration section.
@ -122,7 +121,7 @@ class AuthenticationTestCase(TestCase):
""" """
return f"Hello, {g.user.username}! #2" return f"Hello, {g.user.username}! #2"
@app.post("/logout", endpoint="logout") @app.post(LOGOUT_URI, endpoint="logout")
@auth.login_required @auth.login_required
def logout() -> redirect: def logout() -> redirect:
"""Logs out the user. """Logs out the user.
@ -132,24 +131,21 @@ class AuthenticationTestCase(TestCase):
auth.logout() auth.logout()
return redirect(request.form.get("next")) return redirect(request.form.get("next"))
return app
def test_auth(self) -> None: def test_auth(self) -> None:
"""Tests the authentication. """Tests the authentication.
:return: None. :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) self.assertEqual(response.status_code, 401)
response = self.client.get( response = self.__client.get(ADMIN_1_URI,
self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"), self.assertEqual(response.text, f"Hello, {USERNAME}! #1")
f"Hello, {_USERNAME}! #1") response = self.__client.get(ADMIN_2_URI)
response: Response = self.client.get(self.app.url_for("admin-2"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"), self.assertEqual(response.text, f"Hello, {USERNAME}! #2")
f"Hello, {_USERNAME}! #2")
self.assertEqual(self.__user.visits, 1) self.assertEqual(self.__user.visits, 1)
def test_stale_opaque(self) -> None: def test_stale_opaque(self) -> None:
@ -157,38 +153,43 @@ class AuthenticationTestCase(TestCase):
:return: None. :return: None.
""" """
admin_uri: str = self.app.url_for("admin-1") response: httpx.Response
response: Response
www_authenticate: WWWAuthenticate 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) 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.assertEqual(www_authenticate.type, "digest")
self.assertIsNone(www_authenticate.get("stale")) self.assertIsNone(www_authenticate.get("stale"))
opaque: str = www_authenticate.opaque opaque: str = www_authenticate.opaque
www_authenticate.nonce = "bad" www_authenticate.nonce = "bad"
auth_data = Client.make_authorization( auth_header = make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD) www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
response = super(Client, self.client).get(admin_uri, auth=auth_data) response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 401) 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.get("stale"), "TRUE")
self.assertEqual(www_authenticate.opaque, opaque) self.assertEqual(www_authenticate.opaque, opaque)
auth_data = Client.make_authorization( auth_header = make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2") www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD + "2")
response = super(Client, self.client).get(admin_uri, auth=auth_data) response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 401) 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.get("stale"), "FALSE")
self.assertEqual(www_authenticate.opaque, opaque) self.assertEqual(www_authenticate.opaque, opaque)
auth_data = Client.make_authorization( auth_header = make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD) www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
response = super(Client, self.client).get(admin_uri, auth=auth_data) response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_logout(self) -> None: def test_logout(self) -> None:
@ -196,35 +197,34 @@ class AuthenticationTestCase(TestCase):
:return: None. :return: None.
""" """
admin_uri: str = self.app.url_for("admin-1") logout_uri: str = LOGOUT_URI
logout_uri: str = self.app.url_for("logout") response: httpx.Response
response: Response
response = self.client.get(admin_uri) response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri, response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200) 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(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.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) self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri, response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri, response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200) 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(response.status_code, 200)
self.assertEqual(self.__user.visits, 2) self.assertEqual(self.__user.visits, 2)

View File

@ -19,21 +19,20 @@
""" """
import logging import logging
import unittest
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Optional, Dict from typing import Optional, Dict
from flask import Response, Flask, g, redirect, request import httpx
from flask_testing import TestCase from flask import Flask, g, redirect, request
from werkzeug.datastructures import WWWAuthenticate, Authorization 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" SKIPPED_NO_FLASK_LOGIN: str = "Skipped without Flask-Login."
"""The realm.""" """The message that a test is skipped when Flask-Login is not installed."""
_USERNAME: str = "Mufasa"
"""The username."""
_PASSWORD: str = "Circle Of Life"
"""The password."""
class User: class User:
@ -47,7 +46,7 @@ class User:
""" """
self.username: str = username self.username: str = username
"""The 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.""" """The password hash."""
self.visits: int = 0 self.visits: int = 0
"""The number of visits.""" """The number of visits."""
@ -75,22 +74,25 @@ class User:
return self.is_active return self.is_active
class FlaskLoginTestCase(TestCase): class FlaskLoginTestCase(unittest.TestCase):
"""The test case with the Flask-Login integration.""" """The test case with the Flask-Login integration."""
def create_app(self) -> Flask: def setUp(self) -> None:
"""Creates the Flask application. """Sets up the test.
This is run once per test.
:return: The Flask application. :return: None.
""" """
logging.getLogger("test_flask_login").addHandler(logging.NullHandler()) logging.getLogger("test_flask_login").addHandler(logging.NullHandler())
app: Flask = Flask(__name__) self.app: Flask = Flask(__name__)
app.config.from_mapping({ self.app.config.from_mapping({
"TESTING": True, "TESTING": True,
"SECRET_KEY": token_urlsafe(32), "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 self.__has_flask_login: bool = True
"""Whether the Flask-Login package is installed.""" """Whether the Flask-Login package is installed."""
@ -98,17 +100,20 @@ class FlaskLoginTestCase(TestCase):
import flask_login import flask_login
except ModuleNotFoundError: except ModuleNotFoundError:
self.__has_flask_login = False self.__has_flask_login = False
return app return
except ImportError:
self.__has_flask_login = False
return
login_manager: flask_login.LoginManager = flask_login.LoginManager() login_manager: flask_login.LoginManager = flask_login.LoginManager()
login_manager.init_app(app) login_manager.init_app(self.app)
auth: DigestAuth = DigestAuth() 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.""" """The user account."""
user_db: Dict[str, User] = {_USERNAME: self.__user} user_db: Dict[str, User] = {USERNAME: self.__user}
@auth.register_get_password @auth.register_get_password
def get_password_hash(username: str) -> Optional[str]: def get_password_hash(username: str) -> Optional[str]:
@ -138,7 +143,7 @@ class FlaskLoginTestCase(TestCase):
""" """
return user_db[user_id] if user_id in user_db else None 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 @flask_login.login_required
def admin_1() -> str: def admin_1() -> str:
"""The first administration section. """The first administration section.
@ -147,7 +152,7 @@ class FlaskLoginTestCase(TestCase):
""" """
return f"Hello, {flask_login.current_user.get_id()}! #1" 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 @flask_login.login_required
def admin_2() -> str: def admin_2() -> str:
"""The second administration section. """The second administration section.
@ -156,7 +161,7 @@ class FlaskLoginTestCase(TestCase):
""" """
return f"Hello, {flask_login.current_user.get_id()}! #2" return f"Hello, {flask_login.current_user.get_id()}! #2"
@app.post("/logout", endpoint="logout") @self.app.post(LOGOUT_URI)
@flask_login.login_required @flask_login.login_required
def logout() -> redirect: def logout() -> redirect:
"""Logs out the user. """Logs out the user.
@ -166,27 +171,25 @@ class FlaskLoginTestCase(TestCase):
auth.logout() auth.logout()
return redirect(request.form.get("next")) return redirect(request.form.get("next"))
return app
def test_auth(self) -> None: def test_auth(self) -> None:
"""Tests the authentication. """Tests the authentication.
:return: None. :return: None.
""" """
if not self.__has_flask_login: if not self.__has_flask_login:
self.skipTest("Skipped without 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) self.assertEqual(response.status_code, 401)
response = self.client.get( response = self.__client.get(ADMIN_1_URI,
self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"), self.assertEqual(response.text, f"Hello, {USERNAME}! #1")
f"Hello, {_USERNAME}! #1") response = self.__client.get(ADMIN_2_URI)
response: Response = self.client.get(self.app.url_for("admin-2"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"), self.assertEqual(response.text, f"Hello, {USERNAME}! #2")
f"Hello, {_USERNAME}! #2")
self.assertEqual(self.__user.visits, 1) self.assertEqual(self.__user.visits, 1)
def test_stale_opaque(self) -> None: def test_stale_opaque(self) -> None:
@ -195,46 +198,54 @@ class FlaskLoginTestCase(TestCase):
:return: None. :return: None.
""" """
if not self.__has_flask_login: if not self.__has_flask_login:
self.skipTest("Skipped without Flask-Login.") self.skipTest(SKIPPED_NO_FLASK_LOGIN)
admin_uri: str = self.app.url_for("admin-1") response: httpx.Response
response: Response
www_authenticate: WWWAuthenticate 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) 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.assertEqual(www_authenticate.type, "digest")
self.assertIsNone(www_authenticate.get("stale")) self.assertIsNone(www_authenticate.get("stale"))
opaque: str = www_authenticate.opaque opaque: str = www_authenticate.opaque
with self.app.app_context():
if hasattr(g, "_login_user"): if hasattr(g, "_login_user"):
delattr(g, "_login_user") delattr(g, "_login_user")
www_authenticate.nonce = "bad" www_authenticate.nonce = "bad"
auth_data = Client.make_authorization( auth_header = make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD) www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
response = super(Client, self.client).get(admin_uri, auth=auth_data) response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 401) 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.get("stale"), "TRUE")
self.assertEqual(www_authenticate.opaque, opaque) self.assertEqual(www_authenticate.opaque, opaque)
with self.app.app_context():
if hasattr(g, "_login_user"): if hasattr(g, "_login_user"):
delattr(g, "_login_user") delattr(g, "_login_user")
auth_data = Client.make_authorization( auth_header = make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2") www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD + "2")
response = super(Client, self.client).get(admin_uri, auth=auth_data) response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 401) 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.get("stale"), "FALSE")
self.assertEqual(www_authenticate.opaque, opaque) self.assertEqual(www_authenticate.opaque, opaque)
with self.app.app_context():
if hasattr(g, "_login_user"): if hasattr(g, "_login_user"):
delattr(g, "_login_user") delattr(g, "_login_user")
auth_data = Client.make_authorization( auth_header = make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD) www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
response = super(Client, self.client).get(admin_uri, auth=auth_data) response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_logout(self) -> None: def test_logout(self) -> None:
@ -243,38 +254,36 @@ class FlaskLoginTestCase(TestCase):
:return: None. :return: None.
""" """
if not self.__has_flask_login: if not self.__has_flask_login:
self.skipTest("Skipped without Flask-Login.") self.skipTest(SKIPPED_NO_FLASK_LOGIN)
admin_uri: str = self.app.url_for("admin-1") response: httpx.Response
logout_uri: str = self.app.url_for("logout")
response: Response
response = self.client.get(admin_uri) response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri, response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200) 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(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.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) self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri, response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri, response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200) 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(response.status_code, 200)
self.assertEqual(self.__user.visits, 2) self.assertEqual(self.__user.visits, 2)
@ -284,27 +293,27 @@ class FlaskLoginTestCase(TestCase):
:return: None. :return: None.
""" """
if not self.__has_flask_login: if not self.__has_flask_login:
self.skipTest("Skipped without Flask-Login.") self.skipTest(SKIPPED_NO_FLASK_LOGIN)
response: Response response: httpx.Response
self.__user.is_active = False 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) self.assertEqual(response.status_code, 401)
response = self.client.get(self.app.url_for("admin-1"), response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.__user.is_active = True self.__user.is_active = True
response = self.client.get(self.app.url_for("admin-1"), response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200) 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.assertEqual(response.status_code, 200)
self.__user.is_active = False 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) self.assertEqual(response.status_code, 401)
response = self.client.get(self.app.url_for("admin-1"), response = self.__client.get(ADMIN_1_URI,
digest_auth=(_USERNAME, _PASSWORD)) auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)

87
tests/testlib.py Normal file
View File

@ -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))