55 Commits

Author SHA1 Message Date
a88fdf81fa Advanced to version 0.2.3. 2022-12-06 22:18:49 +08:00
4993bfdd9b Added the documentation dependencies. 2022-12-06 22:18:22 +08:00
696f350a44 Updated the version number in the documentation. 2022-12-06 22:10:25 +08:00
409f794835 Advanced to version 0.2.2. 2022-12-06 22:05:12 +08:00
92eb011470 Added the documentation to MANIFEST.in. 2022-12-06 22:04:58 +08:00
b5ecd1552b Added the .keep files to the _static and _templates subdirectories in the docs directory to keep them in the source distribution. 2022-12-06 22:04:33 +08:00
765822a300 Revised the documentation. 2022-12-06 21:54:10 +08:00
e9a6449505 Changed the "authenticate" and "make_response_header" methods to private in the DigestAuth class. 2022-12-06 21:22:16 +08:00
df15f0b0d5 Revised the introduction in the documentation. 2022-12-06 20:58:13 +08:00
6c7f7e8c8e Added the comment to the master_doc setting in conf.py. 2022-12-06 20:02:53 +08:00
dfc1108b41 Added master_doc to conf.py. 2022-12-06 19:34:45 +08:00
4fe57532b0 Added the installation instructions to the introduction in the documentation. 2022-12-06 19:34:26 +08:00
d104b0f28d Revised the title of the documentation. 2022-12-06 18:55:20 +08:00
30106c7e9f Added the introduction to the documentation. 2022-12-06 18:54:40 +08:00
9997985d8c Added the examples to the documentation. 2022-12-06 18:37:51 +08:00
6057fc0987 Revised the documentation of the Client class and the calc_response function. 2022-12-06 18:04:12 +08:00
a6dc530ac7 Moved the BasePasswordHashGetter, BaseUserGetter, and BaseUserGetter classes to the end of flask_digest_auth.auth, for readability. 2022-12-06 17:47:21 +08:00
6a14c04aaa Moved UnauthorizedException from flask_digest_auth.exception to flask_digest_auth.auth, because it is only used there. 2022-12-06 17:46:17 +08:00
f9e10ecb2f Replaced validate_required with assert in the calc_response function, for simplicity. 2022-12-06 17:44:18 +08:00
b6bfb2eae9 Revised the documentation of the DigestAuth class and the calc_response function. 2022-12-06 17:38:58 +08:00
e6b4594393 Added the Sphinx documentation. 2022-12-06 17:38:14 +08:00
495e9a9785 Advanced to version 0.2.1. 2022-12-06 07:59:44 +08:00
cbbd2248f0 Added the pytest test example to README.rst. 2022-12-06 07:54:49 +08:00
2028cb1362 Revised the User class in the AuthenticationTestCase and FlaskLoginTestCase test classes to accept the clear-text password instead of the password hash, to simplify the code. 2022-12-03 14:30:22 +08:00
7e71115844 Fixed the test_logout test of the FlaskLoginTestCase test case to skip without Flask-Login. 2022-12-03 11:56:40 +08:00
491da61a79 Fixed the AuthenticationTestCase and FlaskLoginTestCase test cases to store the user instead of finding the user through flask-login or g, so that the visit tests work without the application context. 2022-12-03 11:55:52 +08:00
bbaebbc80d Fixed the long lines in the AuthenticationTestCase and FlaskLoginTestCase test cases. 2022-11-30 23:35:04 +08:00
0dfdf70c45 Fixed a minor problem in the get_logged_in_user function. 2022-11-30 08:31:58 +08:00
0432561b21 Revised the minor problems in README.rst. 2022-11-30 08:31:26 +08:00
3709cb4d66 Fixed the return type hint of the get_logged_in_user function. 2022-11-30 08:30:42 +08:00
9d0d0b2686 Revised the login_required method of the DigestAuth class for readability. 2022-11-29 22:24:25 +08:00
8c98d35934 Revised the calc_response function for readability. 2022-11-29 22:10:18 +08:00
7db38c7eae Revised the code in the make_authorization method of the test client. 2022-11-29 21:53:16 +08:00
9616fb3ddc Added the get_opaque inline function in the make_response_header method of the DigestAuth class for readability. 2022-11-29 21:52:19 +08:00
f473db29a8 Revised the order in .gitignore. 2022-11-29 20:32:25 +08:00
b39e9b1321 Added sonar-project.properties to .gitignore. 2022-11-29 20:25:08 +08:00
f3b525d715 Replaced random.random() with secrets.randbits() in the make_response_header method of the DigestAuth class. 2022-11-29 19:13:50 +08:00
0f3694ba05 Added the SonarQube .scannerwork directory to .gitignore. 2022-11-29 19:12:47 +08:00
2425d99492 Advanced to version 0.2.0. 2022-11-27 07:06:35 +11:00
be163d35fb Added the on-login callback for the log in bookkeeping. 2022-11-27 06:58:29 +11:00
2aaaa9f47f Changed the BasePasswordGetter and BaseUserGetter from abstract to non-abstract, to simplify the code. 2022-11-27 06:32:02 +11:00
cb3e313e21 Revised the FlaskLoginTestCase test case and README.rst for illustrations on how to retrieve the currently logged-in user. 2022-11-26 18:59:17 +11:00
6f49a180e3 Revised the DigestAuth class to deal with the dummy get_password_hash and get_user functions when they are not registered yet. 2022-11-26 09:37:13 +11:00
af8c3a484c Revised so that you always call digest_auth.init_app(), to avoid confusion. It remembers the current application. The logout() method no longer need current_app for the current application. 2022-11-25 09:14:57 +11:00
65c3322ecc Revised the routes in the examples in README.rst. 2022-11-25 08:41:30 +11:00
cb5cfaf7d4 Added logging out. 2022-11-25 08:40:27 +11:00
dda8472a76 Revised the test_stale_opaque tests of the AuthenticationTestCase and FlaskLoginTestCase test cases to use the super method instead of the overridden method of the Client class. 2022-11-25 00:18:43 +11:00
177f549786 Changed the test_stale tests to test_stale_opaque that also tests if the opaque value is still the same across the client authentication requests. 2022-11-24 23:55:49 +11:00
ff8ada129d Added the docstring documentation to the dummy user of the Flask-Login test case. 2022-11-24 23:48:09 +11:00
ccec1365bf Revised the AuthenticationTestCase and FlaskLoginTestCase test cases for simplicity and readability. 2022-11-24 23:46:45 +11:00
78514a8f17 Added to test the stale value of the WWW-Authenticate response header in the AuthenticationTestCase and FlaskLoginTestCase test cases. 2022-11-24 21:53:21 +11:00
3dcc409bef Changed the make_authorization method from private to public in the test client. 2022-11-24 21:45:11 +11:00
2156aa710f Added the instructions on setting the password hash in README.rst. 2022-11-24 21:34:48 +11:00
a43c6aea89 Added rationale to HTTP Digest Authentication in README.rst. 2022-11-24 21:25:01 +11:00
8e29c91f92 Revised README.rst. 2022-11-24 21:10:25 +11:00
20 changed files with 1403 additions and 215 deletions

