Compare commits

...

32 Commits
v0.4.0 ... main

Author SHA1 Message Date
9da7cec1f5 Updated optional dependencies in pyproject.toml. 2024-06-02 09:25:12 +08:00
2d0a8dbcd8 Updated the dependencies in pyproject.toml. 2024-06-02 09:04:34 +08:00
a3bc807192 Removed the test client from thd Sphinx documentation. 2023-10-08 23:35:20 +08:00
848971709a Advanced to version 0.7.0. 2023-10-08 22:46:59 +08:00
409b04af47 Added docs/requirements.txt and the "sphinx_rtd_theme" theme to the readthedocs configuration, as Read the Docs does not install sphinx_rtd_theme by default after August 7, 2023. 2023-10-08 22:46:33 +08:00
a8c34eb367 Updated the Sphinx documentation. 2023-10-08 22:15:44 +08:00
cc96987a18 Revised the FlaskLoginTestCase test case to skip testing when Flask-Login 0.6.2 conflicts with Werkzeug 3. 2023-10-08 22:11:50 +08:00
1f657568bb 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. 2023-10-08 22:06:20 +08:00
320cfe1700 Added the SKIPPED_NO_FLASK_LOGIN constant to test_flask_login.py, to simplify the code. 2023-06-10 16:45:19 +08:00
a5188c9aa1 Advanced to version 0.6.2. 2023-06-10 16:26:39 +08:00
b62b98bd51 Changed the properties of the test cases from public to private. 2023-06-08 17:28:35 +08:00
877f02fe82 Added missing documentation to the global variables and class and object properties. 2023-06-08 17:20:24 +08:00
bc888195ad Disabled logging in the AuthenticationTestCase and FlaskLoginTestCase test cases, for clearer test output. 2023-05-03 08:08:51 +08:00
8e69733cf6 Updated the login_required view decorator of the DigestAuth class, replaced writing to STDERR directly with warning through the Flask logger. 2023-05-03 08:05:28 +08:00
f04ea7ac18 Advanced to version 0.6.1. 2023-05-03 06:59:27 +08:00
15ea650ddd Revised the code that handles the "qop" and "stale" parameters of the "WWW-Authenticate" response HTTP header for the upcoming Werkzeug 2.4. 2023-05-03 06:58:14 +08:00
5b255b6504 Split the Flask-Login login manager initialization from the init_app method to the __init_login_manager method in the DigestAuth class, to simplify the code. 2023-04-29 11:17:11 +08:00
919b8d0dc3 Removed the unnecessary f-string in the __make_response_header method of the DigestAuth class. 2023-04-29 10:44:15 +08:00
604ed0be27 Updated the Python version in the Read the Docs configuration. 2023-04-27 09:09:00 +08:00
9e0a06bd4c Advanced to version 0.6.0. 2023-04-27 09:08:21 +08:00
e861cae2e0 Replaced importing the "typing" module as "t" with importing the individual names in the "typing" module. We do not have as many names to import. This is also to be consistent with the practices of most major and standard packages and examples. 2023-04-27 09:08:10 +08:00
264ba158ee Updated minimal Python version to 3.8. As "typing.Literal" is used from the beginning of the project, it is never compatible with Python 3.7. I suppose it was ignored by Python 3.7 when importing the "typing" package but not the name "Literal" itself for type hints. 2023-04-26 23:30:27 +08:00
d1fd0c3693 Simplified README.rst. 2023-04-23 22:43:56 +08:00
bc15a578cb Added the change log. 2023-04-23 22:36:31 +08:00
cedff68247 Added the "VERSION" constant to the "flask_digest_auth" module for the package version, and revised "pyproject.toml" and "conf.py" to read the version from it. 2023-04-23 22:15:11 +08:00
769ca7dddd Replaced the requirements.txt in the docs directory with the Read the Docs configuration file. 2023-04-05 23:52:43 +08:00
33eb81f368 Replaced setup.cfg with pyproject.toml for the package settings, and rewrote the packaging rules in MANIFEST.in. 2023-04-05 23:09:44 +08:00
5faf51c49b Removed the realm from the example in the documentation of the init_app method of the DigestAuth class. 2023-01-07 15:03:37 +08:00
d5a8bb3acd Advanced to version 0.5.0. 2023-01-06 00:21:19 +08:00
27d27127f6 Added the DIGEST_AUTH_REALM configuration variable as the recommended way to set the authentication realm. Changed the default realm from an empty string to "Login Required". 2023-01-06 00:20:40 +08:00
5ebdea6d0a Reordered the code in the create_app methods of the AuthenticationTestCase and FlaskLoginTestCase test cases. 2023-01-05 22:50:59 +08:00
ea31bb9579 Revised the coding style in the init_app method of the DigestAuth class. 2023-01-05 22:42:59 +08:00
20 changed files with 641 additions and 895 deletions

41
.readthedocs.yaml Normal file
View File

@ -0,0 +1,41 @@
# The Flask HTTP Digest Authentication Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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.
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.8"
# Build documentation in the docs/ directory with Sphinx
# If using Sphinx, optionally build your docs in additional formats such as PDF
formats: all
# Optionally declare the Python requirements required to build your docs
python:
install:
- method: pip
path: .
- requirements: docs/requirements.txt

View File

