Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

27 changed files with 666 additions and 1673 deletions

9
.gitignore vendored
View File

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

View File

@ -1,41 +0,0 @@
# 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,7 +15,4 @@
# 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.
recursive-include docs * include tests/*
recursive-exclude docs/build *
recursive-include tests *
recursive-exclude tests *.pyc

View File

@ -6,66 +6,250 @@ Flask HTTP Digest Authentication
Description Description
=========== ===========
*Flask-DigestAuth* 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 for Flask_ applications. It authenticates the user for the protected
views. 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.
HTTP Digest Authentication is specified in `RFC 2617`_. .. _Flask: https://flask.palletsprojects.com
.. _Flask-Login: https://flask-login.readthedocs.io
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-DigestAuth 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 Installation
============ ============
You can install Flask-DigestAuth with ``pip``: It's suggested that you install with ``pip``:
:: ::
pip install Flask-DigestAuth pip install flask-digest-auth
You may also install the latest source from the You may also install from the latest source from the
`Flask-DigestAuth GitHub repository`_. `flask-digest-auth Github repository`_.
:: ::
pip install git+https://github.com/imacat/flask-digestauth.git git clone git@github.com:imacat/flask-digest-auth.git
cd flask-digest-auth
pip install .
.. _flask-digest-auth Github repository: https://github.com/imacat/flask-digest-auth
Documentation Flask-Digest-Auth Alone
=======================
Flask-Digest-Auth can authenticate the users alone.
Example for Simple Applications with Flask-Digest-Auth Alone
------------------------------------------------------------
In your ``my_app.py``:
::
from flask import Flask
from flask_digest_auth import DigestAuth
app: flask = Flask(__name__)
... (Configure the Flask application) ...
auth: DigestAuth = DigestAuth(realm="Admin")
@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():
... (Process the view) ...
Example for 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.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
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/")
@auth.login_required
def admin():
... (Process the view) ...
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.
Example for Simple Applications with Flask-Login Integration
------------------------------------------------------------
In your ``my_app.py``:
::
from flask import Flask
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.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")
@login_manager.login_required
def admin():
... (Process the view) ...
Example for 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
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: DigestAuth = DigestAuth(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
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/")
@flask_login.login_required
def admin():
... (Process the view) ...
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.
Writing Tests
============= =============
Refer to the `documentation on Read the Docs`_. You can write tests with our test client that handles HTTP Digest
Authentication. Example for a unittest testcase:
::
Change Log from flask_digest_auth import Client
========== from flask_testing import TestCase
from my_app import create_app
Refer to the `change log`_. 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)
Copyright Copyright
========= =========
Copyright (c) 2022-2023 imacat. Copyright (c) 2022 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.
@ -79,18 +263,9 @@ Copyright
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.
Authors Authors
======= =======
| imacat | imacat
| imacat@mail.imacat.idv.tw | imacat@mail.imacat.idv.tw
| 2022/11/23 | 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-DigestAuth GitHub repository: https://github.com/imacat/flask-digestauth
.. _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,20 +0,0 @@
# 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)

View File

@ -1,35 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
%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.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@ -1 +0,0 @@
sphinx_rtd_theme

View File

@ -1,153 +0,0 @@
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

@ -1,33 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import sys
sys.path.insert(0, os.path.abspath('../../src/'))
import flask_digest_auth
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Flask-DigestAuth'
copyright = '2022-2023, imacat'
author = 'imacat'
release = flask_digest_auth.VERSION
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ["sphinx.ext.autodoc"]
templates_path = ['_templates']
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']

View File

@ -1,201 +0,0 @@
Examples
========
.. _example-alone-simple:
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()
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-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.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()
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.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.

View File

@ -1,29 +0,0 @@
flask\_digest\_auth package
===========================
Submodules
----------
flask\_digest\_auth.algo module
-------------------------------
.. automodule:: flask_digest_auth.algo
:members:
:undoc-members:
:show-inheritance:
flask\_digest\_auth.auth module
-------------------------------
.. automodule:: flask_digest_auth.auth
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: flask_digest_auth
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,35 +0,0 @@
.. Flask-DigestAuth documentation master file, created by
sphinx-quickstart on Wed Dec 7 09:40:48 2022.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Flask-DigestAuth's documentation!
============================================
*Flask-DigestAuth* 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
changelog
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

View File

@ -1,144 +0,0 @@
Introduction
============
*Flask-DigestAuth* 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-DigestAuth 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-DigestAuth with ``pip``:
::
pip install Flask-DigestAuth
You may also install the latest source from the
`Flask-DigestAuth GitHub repository`_.
::
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
--------------------
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 :func:`flask_digest_auth.algo.make_password_hash`.
Flask-DigestAuth Alone
----------------------
Flask-DigestAuth can authenticate the users alone.
See :ref:`example-alone-simple` and :ref:`example-alone-large`.
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.
See :ref:`example-flask-login-simple` and
:ref:`example-flask-login-large`.
The views only depend on Flask-Login, but not the Flask-DigestAuth.
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
See :meth:`flask_digest_auth.auth.DigestAuth.register_on_login`.
Log Out
-------
Flask-DigestAuth supports log out. The user will be prompted for the
new username and password.
See :meth:`flask_digest_auth.auth.DigestAuth.logout`.
.. _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-DigestAuth GitHub repository: https://github.com/imacat/flask-digestauth

View File

@ -1,7 +0,0 @@
src
===
.. toctree::
:maxdepth: 4
flask_digest_auth

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-2024 imacat. # Copyright (c) 2022 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,42 +15,6 @@
# 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"}

53
setup.cfg Normal file
View File

@ -0,0 +1,53 @@
# The Flask HTTP Digest Authentication Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/23
# 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.
[metadata]
name = flask-digest-auth
version = 0.1.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-digest-auth
project_urls =
Bug Tracker = https://github.com/imacat/flask-digest-auth/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.10
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-2023 imacat. # Copyright (c) 2022 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,6 +20,4 @@
""" """
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,19 +20,14 @@
""" """
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
from flask_digest_auth.exception import UnauthorizedException
def make_password_hash(realm: str, username: str, password: str) -> str: def make_password_hash(realm: str, username: str, password: str) -> str:
"""Calculates the password hash for the HTTP digest authentication. """Calculates the password hash for the HTTP digest authentication.
Use this function to set the password for the user.
:Example:
::
user.password = make_password_hash(realm, username, password)
:param realm: The realm. :param realm: The realm.
:param username: The username. :param username: The username.
@ -44,63 +39,77 @@ 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: Optional[Literal["auth", "auth-int"]] = None, nonce: str, qop: t.Optional[t.Literal["auth", "auth-int"]] = None,
algorithm: Optional[Literal["MD5", "MD5-sess"]] = "MD5-sess", algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = "MD5-sess",
cnonce: Optional[str] = None, nc: Optional[str] = None, cnonce: t.Optional[str] = None, nc: t.Optional[str] = None,
body: Optional[bytes] = None) -> str: body: t.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.
:param uri: The request URI. :param uri: The request URI.
:param password_hash: The password hash for the HTTP digest authentication. :param password_hash: The password hash for the HTTP digest authentication.
:param nonce: The nonce. :param nonce: The nonce.
:param qop: The quality of protection, either ``auth`` or ``auth-int``. :param qop: the quality of protection.
:param algorithm: The algorithm, either ``MD5`` or ``MD5-sess``. :param algorithm: The algorithm, either "MD5" or "MD5-sess".
:param cnonce: The client nonce, which must exists when qop exists or :param cnonce: The client nonce, which must exists when qop exists or
algorithm is ``MD5-sess``. algorithm="MD5-sess".
:param nc: The request counter, which must exists when qop exists. :param nc: The request counter, which must exists when qop exists.
:param body: The request body, which must exists when qop is ``auth-int``. :param body: The request body, which must exists when qop="auth-int".
:return: The response value. :return: The response value.
:raise AssertionError: When cnonce is missing with algorithm is :raise UnauthorizedException: When the cnonce is missing with the MD5-sess
``MD5-sess``, when body is missing with qop is ``auth-int``, or when algorithm, when the body is missing with the auth-int qop, or when the
cnonce or nc is missing with qop exits. cnonce or nc is missing with the auth or auth-int qop.
""" """
def calc_ha1() -> str: def calc_ha1() -> str:
"""Calculates and returns the first hash. """Calculates and returns the first hash.
:return: The first hash. :return: The first hash.
:raise AssertionError: When cnonce is missing with :raise UnauthorizedException: When the cnonce is missing with the MD5-sess
algorithm is ``MD5-sess``. algorithm.
""" """
if algorithm is None or algorithm == "MD5":
return password_hash
if algorithm == "MD5-sess": if algorithm == "MD5-sess":
assert cnonce is not None,\ if cnonce is None:
f"Missing \"cnonce\" with algorithm=\"{algorithm}\"" raise UnauthorizedException(
f"Missing \"cnonce\" with algorithm=\"{algorithm}\"")
return md5(f"{password_hash}:{nonce}:{cnonce}".encode("utf8")) \ return md5(f"{password_hash}:{nonce}:{cnonce}".encode("utf8")) \
.hexdigest() .hexdigest()
# algorithm is None or algorithm == "MD5" raise UnauthorizedException(
return password_hash f"Unsupported algorithm=\"{algorithm}\"")
def calc_ha2() -> str: def calc_ha2() -> str:
"""Calculates the second hash. """Calculates the second hash.
:return: The second hash. :return: The second hash.
:raise AssertionError: When body is missing with qop is ``auth-int``. :raise UnauthorizedException: When the body is missing with
qop="auth-int".
""" """
if qop is None or qop == "auth":
return md5(f"{method}:{uri}".encode("utf8")).hexdigest()
if qop == "auth-int": if qop == "auth-int":
assert body is not None, f"Missing \"body\" with qop=\"{qop}\"" if body is None:
raise UnauthorizedException(
f"Missing \"body\" with qop=\"{qop}\"")
return md5( return md5(
f"{method}:{uri}:{md5(body).hexdigest()}".encode("utf8")) \ f"{method}:{uri}:{md5(body).hexdigest()}".encode("utf8")) \
.hexdigest() .hexdigest()
# qop is None or qop == "auth" raise UnauthorizedException(f"Unsupported qop=\"{qop}\"")
return md5(f"{method}:{uri}".encode("utf8")).hexdigest()
ha1: str = calc_ha1() ha1: str = calc_ha1()
ha2: str = calc_ha2() 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 qop == "auth" or qop == "auth-int":
assert cnonce is not None, f"Missing \"cnonce\" with the qop=\"{qop}\"" if cnonce is None:
assert nc is not None, f"Missing \"nc\" with the qop=\"{qop}\"" raise UnauthorizedException(
f"Missing \"cnonce\" with the qop=\"{qop}\"")
if nc is None:
raise UnauthorizedException(
f"Missing \"nc\" with the qop=\"{qop}\"")
return md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode("utf8"))\ return md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode("utf8"))\
.hexdigest() .hexdigest()
# qop is None if cnonce is None:
return md5(f"{ha1}:{nonce}:{ha2}".encode("utf8")).hexdigest() raise UnauthorizedException(
f"Unsupported qop=\"{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-2023 imacat. # Copyright (c) 2022 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.
@ -16,111 +16,57 @@
# limitations under the License. # limitations under the License.
"""The HTTP Digest Authentication. """The HTTP Digest Authentication.
See `RFC 2617`_ HTTP Authentication: Basic and Digest Access Authentication See RFC 2617 HTTP Authentication: Basic and Digest Access Authentication
.. _RFC 2617: https://www.rfc-editor.org/rfc/rfc2617
""" """
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 random import random
from typing import Any, Optional, Literal, Callable, List from secrets import token_urlsafe
from flask import g, request, Response, session, abort, Flask, Request, \ from flask import g, request, Response, session, abort, Flask, Request
current_app
from itsdangerous import URLSafeTimedSerializer, BadData from itsdangerous import URLSafeTimedSerializer, BadData
from werkzeug.datastructures import Authorization from werkzeug.datastructures import Authorization
from flask_digest_auth.algo import calc_response from flask_digest_auth.algo import calc_response
from flask_digest_auth.exception import UnauthorizedException
class DigestAuth: class DigestAuth:
"""The HTTP digest authentication.""" """The HTTP digest authentication."""
def __init__(self, realm: Optional[str] = None): def __init__(self, realm: t.Optional[str] = None):
"""Constructs the HTTP digest authentication. """Constructs the HTTP digest authentication.
:param realm: The realm. :param realm: The realm.
""" """
self.__serializer: URLSafeTimedSerializer \ self.secret_key: str = token_urlsafe(32)
= URLSafeTimedSerializer(token_urlsafe(32)) self.serializer: URLSafeTimedSerializer \
"""The serializer to generate and validate the nonce and opaque.""" = URLSafeTimedSerializer(self.secret_key)
self.realm: str = "Login Required" if realm is None else realm self.realm: str = "" if realm is None else realm
"""The realm. Default is "Login Required".""" self.algorithm: t.Optional[str] = None
self.algorithm: Optional[Literal["MD5", "MD5-sess"]] = None
"""The algorithm, either None, ``MD5``, or ``MD5-sess``. Default is
None."""
self.use_opaque: bool = True self.use_opaque: bool = True
"""Whether to use an opaque. Default is True.""" self.domain: t.List[str] = []
self.__domain: List[str] = [] self.qop: t.List[str] = ["auth", "auth-int"]
"""A list of directories that this username and password applies to. self.__get_password_hash: t.Callable[[str], t.Optional[str]] \
Default is empty.""" = lambda x: None
self.__qop: List[Literal["auth", "auth-int"]] = ["auth", "auth-int"] self.__get_user: t.Callable[[str], t.Optional] = lambda x: None
"""A list of supported quality of protection supported, either
``qop``, ``auth-int``, both, or empty. Default is both."""
self.__get_password_hash: BasePasswordHashGetter \
= BasePasswordHashGetter()
"""The callback to return the password hash."""
self.__get_user: BaseUserGetter = BaseUserGetter()
"""The callback to return the user."""
self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback()
"""The callback to run when the user logs in."""
def login_required(self, view) -> Callable: def login_required(self, view) -> t.Callable:
"""The view decorator for the HTTP digest authentication. """The view decorator for HTTP digest authentication.
:Example: :param view:
::
@app.get("/admin")
@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. :return: The login-protected view.
""" """
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() -> 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: Optional[Any] = self.__get_user(session["user"])
if user is None:
del session["user"]
raise NoLogInException
return user
def auth_user(state: AuthState) -> 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) @wraps(view)
def login_required_view(*args, **kwargs) -> Any: def login_required_view(*args, **kwargs) -> t.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.
@ -128,50 +74,57 @@ class DigestAuth:
:return: The response. :return: The response.
""" """
try: try:
g.user = get_logged_in_user() 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
return view(*args, **kwargs) return view(*args, **kwargs)
except NoLogInException: except NoLogInException:
pass state: AuthState = AuthState()
authorization: Authorization = request.authorization
state: AuthState = AuthState() try:
try: if authorization is None:
g.user = auth_user(state) raise UnauthorizedException
self.__on_login(g.user) if authorization.type != "digest":
return view(*args, **kwargs) raise UnauthorizedException(
except UnauthorizedException as e: "Not an HTTP digest authorization")
if len(e.args) > 0: self.authenticate(state)
current_app.logger.warning(e.args[0]) session["user"] = authorization.username
response: Response = Response() g.user = self.__get_user(authorization.username)
response.status = 401 return view(*args, **kwargs)
response.headers["WWW-Authenticate"] \ except UnauthorizedException as e:
= self.__make_response_header(state) if len(e.args) > 0:
abort(response) sys.stderr.write(e.args[0] + "\n")
response: Response = Response()
response.status = 401
response.headers["WWW-Authenticate"] \
= self.make_response_header(state)
abort(response)
return login_required_view return login_required_view
def __authenticate(self, state: AuthState) -> None: def authenticate(self, state: AuthState) -> None:
"""Authenticate a user. """Authenticate a user.
:param state: The authorization state. :param state: The authorization state.
:return: None. :return: None.
:raise UnauthorizedException: When the authentication failed. :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 authorization: Authorization = request.authorization
if self.use_opaque: if self.use_opaque:
if authorization.opaque is None: if authorization.opaque is None:
raise UnauthorizedException( raise UnauthorizedException(
"Missing \"opaque\" in the Authorization header") "Missing \"opaque\" in the Authorization header")
try: try:
self.__serializer.loads( self.serializer.loads(
authorization.opaque, salt="opaque", max_age=1800) authorization.opaque, salt="opaque", max_age=1800)
except BadData: except BadData:
raise UnauthorizedException("Invalid opaque") raise UnauthorizedException("Invalid opaque")
state.opaque = authorization.opaque state.opaque = authorization.opaque
password_hash: Optional[str] \ password_hash: t.Optional[str] = self.__get_password_hash(
= self.__get_password_hash(authorization.username) authorization.username)
if password_hash is None: if password_hash is None:
raise UnauthorizedException( raise UnauthorizedException(
f"No such user \"{authorization.username}\"") f"No such user \"{authorization.username}\"")
@ -186,7 +139,7 @@ class DigestAuth:
state.stale = False state.stale = False
raise UnauthorizedException("Incorrect response value") raise UnauthorizedException("Incorrect response value")
try: try:
self.__serializer.loads( self.serializer.loads(
authorization.nonce, authorization.nonce,
salt="nonce" if authorization.opaque is None salt="nonce" if authorization.opaque is None
else f"nonce-{authorization.opaque}") else f"nonce-{authorization.opaque}")
@ -194,307 +147,116 @@ class DigestAuth:
state.stale = True state.stale = True
raise UnauthorizedException("Invalid nonce") raise UnauthorizedException("Invalid nonce")
def __make_response_header(self, state: AuthState) -> str: def make_response_header(self, state: AuthState) -> str:
"""Composes and returns the ``WWW-Authenticate`` response header. """Composes and returns the WWW-Authenticate response header.
:param state: The authorization state. :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 \
def get_opaque() -> Optional[str]: (state.opaque if state.opaque is not None
"""Returns the opaque value. else self.serializer.dumps(random(), salt="opaque"))
nonce: str = self.serializer.dumps(
:return: The opaque value. random(), salt="nonce" if opaque is None else f"nonce-{opaque}")
"""
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: Optional[str] = get_opaque()
nonce: str = self.__serializer.dumps(
randbits(32),
salt="nonce" if opaque is None else f"nonce-{opaque}")
header: str = f"Digest realm=\"{self.realm}\"" header: str = f"Digest realm=\"{self.realm}\""
if len(self.__domain) > 0: if len(self.domain) > 0:
domain_list: str = ",".join(self.__domain) domain_list: str = ",".join(self.domain)
header += f", domain=\"{domain_list}\"" header += f", domain=\"{domain_list}\""
header += f", nonce=\"{nonce}\"" header += f", nonce=\"{nonce}\""
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 += ", stale=TRUE" if state.stale else ", stale=FALSE" header += f", stale=TRUE" if state.stale else f", 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:
qop_list: str = ",".join(self.__qop) qop_list: str = ",".join(self.qop)
header += f", qop=\"{qop_list}\"" header += f", qop=\"{qop_list}\""
return header return header
def register_get_password(self, func: Callable[[str], Optional[str]]) \ def register_get_password(self, func: t.Callable[[str], t.Optional[str]])\
-> None: -> None:
"""The decorator to register the callback to obtain the password hash. """Registers the callback to obtain the password hash.
: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 :param func: The callback that given the username, returns the password
hash, or None if the user does not exist. hash, or None if the user does not exist.
:return: None. :return: None.
""" """
self.__get_password_hash = func
class PasswordHashGetter(BasePasswordHashGetter): def register_get_user(self, func: t.Callable[[str], t.Optional[t.Any]])\
"""The base password hash getter.""" -> None:
"""Registers the callback to obtain the user.
@staticmethod
def __call__(username: str) -> 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: Callable[[str], Optional[Any]]) -> None:
"""The decorator to register the callback to obtain the user.
: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, :param func: The callback that given the username, returns the user,
or None if the user does not exist. or None if the user does not exist.
:return: None. :return: None.
""" """
self.__get_user = func
class UserGetter(BaseUserGetter):
"""The user getter."""
@staticmethod
def __call__(username: str) -> Optional[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: Callable[[Any], None]) -> None:
"""The decorator to register the callback to run when the user logs in.
: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: 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: def init_app(self, app: Flask) -> None:
"""Initializes the Flask application. The DigestAuth instance will """Initializes the Flask application.
be stored in ``app.extensions["digest_auth"]``.
:Example:
::
app: flask = Flask(__name__)
auth: DigestAuth = DigestAuth()
auth.init_app(app)
:param app: The Flask application. :param app: The Flask application.
:return: None. :return: None.
""" """
app.extensions["digest_auth"] = self
if "DIGEST_AUTH_REALM" in app.config:
self.realm = app.config["DIGEST_AUTH_REALM"]
if hasattr(app, "login_manager"):
self.__init_login_manager(app)
def __init_login_manager(self, app: Flask) -> None:
"""Initializes the Flask-Login login manager.
:param app: The Flask application.
:return: None.
"""
from flask_login import LoginManager, login_user
login_manager: LoginManager = getattr(app, "login_manager")
@login_manager.unauthorized_handler
def unauthorized() -> None:
"""Handles when the user is unauthorized.
:return: None.
"""
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)
@login_manager.request_loader
def load_user_from_request(req: Request) -> Optional[Any]:
"""Loads the user from the request header.
:param req: The request.
:return: The authenticated user, or None if the
authentication fails
"""
request._digest_auth_state = AuthState()
authorization: Authorization = req.authorization
try:
if authorization is None:
raise UnauthorizedException
if authorization.type != "digest":
raise UnauthorizedException(
"Not an HTTP digest authorization")
self.__authenticate(request._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.
: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: try:
if hasattr(current_app, "login_manager"): from flask_login import LoginManager, login_user
from flask_login import logout_user
logout_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
def unauthorized() -> None:
"""Handles when the user is unauthorized.
:return: None.
"""
response: Response = Response()
response.status = 401
response.headers["WWW-Authenticate"] \
= self.make_response_header(g.digest_auth_state)
abort(response)
@login_manager.request_loader
def load_user_from_request(req: Request) -> t.Optional[t.Any]:
"""Loads the user from the request header.
:param req: The request.
:return: The authenticated user, or None if the
authentication fails
"""
g.digest_auth_state = AuthState()
authorization: Authorization = req.authorization
try:
if authorization is None:
raise UnauthorizedException
if authorization.type != "digest":
raise UnauthorizedException(
"Not an HTTP digest authorization")
self.authenticate(g.digest_auth_state)
user = login_manager.user_callback(
authorization.username)
login_user(user)
return user
except UnauthorizedException as e:
if str(e) != "":
app.logger.warning(str(e))
return None
except ModuleNotFoundError: except ModuleNotFoundError:
pass raise ModuleNotFoundError(
session["digest_auth_logout"] = True "init_app() is only for Flask-Login integration")
class AuthState: class AuthState:
"""The authentication state. It keeps the status in the earlier """The authorization state."""
authentication stage, so that the latter response stage knows how to
response.
"""
def __init__(self): def __init__(self):
"""Constructs the authorization state.""" """Constructs the authorization state."""
self.opaque: Optional[str] = None self.opaque: t.Optional[str] = None
"""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."""
class UnauthorizedException(Exception):
"""The exception thrown when the authentication fails."""
class BasePasswordHashGetter:
"""The base callback that given the username, returns the password hash,
or None if the user does not exist. The default is to raise an
:class:`UnboundLocalError` if the callback is not registered yet.
See :meth:`flask_digest_auth.auth.DigestAuth.register_get_password`
"""
@staticmethod
def __call__(username: str) -> 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 callback that given the username, returns the user, or None if
the user does not exist. The default is to raise an
:class:`UnboundLocalError` if the callback is not registered yet.
See :meth:`flask_digest_auth.auth.DigestAuth.register_get_user`
"""
@staticmethod
def __call__(username: str) -> Optional[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 to run when the user logs in, given the logged-in
user. The default does nothing.
See :meth:`flask_digest_auth.auth.DigestAuth.register_on_login`
"""
@staticmethod
def __call__(user: Any) -> None:
"""Runs the callback when the user logs in.
:param user: The logged-in user.
:return: None.
"""

View File

@ -0,0 +1,25 @@
# 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

@ -0,0 +1,103 @@
# 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."""
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 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:
if "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: Optional[Literal["auth", "auth-int"]] = "auth" qop: t.Optional[t.Literal["auth", "auth-int"]] = "auth"
algorithm: Optional[Literal["MD5", "MD5-sess"]] = None algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = None
cnonce: Optional[str] = "0a4f113b" cnonce: t.Optional[str] = "0a4f113b"
nc: Optional[str] = "00000001" nc: t.Optional[str] = "00000001"
body: Optional[bytes] = None body: t.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-2023 imacat. # Copyright (c) 2022 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,213 +18,91 @@
"""The test case for the HTTP digest authentication. """The test case for the HTTP digest authentication.
""" """
import logging import typing as t
import unittest
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Any, Optional, Dict from types import SimpleNamespace
import httpx from flask import Response, Flask, g
from flask import Flask, g, redirect, request from flask_testing import TestCase
from werkzeug.datastructures import WWWAuthenticate
from flask_digest_auth import DigestAuth, make_password_hash from flask_digest_auth import DigestAuth, make_password_hash, Client
from testlib import REALM, USERNAME, PASSWORD, ADMIN_1_URI, ADMIN_2_URI, \
LOGOUT_URI, make_authorization _REALM: str = "testrealm@host.com"
_USERNAME: str = "Mufasa"
_PASSWORD: str = "Circle Of Life"
class User: class AuthenticationTestCase(TestCase):
"""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
"""The username."""
self.password_hash: str = make_password_hash(REALM, username, password)
"""The password hash."""
self.visits: int = 0
"""The number of visits."""
class AuthenticationTestCase(unittest.TestCase):
"""The test case for the HTTP digest authentication.""" """The test case for the HTTP digest authentication."""
def setUp(self) -> None: def create_app(self):
"""Sets up the test. """Creates the Flask application.
This is run once per test.
:return: None. :return: The Flask application.
""" """
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),
"DIGEST_AUTH_REALM": REALM, "TESTING": True
}) })
self.__client: httpx.Client = httpx.Client( app.test_client_class = Client
app=app, base_url="https://testserver")
"""The testing client."""
auth: DigestAuth = DigestAuth() auth: DigestAuth = DigestAuth(realm=_REALM)
auth.init_app(app) user_db: t.Dict[str, str] \
self.__user: User = User(USERNAME, PASSWORD) = {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)}
"""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) -> Optional[str]: def get_password_hash(username: str) -> t.Optional[str]:
"""Returns the password hash of a user. """Returns the password hash of a user.
:param username: The username. :param username: The username.
:return: The password hash, or None if the user does not exist. :return: The password hash, or None if the user does not exist.
""" """
return user_db[username].password_hash if username in user_db \ return user_db[username] if username in user_db else None
else None
@auth.register_get_user @auth.register_get_user
def get_user(username: str) -> Optional[Any]: def get_user(username: str) -> t.Optional[t.Any]:
"""Returns a user. """Returns a user.
:param username: The username. :param username: The username.
:return: The user, or None if the user does not exist. :return: The user, or None if the user does not exist.
""" """
return user_db[username] if username in user_db else None return SimpleNamespace(username=username) if username in user_db \
else None
@auth.register_on_login @app.get("/login-required-1/auth", endpoint="auth-1")
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_URI, endpoint="admin-1")
@auth.login_required @auth.login_required
def admin_1() -> str: def login_required_1() -> str:
"""The first administration section. """The first dummy view.
:return: The response. :return: The response.
""" """
return f"Hello, {g.user.username}! #1" return f"Hello, {g.user.username}! #1"
@app.get(ADMIN_2_URI, endpoint="admin-2") @app.get("/login-required-2/auth", endpoint="auth-2")
@auth.login_required @auth.login_required
def admin_2() -> str: def login_required_2() -> str:
"""The second administration section. """The second dummy view.
:return: The response. :return: The response.
""" """
return f"Hello, {g.user.username}! #2" return f"Hello, {g.user.username}! #2"
@app.post(LOGOUT_URI, endpoint="logout") return app
@auth.login_required
def logout() -> redirect:
"""Logs out the user.
:return: The response.
"""
auth.logout()
return redirect(request.form.get("next"))
def test_auth(self) -> None: def test_auth(self) -> None:
"""Tests the authentication. """Tests the authentication.
:return: None. :return: None.
""" """
response: httpx.Response response: Response = self.client.get(self.app.url_for("auth-1"))
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI, response = self.client.get(
auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.app.url_for("auth-1"), digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.text, f"Hello, {USERNAME}! #1") self.assertEqual(response.data.decode("UTF-8"),
response = self.__client.get(ADMIN_2_URI) f"Hello, {_USERNAME}! #1")
response: Response = self.client.get(self.app.url_for("auth-2"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.text, f"Hello, {USERNAME}! #2") self.assertEqual(response.data.decode("UTF-8"),
self.assertEqual(self.__user.visits, 1) f"Hello, {_USERNAME}! #2")
def test_stale_opaque(self) -> None:
"""Tests the stale and opaque value.
:return: None.
"""
response: httpx.Response
www_authenticate: WWWAuthenticate
auth_header: str
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401)
www_authenticate = WWWAuthenticate.from_header(
response.headers["WWW-Authenticate"])
self.assertEqual(www_authenticate.type, "digest")
self.assertIsNone(www_authenticate.get("stale"))
opaque: str = www_authenticate.opaque
www_authenticate.nonce = "bad"
auth_header = make_authorization(
www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 401)
www_authenticate = WWWAuthenticate.from_header(
response.headers["WWW-Authenticate"])
self.assertEqual(www_authenticate.get("stale"), "TRUE")
self.assertEqual(www_authenticate.opaque, opaque)
auth_header = make_authorization(
www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD + "2")
response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 401)
www_authenticate = WWWAuthenticate.from_header(
response.headers["WWW-Authenticate"])
self.assertEqual(www_authenticate.get("stale"), "FALSE")
self.assertEqual(www_authenticate.opaque, opaque)
auth_header = make_authorization(
www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 200)
def test_logout(self) -> None:
"""Tests the logging out.
:return: None.
"""
logout_uri: str = LOGOUT_URI
response: httpx.Response
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 200)
response = self.__client.post(logout_uri, data={"next": ADMIN_1_URI})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], ADMIN_1_URI)
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 200)
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-2023 imacat. # Copyright (c) 2022 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,42 +18,25 @@
"""The test case for the Flask-Login integration. """The test case for the Flask-Login integration.
""" """
import logging import typing as t
import unittest
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Optional, Dict
import httpx from flask import Response, Flask
from flask import Flask, g, redirect, request from flask_testing import TestCase
from werkzeug.datastructures import WWWAuthenticate
from flask_digest_auth import DigestAuth, make_password_hash from flask_digest_auth import DigestAuth, make_password_hash, Client
from testlib import REALM, USERNAME, PASSWORD, ADMIN_1_URI, ADMIN_2_URI, \
LOGOUT_URI, make_authorization
SKIPPED_NO_FLASK_LOGIN: str = "Skipped without Flask-Login." _REALM: str = "testrealm@host.com"
"""The message that a test is skipped when Flask-Login is not installed.""" _USERNAME: str = "Mufasa"
_PASSWORD: str = "Circle Of Life"
class User: class User:
"""A dummy user.""" def __init__(self, username: str):
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.username: str = username
"""The username.""" self.is_authenticated: bool = True
self.password_hash: str = make_password_hash(REALM, username, password)
"""The password hash."""
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.
@ -63,257 +46,92 @@ class User:
""" """
return self.username return self.username
@property
def is_authenticated(self) -> bool:
"""Returns whether the user is authenticated.
This is required by Flask-Login.
This should return self.is_active.
:return: True if the user is active, or False otherwise. class FlaskLoginTestCase(TestCase):
"""
return self.is_active
class FlaskLoginTestCase(unittest.TestCase):
"""The test case with the Flask-Login integration.""" """The test case with the Flask-Login integration."""
def setUp(self) -> None: def create_app(self) -> Flask:
"""Sets up the test. """Creates the Flask application.
This is run once per test.
:return: None. :return: The Flask application.
""" """
logging.getLogger("test_flask_login").addHandler(logging.NullHandler()) app: Flask = Flask(__name__)
self.app: Flask = Flask(__name__) app.config.from_mapping({
self.app.config.from_mapping({
"TESTING": True,
"SECRET_KEY": token_urlsafe(32), "SECRET_KEY": token_urlsafe(32),
"DIGEST_AUTH_REALM": REALM, "TESTING": True
}) })
self.__client: httpx.Client = httpx.Client( app.test_client_class = 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 return app
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(self.app) login_manager.init_app(app)
auth: DigestAuth = DigestAuth() auth: DigestAuth = DigestAuth(realm=_REALM)
auth.init_app(self.app) auth.init_app(app)
self.__user: User = User(USERNAME, PASSWORD) user_db: t.Dict[str, str] \
"""The user account.""" = {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)}
user_db: Dict[str, User] = {USERNAME: self.__user}
@auth.register_get_password @auth.register_get_password
def get_password_hash(username: str) -> Optional[str]: def get_password_hash(username: str) -> t.Optional[str]:
"""Returns the password hash of a user. """Returns the password hash of a user.
:param username: The username. :param username: The username.
:return: The password hash, or None if the user does not exist. :return: The password hash, or None if the user does not exist.
""" """
return user_db[username].password_hash if username in user_db \ return user_db[username] if username in user_db else None
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 @login_manager.user_loader
def load_user(user_id: str) -> Optional[User]: def load_user(user_id: str) -> t.Optional[User]:
"""Loads a user. """Loads a user.
:param user_id: The username. :param user_id: The username.
:return: The user, or None if the user does not exist. :return: The user, or None if the user does not exist.
""" """
return user_db[user_id] if user_id in user_db else None return User(user_id) if user_id in user_db else None
@self.app.get(ADMIN_1_URI) @app.get("/login-required-1/auth", endpoint="auth-1")
@flask_login.login_required @flask_login.login_required
def admin_1() -> str: def login_required_1() -> str:
"""The first administration section. """The first dummy view.
:return: The response. :return: The response.
""" """
return f"Hello, {flask_login.current_user.get_id()}! #1" return f"Hello, {flask_login.current_user.username}! #1"
@self.app.get(ADMIN_2_URI) @app.get("/login-required-2/auth", endpoint="auth-2")
@flask_login.login_required @flask_login.login_required
def admin_2() -> str: def login_required_2() -> str:
"""The second administration section. """The second dummy view.
:return: The response. :return: The response.
""" """
return f"Hello, {flask_login.current_user.get_id()}! #2" return f"Hello, {flask_login.current_user.username}! #2"
@self.app.post(LOGOUT_URI) return app
@flask_login.login_required
def logout() -> redirect:
"""Logs out the user.
:return: The response.
"""
auth.logout()
return redirect(request.form.get("next"))
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_NO_FLASK_LOGIN) self.skipTest("Skipped testing Flask-Login integration without it.")
response: httpx.Response response: Response = self.client.get(self.app.url_for("auth-1"))
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI, response = self.client.get(
auth=httpx.DigestAuth(USERNAME, PASSWORD)) self.app.url_for("auth-1"), digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.text, f"Hello, {USERNAME}! #1") self.assertEqual(response.data.decode("UTF-8"),
response = self.__client.get(ADMIN_2_URI) f"Hello, {_USERNAME}! #1")
response: Response = self.client.get(self.app.url_for("auth-2"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.text, f"Hello, {USERNAME}! #2") self.assertEqual(response.data.decode("UTF-8"),
self.assertEqual(self.__user.visits, 1) f"Hello, {_USERNAME}! #2")
def test_stale_opaque(self) -> None:
"""Tests the stale and opaque value.
:return: None.
"""
if not self.__has_flask_login:
self.skipTest(SKIPPED_NO_FLASK_LOGIN)
response: httpx.Response
www_authenticate: WWWAuthenticate
auth_header: str
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401)
www_authenticate = WWWAuthenticate.from_header(
response.headers["WWW-Authenticate"])
self.assertEqual(www_authenticate.type, "digest")
self.assertIsNone(www_authenticate.get("stale"))
opaque: str = www_authenticate.opaque
with self.app.app_context():
if hasattr(g, "_login_user"):
delattr(g, "_login_user")
www_authenticate.nonce = "bad"
auth_header = make_authorization(
www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 401)
www_authenticate = WWWAuthenticate.from_header(
response.headers["WWW-Authenticate"])
self.assertEqual(www_authenticate.get("stale"), "TRUE")
self.assertEqual(www_authenticate.opaque, opaque)
with self.app.app_context():
if hasattr(g, "_login_user"):
delattr(g, "_login_user")
auth_header = make_authorization(
www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD + "2")
response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 401)
www_authenticate = WWWAuthenticate.from_header(
response.headers["WWW-Authenticate"])
self.assertEqual(www_authenticate.get("stale"), "FALSE")
self.assertEqual(www_authenticate.opaque, opaque)
with self.app.app_context():
if hasattr(g, "_login_user"):
delattr(g, "_login_user")
auth_header = make_authorization(
www_authenticate, ADMIN_1_URI, USERNAME, PASSWORD)
response = self.__client.get(ADMIN_1_URI,
headers={"Authorization": auth_header})
self.assertEqual(response.status_code, 200)
def test_logout(self) -> None:
"""Tests the logging out.
:return: None.
"""
if not self.__has_flask_login:
self.skipTest(SKIPPED_NO_FLASK_LOGIN)
response: httpx.Response
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 200)
response = self.__client.post(LOGOUT_URI, data={"next": ADMIN_1_URI})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], ADMIN_1_URI)
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.__user.visits, 2)
def test_disabled(self) -> None:
"""Tests the disabled user.
:return: None.
"""
if not self.__has_flask_login:
self.skipTest(SKIPPED_NO_FLASK_LOGIN)
response: httpx.Response
self.__user.is_active = False
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 401)
self.__user.is_active = True
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 200)
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 200)
self.__user.is_active = False
response = self.__client.get(ADMIN_1_URI)
self.assertEqual(response.status_code, 401)
response = self.__client.get(ADMIN_1_URI,
auth=httpx.DigestAuth(USERNAME, PASSWORD))
self.assertEqual(response.status_code, 401)

View File

@ -1,87 +0,0 @@
# 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))