9
.gitignore vendored
View File

@ -23,10 +23,13 @@ dist
.pytest_cache
venv
flask_session
instance
.DS_Store
.idea
instance
flask_session
.scannerwork
sonar-project.properties
excludes

View File

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

View File

@ -6,35 +6,68 @@ Flask HTTP Digest Authentication
Description
===========
*Flask-Digest-Auth* is an HTTP Digest Authentication implementation
*Flask-Digest-Auth* is an `HTTP Digest Authentication`_ implementation
for Flask_ applications. It authenticates the user for the protected
views. It works with Flask-Login_, so that log in protection can be
separated with the authentication mechanism. You can write Flask
modules that work with different authentication mechanisms.
views.
.. _Flask: https://flask.palletsprojects.com
.. _Flask-Login: https://flask-login.readthedocs.io
HTTP Digest Authentication is specified in `RFC 2617`_.
Refer to the full `Flask-Digest-Auth readthedocs documentation`_.
Why HTTP Digest Authentication?
-------------------------------
*HTTP Digest Authentication* has the advantage that it does not send
thee actual password to the server, which greatly enhances the
security. It uses the challenge-response authentication scheme. The
client returns the response calculated from the challenge and the
password, but not the original password.
Log in forms has the advantage of freedom, in the senses of both the
visual design and the actual implementation. You may implement your
own challenge-response log in form, but then you are reinventing the
wheels. If a pretty log in form is not critical to your project, HTTP
Digest Authentication should be a good choice.
Flask-Digest-Auth works with Flask-Login_. Log in protection can be
separated with the authentication mechanism. You can create protected
Flask modules without knowing the actual authentication mechanisms.
Installation
============
It's suggested that you install with ``pip``:
You can install Flask-Digest-Auth with ``pip``:
::
pip install flask-digest-auth
pip install Flask-Digest-Auth
You may also install the latest source from the
`flask-digest-auth Github repository`_.
`Flask-Digest-Auth GitHub repository`_.
::
git clone git@github.com:imacat/flask-digest-auth.git
cd flask-digest-auth
pip install .
pip install git+https://github.com/imacat/flask-digest-auth.git
.. _flask-digest-auth Github repository: https://github.com/imacat/flask-digest-auth
Setting the Password
====================
The password hash of the HTTP Digest Authentication is composed of the
realm, the username, and the password. Example for setting the
password:
::
from flask_digest_auth import make_password_hash
user.password = make_password_hash(realm, username, password)
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-Digest-Auth Alone
@ -43,20 +76,21 @@ Flask-Digest-Auth Alone
Flask-Digest-Auth can authenticate the users alone.
Example for Simple Applications with Flask-Digest-Auth Alone
------------------------------------------------------------
Simple Applications with Flask-Digest-Auth Alone
------------------------------------------------
In your ``my_app.py``:
::
from flask import Flask
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]:
@ -69,15 +103,21 @@ In your ``my_app.py``:
@app.get("/admin")
@auth.login_required
def admin():
... (Process the view) ...
return f"Hello, {g.user.username}!"
@app.post("/logout")
@auth.login_required
def logout():
auth.logout()
return redirect(request.form.get("next"))
Example for Larger Applications with ``create_app()`` with Flask-Digest-Auth Alone
----------------------------------------------------------------------------------
Larger Applications with ``create_app()`` with Flask-Digest-Auth Alone
----------------------------------------------------------------------
In your ``my_app/__init__.py``:
:::
::
from flask import Flask
from flask_digest_auth import DigestAuth
@ -89,6 +129,7 @@ In your ``my_app/__init__.py``:
... (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]:
@ -105,43 +146,61 @@ In your ``my_app/views.py``:
::
from my_app import auth
from flask import Flask, Blueprint
from flask import Flask, Blueprint, request, redirect
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/")
@bp.get("/admin")
@auth.login_required
def admin():
... (Process the view) ...
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-Digest-Auth can work with Flask-Login. You can write a Flask
module that requires log in, without specifying the authentication
mechanism. The Flask application can specify the actual
authentication mechanism as it sees fit.
Flask-Digest-Auth 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-Digest-Auth,
``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-Digest-Auth.
You can change the actual authentication mechanism without changing
the views.
Example for Simple Applications with Flask-Login Integration
------------------------------------------------------------
Simple Applications with Flask-Login Integration
------------------------------------------------
In your ``my_app.py``:
::
from flask import Flask
import flask_login
from flask import Flask, request, redirect
from flask_digest_auth import DigestAuth
from flask_login import LoginManager
app: flask = Flask(__name__)
... (Configure the Flask application) ...
login_manager: LoginManager = LoginManager()
login_manager: flask_login.LoginManager = flask_login.LoginManager()
login_manager.init_app(app)
@login_manager.user_loader
@ -156,22 +215,31 @@ In your ``my_app.py``:
... (Load the password hash) ...
@app.get("/admin")
@login_manager.login_required
@flask_login.login_required
def admin():
... (Process the view) ...
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"))
Example for Larger Applications with ``create_app()`` with Flask-Login Integration
----------------------------------------------------------------------------------
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) ...
@ -183,7 +251,7 @@ In your ``my_app/__init__.py``:
def load_user(user_id: str) -> t.Optional[User]:
... (Load the user with the username) ...
auth: DigestAuth = DigestAuth(realm=app.config["REALM"])
auth.realm = app.config["REALM"]
auth.init_app(app)
@auth.register_get_password
@ -197,33 +265,73 @@ In your ``my_app/views.py``:
::
import flask_login
from flask import Flask, Blueprint
from flask import Flask, Blueprint, request, redirect
from my_app import auth
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/")
@bp.get("/admin")
@flask_login.login_required
def admin():
... (Process the view) ...
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 its underlying
authentication mechanism. You can always change the
authentication mechanism without changing the views, or release a
protected Flask module without specifying the authentication
mechanism.
The views only depend on Flask-Login, but not the actual
authentication mechanism. You can change the actual authentication
mechanism without changing the views.
Writing Tests
=============
Session Integration
===================
You can write tests with our test client that handles HTTP Digest
Authentication. Example for a unittest testcase:
Flask-Digest-Auth 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-Digest-Auth supports log out. The user will be prompted for the
new username and password.
Test Client
===========
Flask-Digest-Auth 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
@ -246,6 +354,38 @@ Authentication. Example for a unittest testcase:
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 = self.client.get("/admin")
assert response.status_code == 401
response = self.client.get(
"/admin", digest_auth=("my_name", "my_pass"))
assert response.status_code == 200
Copyright
=========
@ -263,9 +403,17 @@ Copyright
See the License for the specific language governing permissions and
limitations under the License.
Authors
=======
| imacat
| imacat@mail.imacat.idv.tw
| 2022/11/23
.. _HTTP Digest Authentication: https://en.wikipedia.org/wiki/Digest_access_authentication
.. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617
.. _Flask: https://flask.palletsprojects.com
.. _Flask-Digest-Auth GitHub repository: https://github.com/imacat/flask-digest-auth
.. _Flask-Digest-Auth readthedocs documentation: https://flask-digest-auth.readthedocs.io
.. _Flask-Login: https://flask-login.readthedocs.io

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