@ -15,8 +15,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
include docs/* recursive-include docs *
include docs/source/* recursive-exclude docs/build *
include docs/source/_static/* recursive-include tests *
include docs/source/_templates/* recursive-exclude tests *.pyc
include tests/*

View File

@ -12,8 +12,6 @@ views.
HTTP Digest Authentication is specified in `RFC 2617`_. HTTP Digest Authentication is specified in `RFC 2617`_.
Refer to the full `Flask-DigestAuth readthedocs documentation`_.
Why HTTP Digest Authentication? Why HTTP Digest Authentication?
------------------------------- -------------------------------
@ -52,338 +50,16 @@ You may also install the latest source from the
pip install git+https://github.com/imacat/flask-digestauth.git pip install git+https://github.com/imacat/flask-digestauth.git
Setting the Password Documentation
==================== =============
The password hash of the HTTP Digest Authentication is composed of the Refer to the `documentation on Read the Docs`_.
realm, the username, and the password. Example for setting the
password:
::
from flask_digest_auth import make_password_hash Change Log
==========
user.password = make_password_hash(realm, username, password) Refer to the `change log`_.
The username is part of the hash. If the user changes their username,
you need to ask their password, to generate and store the new password
hash.
Flask-DigestAuth Alone
======================
Flask-DigestAuth can authenticate the users alone.
Simple Applications with Flask-DigestAuth Alone
-----------------------------------------------
In your ``my_app.py``:
::
from flask import Flask, request, redirect
from flask_digest_auth import DigestAuth
app: flask = Flask(__name__)
... (Configure the Flask application) ...
auth: DigestAuth = DigestAuth(realm="Admin")
auth.init_app(app)
@auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]:
... (Load the password hash) ...
@auth.register_get_user
def get_user(username: str) -> t.Optional[t.Any]:
... (Load the user) ...
@app.get("/admin")
@auth.login_required
def admin():
return f"Hello, {g.user.username}!"
@app.post("/logout")
@auth.login_required
def logout():
auth.logout()
return redirect(request.form.get("next"))
Larger Applications with ``create_app()`` with Flask-DigestAuth Alone
---------------------------------------------------------------------
In your ``my_app/__init__.py``:
::
from flask import Flask
from flask_digest_auth import DigestAuth
auth: DigestAuth = DigestAuth()
def create_app(test_config = None) -> Flask:
app: flask = Flask(__name__)
... (Configure the Flask application) ...
auth.realm = app.config["REALM"]
auth.init_app(app)
@auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]:
... (Load the password hash) ...
@auth.register_get_user
def get_user(username: str) -> t.Optional[t.Any]:
... (Load the user) ...
return app
In your ``my_app/views.py``:
::
from my_app import auth
from flask import Flask, Blueprint, request, redirect
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/admin")
@auth.login_required
def admin():
return f"Hello, {g.user.username}!"
@app.post("/logout")
@auth.login_required
def logout():
auth.logout()
return redirect(request.form.get("next"))
def init_app(app: Flask) -> None:
app.register_blueprint(bp)
Flask-Login Integration
=======================
Flask-DigestAuth works with Flask-Login_. You can write a Flask
module that requires log in, without specifying how to log in. The
application can use either HTTP Digest Authentication, or the log in
forms, as needed.
To use Flask-Login with Flask-DigestAuth,
``login_manager.init_app(app)`` must be called before
``auth.init_app(app)``.
The currently logged-in user can be retrieved at
``flask_login.current_user``, if any.
The views only depend on Flask-Login, but not the Flask-DigestAuth.
You can change the actual authentication mechanism without changing
the views.
Simple Applications with Flask-Login Integration
------------------------------------------------
In your ``my_app.py``:
::
import flask_login
from flask import Flask, request, redirect
from flask_digest_auth import DigestAuth
app: flask = Flask(__name__)
... (Configure the Flask application) ...
login_manager: flask_login.LoginManager = flask_login.LoginManager()
login_manager.init_app(app)
@login_manager.user_loader
def load_user(user_id: str) -> t.Optional[User]:
... (Load the user with the username) ...
auth: DigestAuth = DigestAuth(realm="Admin")
auth.init_app(app)
@auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]:
... (Load the password hash) ...
@app.get("/admin")
@flask_login.login_required
def admin():
return f"Hello, {flask_login.current_user.get_id()}!"
@app.post("/logout")
@flask_login.login_required
def logout():
auth.logout()
# Do not call flask_login.logout_user()
return redirect(request.form.get("next"))
Larger Applications with ``create_app()`` with Flask-Login Integration
----------------------------------------------------------------------
In your ``my_app/__init__.py``:
::
from flask import Flask
from flask_digest_auth import DigestAuth
from flask_login import LoginManager
auth: DigestAuth = DigestAuth()
def create_app(test_config = None) -> Flask:
app: flask = Flask(__name__)
... (Configure the Flask application) ...
login_manager: LoginManager = LoginManager()
login_manager.init_app(app)
@login_manager.user_loader
def load_user(user_id: str) -> t.Optional[User]:
... (Load the user with the username) ...
auth.realm = app.config["REALM"]
auth.init_app(app)
@auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]:
... (Load the password hash) ...
return app
In your ``my_app/views.py``:
::
import flask_login
from flask import Flask, Blueprint, request, redirect
from my_app import auth
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/admin")
@flask_login.login_required
def admin():
return f"Hello, {flask_login.current_user.get_id()}!"
@app.post("/logout")
@flask_login.login_required
def logout():
auth.logout()
# Do not call flask_login.logout_user()
return redirect(request.form.get("next"))
def init_app(app: Flask) -> None:
app.register_blueprint(bp)
The views only depend on Flask-Login, but not the actual
authentication mechanism. You can change the actual authentication
mechanism without changing the views.
Session Integration
===================
Flask-DigestAuth features session integration. The user log in
is remembered in the session. The authentication information is not
requested again. This is different to the practice of the HTTP Digest
Authentication, but is convenient for the log in accounting.
Log In Bookkeeping
==================
You can register a callback to run when the user logs in, for ex.,
logging the log in event, adding the log in counter, etc.
::
@auth.register_on_login
def on_login(user: User) -> None:
user.visits = user.visits + 1
Log Out
=======
Flask-DigestAuth supports log out. The user will be prompted for the
new username and password.
Test Client
===========
Flask-DigestAuth comes with a test client that supports HTTP digest
authentication.
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({
"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)
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({
"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
Copyright Copyright
@ -415,5 +91,6 @@ Authors
.. _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
.. _Flask-DigestAuth GitHub repository: https://github.com/imacat/flask-digestauth .. _Flask-DigestAuth GitHub repository: https://github.com/imacat/flask-digestauth
.. _Flask-DigestAuth readthedocs documentation: https://flask-digestauth.readthedocs.io
.. _Flask-Login: https://flask-login.readthedocs.io .. _Flask-Login: https://flask-login.readthedocs.io
.. _documentation on Read the Docs: https://flask-digestauth.readthedocs.io
.. _change log: https://flask-digestauth.readthedocs.io/en/latest/changelog.html

View File

@ -1 +1 @@
flask sphinx_rtd_theme

153
docs/source/changelog.rst Normal file
View File

@ -0,0 +1,153 @@
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
-------------
Released 2023/6/10
* Changed logging from STDERR to the Flask logger.
* Test case updates:
* Added missing documentation.
* Changed properties from public to private.
* Disabled logging.
Version 0.6.1
-------------
Released 2023/5/3
* Revised the code for the upcoming Werkzeug 2.4.
Version 0.6.0
-------------
Released 2023/4/26
* Updated the minimal Python version to 3.8.
* Switched from ``setup.cfg`` to ``pyproject.toml``.
* Added the change log.
* Simplified ``README.rst``.
Version 0.5.0
-------------
Released 2023/1/6
* Added the ``DIGEST_AUTH_REALM`` configuration variable as the
recommended way to set the authentication realm.
* Changed the default realm from an empty string to
``Login Required``.
Version 0.4.0
-------------
Released 2023/1/4
* Changed the package name from ``flask-digest-auth`` to
``Flask-DigestAuth``, according to the Flask recommended extension
guidelines
https://flask.palletsprojects.com/en/latest/extensiondev/ .
* Replaced ``app.digest_auth`` with ``app.extensions["digest-auth"]``
to store the ``DigestAuth`` instance.
* Replaced ``auth.app`` with ``current_app``, to prevent circular
imports.
Version 0.3.1
-------------
Released 2022/12/29
Fixed the missing authentication state with disabled users.
Version 0.3.0
-------------
Released 2022/12/7
Changed the visibility of several methods and properties of the
DigestAuth class that should be private to private.
Version 0.2.4
-------------
Released 2022/12/6
Fixed the pytest example in the documentation.
Version 0.2.3
-------------
Released 2022/12/6
Fixed the dependencies for the documentation hosted on Read the Docs.
Version 0.2.2
-------------
Released 2022/12/6
Added the Sphinx documentation, and hosted the documentation on
Read the Docs.
Version 0.2.1
-------------
Released 2022/12/6
Various fixes, with the help from SonarQube.
Version 0.2.0
-------------
Released 2022/11/27
* Added log out support. User can log out.
* Added on-login event handler. You can do some accounting when the
user logs in.
This release is written in Sydney and on the international flight,
and released in Taipei.
Version 0.1.1
-------------
Released 2022/11/24
Changed the minimal Python version to 3.7.
Released at Sydney, Australia on vacation.
Version 0.1.0
-------------
Released 2022/11/24
The initial release.
Released at Sydney, Australia on vacation.

View File

@ -6,6 +6,7 @@ import os
import sys import sys
sys.path.insert(0, os.path.abspath('../../src/')) sys.path.insert(0, os.path.abspath('../../src/'))
import flask_digest_auth
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
@ -13,7 +14,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Flask-DigestAuth' project = 'Flask-DigestAuth'
copyright = '2022-2023, imacat' copyright = '2022-2023, imacat'
author = 'imacat' author = 'imacat'
release = '0.4.0' release = flask_digest_auth.VERSION
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -17,7 +17,7 @@ In your ``my_app.py``:
app: flask = Flask(__name__) app: flask = Flask(__name__)
... (Configure the Flask application) ... ... (Configure the Flask application) ...
auth: DigestAuth = DigestAuth(realm="Admin") auth: DigestAuth = DigestAuth()
auth.init_app(app) auth.init_app(app)
@auth.register_get_password @auth.register_get_password
@ -58,7 +58,6 @@ In your ``my_app/__init__.py``:
app: flask = Flask(__name__) app: flask = Flask(__name__)
... (Configure the Flask application) ... ... (Configure the Flask application) ...
auth.realm = app.config["REALM"]
auth.init_app(app) auth.init_app(app)
@auth.register_get_password @auth.register_get_password
@ -118,7 +117,7 @@ In your ``my_app.py``:
def load_user(user_id: str) -> t.Optional[User]: def load_user(user_id: str) -> t.Optional[User]:
... (Load the user with the username) ... ... (Load the user with the username) ...
auth: DigestAuth = DigestAuth(realm="Admin") auth: DigestAuth = DigestAuth()
auth.init_app(app) auth.init_app(app)
@auth.register_get_password @auth.register_get_password
@ -164,7 +163,6 @@ In your ``my_app/__init__.py``:
def load_user(user_id: str) -> t.Optional[User]: def load_user(user_id: str) -> t.Optional[User]:
... (Load the user with the username) ... ... (Load the user with the username) ...
auth.realm = app.config["REALM"]
auth.init_app(app) auth.init_app(app)
@auth.register_get_password @auth.register_get_password
@ -201,68 +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({
"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)
.. _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({
"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

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

@ -19,6 +19,7 @@ HTTP Digest Authentication is specified in `RFC 2617`_.
intro intro
flask_digest_auth flask_digest_auth
examples examples
changelog

View File

@ -46,6 +46,13 @@ You may also install the latest source from the
pip install git+https://github.com/imacat/flask-digestauth.git pip install git+https://github.com/imacat/flask-digestauth.git
Configuration
-------------
Flask-DigestAuth takes the configuration ``DIGEST_AUTH_REALM`` as the
realm. The default realm is ``Login Required``.
Setting the Password Setting the Password
-------------------- --------------------
@ -130,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 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.
@ -15,6 +15,42 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
[project]
name = "Flask-DigestAuth"
dynamic = ["version"]
description = "The Flask HTTP Digest Authentication project."
readme = "README.rst"
requires-python = ">=3.8"
authors = [
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
]
keywords = ["flask", "digest-authentication"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Framework :: Flask",
"Topic :: System :: Systems Administration :: Authentication/Directory",
"Intended Audience :: Developers",
]
dependencies = [
"Flask",
]
[project.optional-dependencies]
devel = [
"httpx",
]
[project.urls]
"Documentation" = "https://flask-digestauth.readthedocs.io"
"Change Log" = "https://mia-accounting.readthedocs.io/en/latest/changelog.html"
"Repository" = "https://github.com/imacat/flask-digestauth"
"Bug Tracker" = "https://github.com/imacat/flask-digestauth/issues"
[build-system] [build-system]
requires = ["setuptools>=42"] requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
version = {attr = "flask_digest_auth.VERSION"}

View File

@ -1,53 +0,0 @@
# The Flask HTTP Digest Authentication Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/23
# 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.
# 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.
[metadata]
name = Flask-DigestAuth
version = 0.4.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Flask HTTP Digest Authentication project.
long_description = file: README.rst
long_description_content_type = text/x-rst
url = https://github.com/imacat/flask-digestauth
project_urls =
Bug Tracker = https://github.com/imacat/flask-digestauth/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: Apache Software License
Operating System :: OS Independent
Framework :: Flask
Topic :: System :: Systems Administration :: Authentication/Directory
Intended Audience :: Developers
[options]
package_dir =
= src
packages = find:
python_requires = >=3.7
install_requires =
flask
tests_require =
unittest
flask-testing
[options.packages.find]
where = src
[options.extras_require]
flask_login =
flask-login

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,4 +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.7.0"
"""The package version."""

View File

@ -20,8 +20,8 @@
""" """
from __future__ import annotations from __future__ import annotations
import typing as t
from hashlib import md5 from hashlib import md5
from typing import Optional, Literal
def make_password_hash(realm: str, username: str, password: str) -> str: def make_password_hash(realm: str, username: str, password: str) -> str:
@ -44,10 +44,10 @@ def make_password_hash(realm: str, username: str, password: str) -> str:
def calc_response( def calc_response(
method: str, uri: str, password_hash: str, method: str, uri: str, password_hash: str,
nonce: str, qop: t.Optional[t.Literal["auth", "auth-int"]] = None, nonce: str, qop: Optional[Literal["auth", "auth-int"]] = None,
algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = "MD5-sess", algorithm: Optional[Literal["MD5", "MD5-sess"]] = "MD5-sess",
cnonce: t.Optional[str] = None, nc: t.Optional[str] = None, cnonce: Optional[str] = None, nc: Optional[str] = None,
body: t.Optional[bytes] = None) -> str: body: Optional[bytes] = None) -> str:
"""Calculates the response value of the HTTP digest authentication. """Calculates the response value of the HTTP digest authentication.
:param method: The request method. :param method: The request method.

View File

@ -23,9 +23,9 @@ See `RFC 2617`_ HTTP Authentication: Basic and Digest Access Authentication
from __future__ import annotations from __future__ import annotations
import sys import sys
import typing as t
from functools import wraps from functools import wraps
from secrets import token_urlsafe, randbits from secrets import token_urlsafe, randbits
from typing import Any, Optional, Literal, Callable, List
from flask import g, request, Response, session, abort, Flask, Request, \ from flask import g, request, Response, session, abort, Flask, Request, \
current_app current_app
@ -38,7 +38,7 @@ from flask_digest_auth.algo import calc_response
class DigestAuth: class DigestAuth:
"""The HTTP digest authentication.""" """The HTTP digest authentication."""
def __init__(self, realm: t.Optional[str] = None): def __init__(self, realm: Optional[str] = None):
"""Constructs the HTTP digest authentication. """Constructs the HTTP digest authentication.
:param realm: The realm. :param realm: The realm.
@ -46,18 +46,17 @@ class DigestAuth:
self.__serializer: URLSafeTimedSerializer \ self.__serializer: URLSafeTimedSerializer \
= URLSafeTimedSerializer(token_urlsafe(32)) = URLSafeTimedSerializer(token_urlsafe(32))
"""The serializer to generate and validate the nonce and opaque.""" """The serializer to generate and validate the nonce and opaque."""
self.realm: str = "" if realm is None else realm self.realm: str = "Login Required" if realm is None else realm
"""The realm. Default is an empty string.""" """The realm. Default is "Login Required"."""
self.algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = None self.algorithm: Optional[Literal["MD5", "MD5-sess"]] = None
"""The algorithm, either None, ``MD5``, or ``MD5-sess``. Default is """The algorithm, either None, ``MD5``, or ``MD5-sess``. Default is
None.""" None."""
self.use_opaque: bool = True self.use_opaque: bool = True
"""Whether to use an opaque. Default is True.""" """Whether to use an opaque. Default is True."""
self.__domain: t.List[str] = [] self.__domain: List[str] = []
"""A list of directories that this username and password applies to. """A list of directories that this username and password applies to.
Default is empty.""" Default is empty."""
self.__qop: t.List[t.Literal["auth", "auth-int"]] \ self.__qop: List[Literal["auth", "auth-int"]] = ["auth", "auth-int"]
= ["auth", "auth-int"]
"""A list of supported quality of protection supported, either """A list of supported quality of protection supported, either
``qop``, ``auth-int``, both, or empty. Default is both.""" ``qop``, ``auth-int``, both, or empty. Default is both."""
self.__get_password_hash: BasePasswordHashGetter \ self.__get_password_hash: BasePasswordHashGetter \
@ -68,7 +67,7 @@ class DigestAuth:
self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback() self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback()
"""The callback to run when the user logs in.""" """The callback to run when the user logs in."""
def login_required(self, view) -> t.Callable: def login_required(self, view) -> Callable:
"""The view decorator for the HTTP digest authentication. """The view decorator for the HTTP digest authentication.
:Example: :Example:
@ -89,7 +88,7 @@ class DigestAuth:
class NoLogInException(Exception): class NoLogInException(Exception):
"""The exception thrown when the user is not authorized.""" """The exception thrown when the user is not authorized."""
def get_logged_in_user() -> t.Any: def get_logged_in_user() -> Any:
"""Returns the currently logged-in user. """Returns the currently logged-in user.
:return: The currently logged-in user. :return: The currently logged-in user.
@ -97,13 +96,13 @@ class DigestAuth:
""" """
if "user" not in session: if "user" not in session:
raise NoLogInException raise NoLogInException
user: t.Optional[t.Any] = self.__get_user(session["user"]) user: Optional[Any] = self.__get_user(session["user"])
if user is None: if user is None:
del session["user"] del session["user"]
raise NoLogInException raise NoLogInException
return user return user
def auth_user(state: AuthState) -> t.Any: def auth_user(state: AuthState) -> Any:
"""Authenticates a user. """Authenticates a user.
:param state: The authentication state. :param state: The authentication state.
@ -121,7 +120,7 @@ class DigestAuth:
return self.__get_user(authorization.username) return self.__get_user(authorization.username)
@wraps(view) @wraps(view)
def login_required_view(*args, **kwargs) -> t.Any: def login_required_view(*args, **kwargs) -> Any:
"""The login-protected view. """The login-protected view.
:param args: The positional arguments of the view. :param args: The positional arguments of the view.
@ -141,7 +140,7 @@ class DigestAuth:
return view(*args, **kwargs) return view(*args, **kwargs)
except UnauthorizedException as e: except UnauthorizedException as e:
if len(e.args) > 0: if len(e.args) > 0:
sys.stderr.write(e.args[0] + "\n") current_app.logger.warning(e.args[0])
response: Response = Response() response: Response = Response()
response.status = 401 response.status = 401
response.headers["WWW-Authenticate"] \ response.headers["WWW-Authenticate"] \
@ -171,7 +170,7 @@ class DigestAuth:
except BadData: except BadData:
raise UnauthorizedException("Invalid opaque") raise UnauthorizedException("Invalid opaque")
state.opaque = authorization.opaque state.opaque = authorization.opaque
password_hash: t.Optional[str] \ password_hash: Optional[str] \
= self.__get_password_hash(authorization.username) = self.__get_password_hash(authorization.username)
if password_hash is None: if password_hash is None:
raise UnauthorizedException( raise UnauthorizedException(
@ -202,7 +201,7 @@ class DigestAuth:
:return: The ``WWW-Authenticate`` response header. :return: The ``WWW-Authenticate`` response header.
""" """
def get_opaque() -> t.Optional[str]: def get_opaque() -> Optional[str]:
"""Returns the opaque value. """Returns the opaque value.
:return: The opaque value. :return: The opaque value.
@ -213,7 +212,7 @@ class DigestAuth:
return state.opaque return state.opaque
return self.__serializer.dumps(randbits(32), salt="opaque") return self.__serializer.dumps(randbits(32), salt="opaque")
opaque: t.Optional[str] = get_opaque() opaque: Optional[str] = get_opaque()
nonce: str = self.__serializer.dumps( nonce: str = self.__serializer.dumps(
randbits(32), randbits(32),
salt="nonce" if opaque is None else f"nonce-{opaque}") salt="nonce" if opaque is None else f"nonce-{opaque}")
@ -226,7 +225,7 @@ class DigestAuth:
if opaque is not None: if opaque is not None:
header += f", opaque=\"{opaque}\"" header += f", opaque=\"{opaque}\""
if state.stale is not None: if state.stale is not None:
header += f", stale=TRUE" if state.stale else f", stale=FALSE" header += ", stale=TRUE" if state.stale else ", stale=FALSE"
if self.algorithm is not None: if self.algorithm is not None:
header += f", algorithm=\"{self.algorithm}\"" header += f", algorithm=\"{self.algorithm}\""
if len(self.__qop) > 0: if len(self.__qop) > 0:
@ -234,7 +233,7 @@ class DigestAuth:
header += f", qop=\"{qop_list}\"" header += f", qop=\"{qop_list}\""
return header return header
def register_get_password(self, func: t.Callable[[str], t.Optional[str]])\ def register_get_password(self, func: Callable[[str], Optional[str]]) \
-> None: -> None:
"""The decorator to register the callback to obtain the password hash. """The decorator to register the callback to obtain the password hash.
@ -256,7 +255,7 @@ class DigestAuth:
"""The base password hash getter.""" """The base password hash getter."""
@staticmethod @staticmethod
def __call__(username: str) -> t.Optional[str]: def __call__(username: str) -> Optional[str]:
"""Returns the password hash of a user. """Returns the password hash of a user.
:param username: The username. :param username: The username.
@ -266,8 +265,7 @@ class DigestAuth:
self.__get_password_hash = PasswordHashGetter() self.__get_password_hash = PasswordHashGetter()
def register_get_user(self, func: t.Callable[[str], t.Optional[t.Any]])\ def register_get_user(self, func: Callable[[str], Optional[Any]]) -> None:
-> None:
"""The decorator to register the callback to obtain the user. """The decorator to register the callback to obtain the user.
:Example: :Example:
@ -287,7 +285,7 @@ class DigestAuth:
"""The user getter.""" """The user getter."""
@staticmethod @staticmethod
def __call__(username: str) -> t.Optional[t.Any]: def __call__(username: str) -> Optional[Any]:
"""Returns a user. """Returns a user.
:param username: The username. :param username: The username.
@ -297,7 +295,7 @@ class DigestAuth:
self.__get_user = UserGetter() self.__get_user = UserGetter()
def register_on_login(self, func: t.Callable[[t.Any], None]) -> None: def register_on_login(self, func: Callable[[Any], None]) -> None:
"""The decorator to register the callback to run when the user logs in. """The decorator to register the callback to run when the user logs in.
:Example: :Example:
@ -316,7 +314,7 @@ class DigestAuth:
"""The callback when the user logs in.""" """The callback when the user logs in."""
@staticmethod @staticmethod
def __call__(user: t.Any) -> None: def __call__(user: Any) -> None:
"""Runs the callback when the user logs in. """Runs the callback when the user logs in.
:param user: The logged-in user. :param user: The logged-in user.
@ -336,60 +334,67 @@ class DigestAuth:
app: flask = Flask(__name__) app: flask = Flask(__name__)
auth: DigestAuth = DigestAuth() auth: DigestAuth = DigestAuth()
auth.realm = "My Admin"
auth.init_app(app) auth.init_app(app)
:param app: The Flask application. :param app: The Flask application.
:return: None. :return: None.
""" """
app.extensions["digest_auth"] = self app.extensions["digest_auth"] = self
if "DIGEST_AUTH_REALM" in app.config:
self.realm = app.config["DIGEST_AUTH_REALM"]
if hasattr(app, "login_manager"): if hasattr(app, "login_manager"):
from flask_login import LoginManager, login_user self.__init_login_manager(app)
login_manager: LoginManager = getattr(app, "login_manager") def __init_login_manager(self, app: Flask) -> None:
"""Initializes the Flask-Login login manager.
@login_manager.unauthorized_handler :param app: The Flask application.
def unauthorized() -> None: :return: None.
"""Handles when the user is unauthorized. """
from flask_login import LoginManager, login_user
login_manager: LoginManager = getattr(app, "login_manager")
:return: None. @login_manager.unauthorized_handler
""" def unauthorized() -> None:
state: AuthState = getattr(request, "_digest_auth_state") \ """Handles when the user is unauthorized.
if hasattr(request, "_digest_auth_state") \
else AuthState()
response: Response = Response()
response.status = 401
response.headers["WWW-Authenticate"] \
= self.__make_response_header(state)
abort(response)
@login_manager.request_loader :return: None.
def load_user_from_request(req: Request) -> t.Optional[t.Any]: """
"""Loads the user from the request header. state: AuthState = getattr(request, "_digest_auth_state") \
if hasattr(request, "_digest_auth_state") \
else AuthState()
response: Response = Response()
response.status = 401
response.headers["WWW-Authenticate"] \
= self.__make_response_header(state)
abort(response)
:param req: The request. @login_manager.request_loader
:return: The authenticated user, or None if the def load_user_from_request(req: Request) -> Optional[Any]:
authentication fails """Loads the user from the request header.
"""
request._digest_auth_state = AuthState() :param req: The request.
authorization: Authorization = req.authorization :return: The authenticated user, or None if the
try: authentication fails
if authorization is None: """
raise UnauthorizedException request._digest_auth_state = AuthState()
if authorization.type != "digest": authorization: Authorization = req.authorization
raise UnauthorizedException( try:
"Not an HTTP digest authorization") if authorization is None:
self.__authenticate(request._digest_auth_state) raise UnauthorizedException
user = login_manager.user_callback( if authorization.type != "digest":
authorization.username) raise UnauthorizedException(
login_user(user) "Not an HTTP digest authorization")
self.__on_login(user) self.__authenticate(request._digest_auth_state)
return user user = login_manager.user_callback(authorization.username)
except UnauthorizedException as e: login_user(user)
if str(e) != "": self.__on_login(user)
app.logger.warning(str(e)) return user
return None except UnauthorizedException as e:
if str(e) != "":
app.logger.warning(str(e))
return None
def logout(self) -> None: def logout(self) -> None:
"""Logs out the user. """Logs out the user.
@ -427,9 +432,9 @@ class AuthState:
def __init__(self): def __init__(self):
"""Constructs the authorization state.""" """Constructs the authorization state."""
self.opaque: t.Optional[str] = None self.opaque: Optional[str] = None
"""The opaque value specified by the client, if valid.""" """The opaque value specified by the client, if valid."""
self.stale: t.Optional[bool] = None self.stale: Optional[bool] = None
"""The stale value, if there is a previous log in attempt.""" """The stale value, if there is a previous log in attempt."""
@ -446,7 +451,7 @@ class BasePasswordHashGetter:
""" """
@staticmethod @staticmethod
def __call__(username: str) -> t.Optional[str]: def __call__(username: str) -> Optional[str]:
"""Returns the password hash of a user. """Returns the password hash of a user.
:param username: The username. :param username: The username.
@ -467,7 +472,7 @@ class BaseUserGetter:
""" """
@staticmethod @staticmethod
def __call__(username: str) -> t.Optional[t.Any]: def __call__(username: str) -> Optional[Any]:
"""Returns a user. """Returns a user.
:param username: The username. :param username: The username.
@ -487,7 +492,7 @@ class BaseOnLogInCallback:
""" """
@staticmethod @staticmethod
def __call__(user: t.Any) -> None: def __call__(user: Any) -> None:
"""Runs the callback when the user logs in. """Runs the callback when the user logs in.
:param user: The logged-in user. :param user: The logged-in user.

View File

@ -1,154 +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.
"""
import typing as t
from secrets import token_urlsafe
from flask import g
from werkzeug.datastructures import Authorization, WWWAuthenticate
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: t.Optional[t.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: t.Optional[t.Literal["auth", "auth-int"]] = None
if www_authenticate.qop is not None and "auth" in www_authenticate.qop:
qop = "auth"
cnonce: t.Optional[str] = None
if qop is not None or www_authenticate.algorithm == "MD5-sess":
cnonce = token_urlsafe(8)
nc: t.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: t.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

@ -18,8 +18,8 @@
"""The test case for the HTTP digest authentication algorithm. """The test case for the HTTP digest authentication algorithm.
""" """
import typing as t
import unittest import unittest
from typing import Optional, Literal
from flask_digest_auth import make_password_hash, calc_response from flask_digest_auth import make_password_hash, calc_response
@ -39,11 +39,11 @@ class AlgorithmTestCase(unittest.TestCase):
method: str = "GET" method: str = "GET"
uri: str = "/dir/index.html" uri: str = "/dir/index.html"
nonce: str = "dcd98b7102dd2f0e8b11d0f600bfb0c093" nonce: str = "dcd98b7102dd2f0e8b11d0f600bfb0c093"
qop: t.Optional[t.Literal["auth", "auth-int"]] = "auth" qop: Optional[Literal["auth", "auth-int"]] = "auth"
algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = None algorithm: Optional[Literal["MD5", "MD5-sess"]] = None
cnonce: t.Optional[str] = "0a4f113b" cnonce: Optional[str] = "0a4f113b"
nc: t.Optional[str] = "00000001" nc: Optional[str] = "00000001"
body: t.Optional[bytes] = None body: Optional[bytes] = None
password_hash: str = make_password_hash(realm, username, password) password_hash: str = make_password_hash(realm, username, password)
response: str = calc_response(method, uri, password_hash, nonce, qop, response: str = calc_response(method, uri, password_hash, nonce, qop,

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/10/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2022/10/22
# 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.
@ -18,18 +18,18 @@
"""The test case for the HTTP digest authentication. """The test case for the HTTP digest authentication.
""" """
import typing as t import logging
import unittest
from secrets import token_urlsafe from secrets import token_urlsafe
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
_USERNAME: str = "Mufasa"
_PASSWORD: str = "Circle Of Life"
class User: class User:
@ -42,33 +42,41 @@ class User:
:param password: The clear-text password. :param password: The clear-text password.
""" """
self.username: str = username self.username: str = username
self.password_hash: str = make_password_hash( """The username."""
_REALM, username, password) self.password_hash: str = make_password_hash(REALM, username, password)
"""The password hash."""
self.visits: int = 0 self.visits: int = 0
"""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())
app: Flask = Flask(__name__) app: Flask = Flask(__name__)
app.config.from_mapping({ app.config.from_mapping({
"TESTING": True,
"SECRET_KEY": token_urlsafe(32), "SECRET_KEY": token_urlsafe(32),
"TESTING": True "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(realm=_REALM) auth: DigestAuth = DigestAuth()
auth.init_app(app) auth.init_app(app)
self.user: User = User(_USERNAME, _PASSWORD) self.__user: User = User(USERNAME, PASSWORD)
user_db: t.Dict[str, User] = {_USERNAME: self.user} """The user account."""
user_db: Dict[str, User] = {USERNAME: self.__user}
@auth.register_get_password @auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]: def get_password_hash(username: str) -> Optional[str]:
"""Returns the password hash of a user. """Returns the password hash of a user.
:param username: The username. :param username: The username.
@ -78,7 +86,7 @@ class AuthenticationTestCase(TestCase):
else None else None
@auth.register_get_user @auth.register_get_user
def get_user(username: str) -> t.Optional[t.Any]: def get_user(username: str) -> Optional[Any]:
"""Returns a user. """Returns a user.
:param username: The username. :param username: The username.
@ -95,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.
@ -104,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.
@ -113,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.
@ -123,63 +131,65 @@ 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:
"""Tests the stale and opaque value. """Tests the stale and opaque value.
: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.assertEqual(www_authenticate.stale, None) 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(
self.assertEqual(www_authenticate.stale, True) response.headers["WWW-Authenticate"])
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(
self.assertEqual(www_authenticate.stale, False) response.headers["WWW-Authenticate"])
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:
@ -187,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

@ -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 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.
@ -18,18 +18,21 @@
"""The test case for the Flask-Login integration. """The test case for the Flask-Login integration.
""" """
import typing as t import logging
import unittest
from secrets import token_urlsafe from secrets import token_urlsafe
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."
_USERNAME: str = "Mufasa" """The message that a test is skipped when Flask-Login is not installed."""
_PASSWORD: str = "Circle Of Life"
class User: class User:
@ -42,11 +45,15 @@ class User:
:param password: The clear-text password. :param password: The clear-text password.
""" """
self.username: str = username self.username: str = username
self.password_hash: str = make_password_hash( """The username."""
_REALM, username, password) self.password_hash: str = make_password_hash(REALM, username, password)
"""The password hash."""
self.visits: int = 0 self.visits: int = 0
"""The number of visits."""
self.is_active: bool = True self.is_active: bool = True
"""True if the account is active, or False otherwise."""
self.is_anonymous: bool = False self.is_anonymous: bool = False
"""True if the account is anonymous, or False otherwise."""
def get_id(self) -> str: def get_id(self) -> str:
"""Returns the username. """Returns the username.
@ -67,39 +74,49 @@ 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.
""" """
app: Flask = Flask(__name__) logging.getLogger("test_flask_login").addHandler(logging.NullHandler())
app.config.from_mapping({ self.app: Flask = Flask(__name__)
self.app.config.from_mapping({
"TESTING": True,
"SECRET_KEY": token_urlsafe(32), "SECRET_KEY": token_urlsafe(32),
"TESTING": True "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."""
try: try:
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(realm=_REALM) auth: DigestAuth = DigestAuth()
auth.init_app(app) auth.init_app(self.app)
self.user: User = User(_USERNAME, _PASSWORD) self.__user: User = User(USERNAME, PASSWORD)
user_db: t.Dict[str, User] = {_USERNAME: self.user} """The user account."""
user_db: Dict[str, User] = {USERNAME: self.__user}
@auth.register_get_password @auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]: def get_password_hash(username: str) -> Optional[str]:
"""Returns the password hash of a user. """Returns the password hash of a user.
:param username: The username. :param username: The username.
@ -118,7 +135,7 @@ class FlaskLoginTestCase(TestCase):
user.visits = user.visits + 1 user.visits = user.visits + 1
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id: str) -> t.Optional[User]: def load_user(user_id: str) -> Optional[User]:
"""Loads a user. """Loads a user.
:param user_id: The username. :param user_id: The username.
@ -126,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.
@ -135,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.
@ -144,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.
@ -154,75 +171,81 @@ 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:
"""Tests the stale and opaque value. """Tests the stale and opaque value.
: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.assertEqual(www_authenticate.stale, None) self.assertIsNone(www_authenticate.get("stale"))
opaque: str = www_authenticate.opaque opaque: str = www_authenticate.opaque
if hasattr(g, "_login_user"): with self.app.app_context():
delattr(g, "_login_user") if hasattr(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(
self.assertEqual(www_authenticate.stale, True) response.headers["WWW-Authenticate"])
self.assertEqual(www_authenticate.get("stale"), "TRUE")
self.assertEqual(www_authenticate.opaque, opaque) self.assertEqual(www_authenticate.opaque, opaque)
if hasattr(g, "_login_user"): with self.app.app_context():
delattr(g, "_login_user") if hasattr(g, "_login_user"):
auth_data = Client.make_authorization( delattr(g, "_login_user")
www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2") auth_header = make_authorization(
response = super(Client, self.client).get(admin_uri, auth=auth_data) 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) self.assertEqual(response.status_code, 401)
www_authenticate = response.www_authenticate www_authenticate = WWWAuthenticate.from_header(
self.assertEqual(www_authenticate.stale, False) response.headers["WWW-Authenticate"])
self.assertEqual(www_authenticate.get("stale"), "FALSE")
self.assertEqual(www_authenticate.opaque, opaque) self.assertEqual(www_authenticate.opaque, opaque)
if hasattr(g, "_login_user"): with self.app.app_context():
delattr(g, "_login_user") if hasattr(g, "_login_user"):
auth_data = Client.make_authorization( delattr(g, "_login_user")
www_authenticate, admin_uri, _USERNAME, _PASSWORD) auth_header = make_authorization(
response = super(Client, self.client).get(admin_uri, auth=auth_data) www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
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:
@ -230,69 +253,67 @@ 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)
def test_disabled(self) -> None: def test_disabled(self) -> None:
"""Tests the disabled user. """Tests the disabled user.
: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))