1
docs/requirements.txt Normal file
View File

@ -0,0 +1 @@
flask

View File

View File

59
docs/source/conf.py Normal file
View File

@ -0,0 +1,59 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import sys
sys.path.insert(0, os.path.abspath('../../src/'))
# -- Project information -----------------------------------------------------
project = 'Flask-Digest-Auth'
copyright = '2022, imacat'
author = 'imacat'
# The full version, including alpha/beta/rc tags
release = '0.2.3'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx.ext.autodoc"
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# For readthedocs.io to work properly.
master_doc = 'index'

268
docs/source/examples.rst Normal file
View File

@ -0,0 +1,268 @@
Examples
========
.. _example-alone-simple:
Simple Applications with Flask-Digest-Auth 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"))
.. _example-alone-large:
Larger Applications with ``create_app()`` with Flask-Digest-Auth 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)
.. _example-flask-login-simple:
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"))
.. _example-flask-login-large:
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.
.. _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=("my_name", "my_pass"))
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 = self.client.get("/admin")
assert response.status_code == 401
response = self.client.get(
"/admin", digest_auth=("my_name", "my_pass"))
assert response.status_code == 200

View File

@ -0,0 +1,24 @@
flask\_digest\_auth package
===========================
The ``DigestAuth`` Class
------------------------
.. autoclass:: flask_digest_auth.DigestAuth
:members:
:undoc-members:
:show-inheritance:
The ``make_password_hash`` Function
-----------------------------------
.. autofunction:: flask_digest_auth.make_password_hash
The ``calc_response`` Function
------------------------------
.. autofunction:: flask_digest_auth.calc_response
The ``Client`` Test Class
-------------------------
.. autoclass:: flask_digest_auth.Client
:members:
:undoc-members:
:show-inheritance:

34
docs/source/index.rst Normal file
View File

@ -0,0 +1,34 @@
.. flask-digest-auth documentation master file, created by
sphinx-quickstart on Tue Dec 6 15:15:08 2022.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Flask-Digest-Auth's documentation!
=============================================
*Flask-Digest-Auth* is an `HTTP Digest Authentication`_ implementation
for Flask_ applications. It authenticates the user for the protected
views.
HTTP Digest Authentication is specified in `RFC 2617`_.
.. toctree::
:maxdepth: 2
:caption: Contents:
intro
flask_digest_auth
examples
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
.. _HTTP Digest Authentication: https://en.wikipedia.org/wiki/Digest_access_authentication
.. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617
.. _Flask: https://flask.palletsprojects.com

148
docs/source/intro.rst Normal file
View File

@ -0,0 +1,148 @@
Introduction
============
*Flask-Digest-Auth* is an `HTTP Digest Authentication`_ implementation
for Flask_ applications. It authenticates the user for the protected
views.
HTTP Digest Authentication is specified in `RFC 2617`_.
Why HTTP Digest Authentication?
-------------------------------
*HTTP Digest Authentication* has the advantage that it does not send
the actual password to the server, which greatly enhances the
security. It uses the challenge-response authentication scheme. The
client returns the response calculated from the challenge and the
password, but not the original password.
Log in forms has the advantage of freedom, in the senses of both the
visual design and the actual implementation. You may implement your
own challenge-response log in form, but then you are reinventing the
wheels. If a pretty log in form is not critical to your project, HTTP
Digest Authentication should be a good choice.
Flask-Digest-Auth works with Flask-Login_. Log in protection can be
separated with the authentication mechanism. You can create protected
Flask modules without knowing the actual authentication mechanisms.
Installation
------------
You can install Flask-Digest-Auth with ``pip``:
::
pip install Flask-Digest-Auth
You may also install the latest source from the
`Flask-Digest-Auth GitHub repository`_.
::
pip install git+https://github.com/imacat/flask-digest-auth.git
Setting the Password
--------------------
The password hash of the HTTP Digest Authentication is composed of the
realm, the username, and the password. Example for setting the
password:
::
from flask_digest_auth import make_password_hash
user.password = make_password_hash(realm, username, password)
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.
See :meth:`flask_digest_auth.make_password_hash`.
Flask-Digest-Auth Alone
-----------------------
Flask-Digest-Auth can authenticate the users alone.
See :ref:`example-alone-simple` and :ref:`example-alone-large`.
Flask-Login Integration
-----------------------
Flask-Digest-Auth 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-Digest-Auth,
``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.
See :ref:`example-flask-login-simple` and
:ref:`example-flask-login-large`.
The views only depend on Flask-Login, but not the Flask-Digest-Auth.
You can change the actual authentication mechanism without changing
the views.
Session Integration
-------------------
Flask-Digest-Auth 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
See :meth:`flask_digest_auth.DigestAuth.register_on_login`.
Log Out
-------
Flask-Digest-Auth supports log out. The user will be prompted for the
new username and password.
See :meth:`flask_digest_auth.DigestAuth.logout`.
Test Client
-----------
Flask-Digest-Auth comes with a test client that supports HTTP digest
authentication.
See :class:`flask_digest_auth.Client`.
Also see :ref:`example-unittest` and :ref:`example-pytest`.
.. _HTTP Digest Authentication: https://en.wikipedia.org/wiki/Digest_access_authentication
.. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617
.. _Flask: https://flask.palletsprojects.com
.. _Flask-Login: https://flask-login.readthedocs.io
.. _Flask-Digest-Auth GitHub repository: https://github.com/imacat/flask-digest-auth

View File

@ -17,7 +17,7 @@
[metadata]
name = flask-digest-auth
version = 0.1.1
version = 0.2.3
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Flask HTTP Digest Authentication project.

View File

@ -23,11 +23,16 @@ from __future__ import annotations
import typing as t
from hashlib import md5
from flask_digest_auth.exception import UnauthorizedException
def make_password_hash(realm: str, username: str, password: str) -> str:
"""Calculates the password hash for the HTTP digest authentication.
Use this function to set the password for the user.
For example:
::
user.password = make_password_hash(realm, username, password)
:param realm: The realm.
:param username: The username.
@ -49,67 +54,53 @@ def calc_response(
:param uri: The request URI.
:param password_hash: The password hash for the HTTP digest authentication.
:param nonce: The nonce.
:param qop: the quality of protection.
:param algorithm: The algorithm, either "MD5" or "MD5-sess".
:param qop: the quality of protection, either ``auth`` or ``auth-int``.
:param algorithm: The algorithm, either ``MD5`` or ``MD5-sess``.
:param cnonce: The client nonce, which must exists when qop exists or
algorithm="MD5-sess".
algorithm is ``MD5-sess``.
:param nc: The request counter, which must exists when qop exists.
:param body: The request body, which must exists when qop="auth-int".
:param body: The request body, which must exists when qop is ``auth-int``.
:return: The response value.
:raise UnauthorizedException: When the cnonce is missing with the MD5-sess
algorithm, when the body is missing with the auth-int qop, or when the
cnonce or nc is missing with the auth or auth-int qop.
:raise AssertionError: When cnonce is missing with algorithm is
``MD5-sess``, when body is missing with qop is ``auth-int``, or when
cnonce or nc is missing with qop exits.
"""
def calc_ha1() -> str:
"""Calculates and returns the first hash.
:return: The first hash.
:raise UnauthorizedException: When the cnonce is missing with the MD5-sess
algorithm.
:raise AssertionError: When cnonce is missing with
algorithm is ``MD5-sess``.
"""
if algorithm is None or algorithm == "MD5":
return password_hash
if algorithm == "MD5-sess":
if cnonce is None:
raise UnauthorizedException(
f"Missing \"cnonce\" with algorithm=\"{algorithm}\"")
assert cnonce is not None,\
f"Missing \"cnonce\" with algorithm=\"{algorithm}\""
return md5(f"{password_hash}:{nonce}:{cnonce}".encode("utf8")) \
.hexdigest()
raise UnauthorizedException(
f"Unsupported algorithm=\"{algorithm}\"")
# algorithm is None or algorithm == "MD5"
return password_hash
def calc_ha2() -> str:
"""Calculates the second hash.
:return: The second hash.
:raise UnauthorizedException: When the body is missing with
qop="auth-int".
:raise AssertionError: When body is missing with qop is ``auth-int``.
"""
if qop is None or qop == "auth":
return md5(f"{method}:{uri}".encode("utf8")).hexdigest()
if qop == "auth-int":
if body is None:
raise UnauthorizedException(
f"Missing \"body\" with qop=\"{qop}\"")
assert body is not None, f"Missing \"body\" with qop=\"{qop}\""
return md5(
f"{method}:{uri}:{md5(body).hexdigest()}".encode("utf8")) \
.hexdigest()
raise UnauthorizedException(f"Unsupported qop=\"{qop}\"")
# qop is None or qop == "auth"
return md5(f"{method}:{uri}".encode("utf8")).hexdigest()
ha1: str = calc_ha1()
ha2: str = calc_ha2()
if qop is None:
return md5(f"{ha1}:{nonce}:{ha2}".encode("utf8")).hexdigest()
if qop == "auth" or qop == "auth-int":
if cnonce is None:
raise UnauthorizedException(
f"Missing \"cnonce\" with the qop=\"{qop}\"")
if nc is None:
raise UnauthorizedException(
f"Missing \"nc\" with the qop=\"{qop}\"")
assert cnonce is not None, f"Missing \"cnonce\" with the qop=\"{qop}\""
assert nc is not None, f"Missing \"nc\" with the qop=\"{qop}\""
return md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode("utf8"))\
.hexdigest()
if cnonce is None:
raise UnauthorizedException(
f"Unsupported qop=\"{qop}\"")
# qop is None
return md5(f"{ha1}:{nonce}:{ha2}".encode("utf8")).hexdigest()

View File

@ -24,15 +24,13 @@ from __future__ import annotations
import sys
import typing as t
from functools import wraps
from random import random
from secrets import token_urlsafe
from secrets import token_urlsafe, randbits
from flask import g, request, Response, session, abort, Flask, Request
from itsdangerous import URLSafeTimedSerializer, BadData
from werkzeug.datastructures import Authorization
from flask_digest_auth.algo import calc_response
from flask_digest_auth.exception import UnauthorizedException
class DigestAuth:
@ -51,20 +49,63 @@ class DigestAuth:
self.use_opaque: bool = True
self.domain: t.List[str] = []
self.qop: t.List[str] = ["auth", "auth-int"]
self.__get_password_hash: t.Callable[[str], t.Optional[str]] \
= lambda x: None
self.__get_user: t.Callable[[str], t.Optional] = lambda x: None
self.app: t.Optional[Flask] = None
self.__get_password_hash: BasePasswordHashGetter \
= BasePasswordHashGetter()
self.__get_user: BaseUserGetter = BaseUserGetter()
self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback()
def login_required(self, view) -> t.Callable:
"""The view decorator for HTTP digest authentication.
:param view:
For example:
::
@auth.login_required
def admin():
return f"Hello, {g.user.username}!"
The logged-in user can be retrieved at ``g.user``.
:param view: The view.
:return: The login-protected view.
"""
class NoLogInException(Exception):
"""The exception thrown when the user is not authorized."""
def get_logged_in_user() -> t.Any:
"""Returns the currently logged-in user.
:return: The currently logged-in user.
:raise NoLogInException: When the user is not logged in.
"""
if "user" not in session:
raise NoLogInException
user: t.Optional[t.Any] = self.__get_user(session["user"])
if user is None:
del session["user"]
raise NoLogInException
return user
def auth_user(state: AuthState) -> t.Any:
"""Authenticates a user.
:param state: The authentication state.
:return: The user.
:raise UnauthorizedException: When the authentication fails.
"""
authorization: Authorization = request.authorization
if authorization is None:
raise UnauthorizedException
if authorization.type != "digest":
raise UnauthorizedException(
"Not an HTTP digest authorization")
self.__authenticate(state)
session["user"] = authorization.username
return self.__get_user(authorization.username)
@wraps(view)
def login_required_view(*args, **kwargs) -> t.Any:
"""The login-protected view.
@ -74,25 +115,15 @@ class DigestAuth:
:return: The response.
"""
try:
if "user" not in session:
raise NoLogInException
user: t.Optional[t.Any] = self.__get_user(session["user"])
if user is None:
raise NoLogInException
g.user = user
g.user = get_logged_in_user()
return view(*args, **kwargs)
except NoLogInException:
pass
state: AuthState = AuthState()
authorization: Authorization = request.authorization
try:
if authorization is None:
raise UnauthorizedException
if authorization.type != "digest":
raise UnauthorizedException(
"Not an HTTP digest authorization")
self.authenticate(state)
session["user"] = authorization.username
g.user = self.__get_user(authorization.username)
g.user = auth_user(state)
self.__on_login(g.user)
return view(*args, **kwargs)
except UnauthorizedException as e:
if len(e.args) > 0:
@ -100,18 +131,21 @@ class DigestAuth:
response: Response = Response()
response.status = 401
response.headers["WWW-Authenticate"] \
= self.make_response_header(state)
= self.__make_response_header(state)
abort(response)
return login_required_view
def authenticate(self, state: AuthState) -> None:
def __authenticate(self, state: AuthState) -> None:
"""Authenticate a user.
:param state: The authorization state.
:return: None.
:raise UnauthorizedException: When the authentication failed.
"""
if "digest_auth_logout" in session:
del session["digest_auth_logout"]
raise UnauthorizedException("Logging out")
authorization: Authorization = request.authorization
if self.use_opaque:
if authorization.opaque is None:
@ -123,8 +157,8 @@ class DigestAuth:
except BadData:
raise UnauthorizedException("Invalid opaque")
state.opaque = authorization.opaque
password_hash: t.Optional[str] = self.__get_password_hash(
authorization.username)
password_hash: t.Optional[str] \
= self.__get_password_hash(authorization.username)
if password_hash is None:
raise UnauthorizedException(
f"No such user \"{authorization.username}\"")
@ -147,17 +181,28 @@ class DigestAuth:
state.stale = True
raise UnauthorizedException("Invalid nonce")
def make_response_header(self, state: AuthState) -> str:
"""Composes and returns the WWW-Authenticate response header.
def __make_response_header(self, state: AuthState) -> str:
"""Composes and returns the ``WWW-Authenticate`` response header.
:param state: The authorization state.
:return: The WWW-Authenticate response header.
:return: The ``WWW-Authenticate`` response header.
"""
opaque: t.Optional[str] = None if not self.use_opaque else \
(state.opaque if state.opaque is not None
else self.serializer.dumps(random(), salt="opaque"))
def get_opaque() -> t.Optional[str]:
"""Returns the opaque value.
:return: The opaque value.
"""
if not self.use_opaque:
return None
if state.opaque is not None:
return state.opaque
return self.serializer.dumps(randbits(32), salt="opaque")
opaque: t.Optional[str] = get_opaque()
nonce: str = self.serializer.dumps(
random(), salt="nonce" if opaque is None else f"nonce-{opaque}")
randbits(32),
salt="nonce" if opaque is None else f"nonce-{opaque}")
header: str = f"Digest realm=\"{self.realm}\""
if len(self.domain) > 0:
@ -177,37 +222,117 @@ class DigestAuth:
def register_get_password(self, func: t.Callable[[str], t.Optional[str]])\
-> None:
"""Registers the callback to obtain the password hash.
"""The decorator to register the callback to obtain the password hash.
For example:
::
@auth.register_get_password
def get_password_hash(username: str) -> Optional[str]:
user = User.query.filter(User.username == username).first()
return None if user is None else user.password
:param func: The callback that given the username, returns the password
hash, or None if the user does not exist.
:return: None.
"""
self.__get_password_hash = func
class PasswordHashGetter(BasePasswordHashGetter):
"""The base password hash getter."""
@staticmethod
def __call__(username: str) -> t.Optional[str]:
"""Returns the password hash of a user.
:param username: The username.
:return: The password hash, or None if the user does not exist.
"""
return func(username)
self.__get_password_hash = PasswordHashGetter()
def register_get_user(self, func: t.Callable[[str], t.Optional[t.Any]])\
-> None:
"""Registers the callback to obtain the user.
"""The decorator to register the callback to obtain the user.
For example:
::
@auth.register_get_user
def get_user(username: str) -> Optional[User]:
return User.query.filter(User.username == username).first()
:param func: The callback that given the username, returns the user,
or None if the user does not exist.
:return: None.
"""
self.__get_user = func
class UserGetter(BaseUserGetter):
"""The user getter."""
@staticmethod
def __call__(username: str) -> t.Optional[t.Any]:
"""Returns a user.
:param username: The username.
:return: The user, or None if the user does not exist.
"""
return func(username)
self.__get_user = UserGetter()
def register_on_login(self, func: t.Callable[[t.Any], None]) -> None:
"""The decorator to register the callback to run when the user logs in.
For example:
::
@auth.register_on_login
def on_login(user: User) -> None:
user.visits = user.visits + 1
:param func: The callback given the logged-in user.
:return: None.
"""
class OnLogInCallback:
"""The callback when the user logs in."""
@staticmethod
def __call__(user: t.Any) -> None:
"""Runs the callback when the user logs in.
:param user: The logged-in user.
:return: None.
"""
func(user)
self.__on_login = OnLogInCallback()
def init_app(self, app: Flask) -> None:
"""Initializes the Flask application.
For example:
::
app: flask = Flask(__name__)
auth: DigestAuth = DigestAuth()
auth.realm = "My Admin"
auth.init_app(app)
:param app: The Flask application.
:return: None.
"""
app.digest_auth = self
self.app = app
try:
if hasattr(app, "login_manager"):
from flask_login import LoginManager, login_user
if not hasattr(app, "login_manager"):
raise AttributeError(
"Please run the Flask-Login init-app() first")
login_manager: LoginManager = getattr(app, "login_manager")
@login_manager.unauthorized_handler
@ -219,7 +344,7 @@ class DigestAuth:
response: Response = Response()
response.status = 401
response.headers["WWW-Authenticate"] \
= self.make_response_header(g.digest_auth_state)
= self.__make_response_header(g.digest_auth_state)
abort(response)
@login_manager.request_loader
@ -238,19 +363,43 @@ class DigestAuth:
if authorization.type != "digest":
raise UnauthorizedException(
"Not an HTTP digest authorization")
self.authenticate(g.digest_auth_state)
self.__authenticate(g.digest_auth_state)
user = login_manager.user_callback(
authorization.username)
login_user(user)
self.__on_login(user)
return user
except UnauthorizedException as e:
if str(e) != "":
app.logger.warning(str(e))
return None
def logout(self) -> None:
"""Logs out the user.
This actually causes the next authentication to fail, which forces
the browser to ask the user for the username and password again.
For example:
::
@app.post("/logout")
@auth.login_required
def logout():
auth.logout()
return redirect(request.form.get("next"))
:return: None.
"""
if "user" in session:
del session["user"]
try:
if hasattr(self.app, "login_manager"):
from flask_login import logout_user
logout_user()
except ModuleNotFoundError:
raise ModuleNotFoundError(
"init_app() is only for Flask-Login integration")
pass
session["digest_auth_logout"] = True
class AuthState:
@ -260,3 +409,52 @@ class AuthState:
"""Constructs the authorization state."""
self.opaque: t.Optional[str] = None
self.stale: t.Optional[bool] = None
class UnauthorizedException(Exception):
"""The exception thrown when the authentication is failed."""
pass
class BasePasswordHashGetter:
"""The base password hash getter."""
@staticmethod
def __call__(username: str) -> t.Optional[str]:
"""Returns the password hash of a user.
:param username: The username.
:return: The password hash, or None if the user does not exist.
:raise UnboundLocalError: When the password hash getter function is
not registered yet.
"""
raise UnboundLocalError("The function to return the password hash"
" was not registered yet.")
class BaseUserGetter:
"""The base user getter."""
@staticmethod
def __call__(username: str) -> t.Optional[t.Any]:
"""Returns a user.
:param username: The username.
:return: The user, or None if the user does not exist.
:raise UnboundLocalError: When the user getter function is not
registered yet.
"""
raise UnboundLocalError("The function to return the user"
" was not registered yet.")
class BaseOnLogInCallback:
"""The base callback when the user logs in."""
@staticmethod
def __call__(user: t.Any) -> None:
"""Runs the callback when the user logs in.
:param user: The logged-in user.
:return: None.
"""

View File

@ -1,25 +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 exception.
"""
class UnauthorizedException(Exception):
"""The exception thrown when the authentication is failed."""
pass

View File

@ -29,15 +29,62 @@ from flask_digest_auth.algo import calc_response, make_password_hash
class Client(WerkzeugClient):
"""The test client with HTTP digest authentication enabled."""
"""The test client with HTTP digest authentication enabled.
For unittest example:
::
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=("my_name", "my_pass"))
self.assertEqual(response.status_code, 200)
For pytest example:
::
@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 = self.client.get("/admin")
assert response.status_code == 401
response = self.client.get(
"/admin", digest_auth=("my_name", "my_pass"))
assert response.status_code == 200
"""
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 and password for the HTTP digest
authentication.
:param digest_auth: A tuple of the username and password for the HTTP
digest authentication.
:param kwargs: The keyword arguments.
:return: The response.
"""
@ -49,25 +96,24 @@ class Client(WerkzeugClient):
return response
if hasattr(g, "_login_user"):
delattr(g, "_login_user")
auth_data: Authorization = self.__class__.__make_authorization(
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,
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 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:
if "auth" in www_authenticate.qop:
if www_authenticate.qop is not None and "auth" in www_authenticate.qop:
qop = "auth"
cnonce: t.Optional[str] = None

View File

@ -20,10 +20,10 @@
"""
import typing as t
from secrets import token_urlsafe
from types import SimpleNamespace
from flask import Response, Flask, g
from flask import Response, Flask, g, redirect, request
from flask_testing import TestCase
from werkzeug.datastructures import WWWAuthenticate, Authorization
from flask_digest_auth import DigestAuth, make_password_hash, Client
@ -32,6 +32,21 @@ _USERNAME: str = "Mufasa"
_PASSWORD: str = "Circle Of Life"
class User:
"""A dummy user"""
def __init__(self, username: str, password: str):
"""Constructs a dummy user.
:param username: The username.
:param password: The clear-text password.
"""
self.username: str = username
self.password_hash: str = make_password_hash(
_REALM, username, password)
self.visits: int = 0
class AuthenticationTestCase(TestCase):
"""The test case for the HTTP digest authentication."""
@ -48,8 +63,9 @@ class AuthenticationTestCase(TestCase):
app.test_client_class = Client
auth: DigestAuth = DigestAuth(realm=_REALM)
user_db: t.Dict[str, str] \
= {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)}
auth.init_app(app)
self.user: User = User(_USERNAME, _PASSWORD)
user_db: t.Dict[str, User] = {_USERNAME: self.user}
@auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]:
@ -58,7 +74,8 @@ class AuthenticationTestCase(TestCase):
:param username: The username.
:return: The password hash, or None if the user does not exist.
"""
return user_db[username] if username in user_db else None
return user_db[username].password_hash if username in user_db \
else None
@auth.register_get_user
def get_user(username: str) -> t.Optional[t.Any]:
@ -67,27 +84,45 @@ class AuthenticationTestCase(TestCase):
:param username: The username.
:return: The user, or None if the user does not exist.
"""
return SimpleNamespace(username=username) if username in user_db \
else None
return user_db[username] if username in user_db else None
@app.get("/login-required-1/auth", endpoint="auth-1")
@auth.register_on_login
def on_login(user: User):
"""The callback when the user logs in.
:param user: The logged-in user.
:return: None.
"""
user.visits = user.visits + 1
@app.get("/admin-1/auth", endpoint="admin-1")
@auth.login_required
def login_required_1() -> str:
"""The first dummy view.
def admin_1() -> str:
"""The first administration section.
:return: The response.
"""
return f"Hello, {g.user.username}! #1"
@app.get("/login-required-2/auth", endpoint="auth-2")
@app.get("/admin-2/auth", endpoint="admin-2")
@auth.login_required
def login_required_2() -> str:
"""The second dummy view.
def admin_2() -> str:
"""The second administration section.
:return: The response.
"""
return f"Hello, {g.user.username}! #2"
@app.post("/logout", endpoint="logout")
@auth.login_required
def logout() -> redirect:
"""Logs out the user.
:return: The response.
"""
auth.logout()
return redirect(request.form.get("next"))
return app
def test_auth(self) -> None:
@ -95,14 +130,92 @@ class AuthenticationTestCase(TestCase):
:return: None.
"""
response: Response = self.client.get(self.app.url_for("auth-1"))
response: Response = self.client.get(self.app.url_for("admin-1"))
self.assertEqual(response.status_code, 401)
response = self.client.get(
self.app.url_for("auth-1"), digest_auth=(_USERNAME, _PASSWORD))
self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"),
f"Hello, {_USERNAME}! #1")
response: Response = self.client.get(self.app.url_for("auth-2"))
response: Response = self.client.get(self.app.url_for("admin-2"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"),
f"Hello, {_USERNAME}! #2")
self.assertEqual(self.user.visits, 1)
def test_stale_opaque(self) -> None:
"""Tests the stale and opaque value.
:return: None.
"""
admin_uri: str = self.app.url_for("admin-1")
response: Response
www_authenticate: WWWAuthenticate
auth_data: Authorization
response = super(Client, self.client).get(admin_uri)
self.assertEqual(response.status_code, 401)
www_authenticate = response.www_authenticate
self.assertEqual(www_authenticate.type, "digest")
self.assertEqual(www_authenticate.stale, None)
opaque: str = www_authenticate.opaque
www_authenticate.nonce = "bad"
auth_data = Client.make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD)
response = super(Client, self.client).get(admin_uri, auth=auth_data)
self.assertEqual(response.status_code, 401)
www_authenticate = response.www_authenticate
self.assertEqual(www_authenticate.stale, True)
self.assertEqual(www_authenticate.opaque, opaque)
auth_data = Client.make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2")
response = super(Client, self.client).get(admin_uri, auth=auth_data)
self.assertEqual(response.status_code, 401)
www_authenticate = response.www_authenticate
self.assertEqual(www_authenticate.stale, False)
self.assertEqual(www_authenticate.opaque, opaque)
auth_data = Client.make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD)
response = super(Client, self.client).get(admin_uri, auth=auth_data)
self.assertEqual(response.status_code, 200)
def test_logout(self) -> None:
"""Tests the logging out.
:return: None.
"""
admin_uri: str = self.app.url_for("admin-1")
logout_uri: str = self.app.url_for("logout")
response: Response
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri,
digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(logout_uri, data={"next": admin_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, admin_uri)
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri,
digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri,
digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.visits, 2)

View File

@ -21,8 +21,9 @@
import typing as t
from secrets import token_urlsafe
from flask import Response, Flask
from flask import Response, Flask, g, redirect, request
from flask_testing import TestCase
from werkzeug.datastructures import WWWAuthenticate, Authorization
from flask_digest_auth import DigestAuth, make_password_hash, Client
@ -32,8 +33,18 @@ _PASSWORD: str = "Circle Of Life"
class User:
def __init__(self, username: str):
"""A dummy user."""
def __init__(self, username: str, password: str):
"""Constructs a dummy user.
:param username: The username.
:param password: The clear-text password.
"""
self.username: str = username
self.password_hash: str = make_password_hash(
_REALM, username, password)
self.visits: int = 0
self.is_authenticated: bool = True
self.is_active: bool = True
self.is_anonymous: bool = False
@ -75,8 +86,8 @@ class FlaskLoginTestCase(TestCase):
auth: DigestAuth = DigestAuth(realm=_REALM)
auth.init_app(app)
user_db: t.Dict[str, str] \
= {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)}
self.user: User = User(_USERNAME, _PASSWORD)
user_db: t.Dict[str, User] = {_USERNAME: self.user}
@auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]:
@ -85,7 +96,17 @@ class FlaskLoginTestCase(TestCase):
:param username: The username.
:return: The password hash, or None if the user does not exist.
"""
return user_db[username] if username in user_db else None
return user_db[username].password_hash if username in user_db \
else None
@auth.register_on_login
def on_login(user: User):
"""The callback when the user logs in.
:param user: The logged-in user.
:return: None.
"""
user.visits = user.visits + 1
@login_manager.user_loader
def load_user(user_id: str) -> t.Optional[User]:
@ -94,25 +115,35 @@ class FlaskLoginTestCase(TestCase):
:param user_id: The username.
:return: The user, or None if the user does not exist.
"""
return User(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("/login-required-1/auth", endpoint="auth-1")
@app.get("/admin-1/auth", endpoint="admin-1")
@flask_login.login_required
def login_required_1() -> str:
"""The first dummy view.
def admin_1() -> str:
"""The first administration section.
:return: The response.
"""
return f"Hello, {flask_login.current_user.username}! #1"
return f"Hello, {flask_login.current_user.get_id()}! #1"
@app.get("/login-required-2/auth", endpoint="auth-2")
@app.get("/admin-2/auth", endpoint="admin-2")
@flask_login.login_required
def login_required_2() -> str:
"""The second dummy view.
def admin_2() -> str:
"""The second administration section.
:return: The response.
"""
return f"Hello, {flask_login.current_user.username}! #2"
return f"Hello, {flask_login.current_user.get_id()}! #2"
@app.post("/logout", endpoint="logout")
@flask_login.login_required
def logout() -> redirect:
"""Logs out the user.
:return: The response.
"""
auth.logout()
return redirect(request.form.get("next"))
return app
@ -122,16 +153,106 @@ class FlaskLoginTestCase(TestCase):
:return: None.
"""
if not self.has_flask_login:
self.skipTest("Skipped testing Flask-Login integration without it.")
self.skipTest("Skipped without Flask-Login.")
response: Response = self.client.get(self.app.url_for("auth-1"))
response: Response = self.client.get(self.app.url_for("admin-1"))
self.assertEqual(response.status_code, 401)
response = self.client.get(
self.app.url_for("auth-1"), digest_auth=(_USERNAME, _PASSWORD))
self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"),
f"Hello, {_USERNAME}! #1")
response: Response = self.client.get(self.app.url_for("auth-2"))
response: Response = self.client.get(self.app.url_for("admin-2"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data.decode("UTF-8"),
f"Hello, {_USERNAME}! #2")
self.assertEqual(self.user.visits, 1)
def test_stale_opaque(self) -> None:
"""Tests the stale and opaque value.
:return: None.
"""
if not self.has_flask_login:
self.skipTest("Skipped without Flask-Login.")
admin_uri: str = self.app.url_for("admin-1")
response: Response
www_authenticate: WWWAuthenticate
auth_data: Authorization
response = super(Client, self.client).get(admin_uri)
self.assertEqual(response.status_code, 401)
www_authenticate = response.www_authenticate
self.assertEqual(www_authenticate.type, "digest")
self.assertEqual(www_authenticate.stale, None)
opaque: str = www_authenticate.opaque
if hasattr(g, "_login_user"):
delattr(g, "_login_user")
www_authenticate.nonce = "bad"
auth_data = Client.make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD)
response = super(Client, self.client).get(admin_uri, auth=auth_data)
self.assertEqual(response.status_code, 401)
www_authenticate = response.www_authenticate
self.assertEqual(www_authenticate.stale, True)
self.assertEqual(www_authenticate.opaque, opaque)
if hasattr(g, "_login_user"):
delattr(g, "_login_user")
auth_data = Client.make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD + "2")
response = super(Client, self.client).get(admin_uri, auth=auth_data)
self.assertEqual(response.status_code, 401)
www_authenticate = response.www_authenticate
self.assertEqual(www_authenticate.stale, False)
self.assertEqual(www_authenticate.opaque, opaque)
if hasattr(g, "_login_user"):
delattr(g, "_login_user")
auth_data = Client.make_authorization(
www_authenticate, admin_uri, _USERNAME, _PASSWORD)
response = super(Client, self.client).get(admin_uri, auth=auth_data)
self.assertEqual(response.status_code, 200)
def test_logout(self) -> None:
"""Tests the logging out.
:return: None.
"""
if not self.has_flask_login:
self.skipTest("Skipped without Flask-Login.")
admin_uri: str = self.app.url_for("admin-1")
logout_uri: str = self.app.url_for("logout")
response: Response
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri,
digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(logout_uri, data={"next": admin_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, admin_uri)
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri,
digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 401)
response = self.client.get(admin_uri,
digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.client.get(admin_uri)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.visits, 2)