Compare commits

...

11 Commits

11 changed files with 608 additions and 104 deletions

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

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

@ -0,0 +1,58 @@
# 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.1'
# -- 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']
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:

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

@ -0,0 +1,24 @@
.. 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!
=============================================
.. toctree::
:maxdepth: 2
:caption: Contents:
intro
flask_digest_auth
examples
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

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

@ -0,0 +1,109 @@
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`_.
See :ref:`example-alone-simple` and :ref:`example-alone-large`.
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.
Features
--------
There are a couple of Flask HTTP digest authentication
implementations. Flask-Digest-Auth has the following features:
Flask-Login Integration
#######################
Flask-Digest-Auth features Flask-Login integration. The views
can be totally independent with the actual authentication mechanism.
You can write a Flask module that requires log in, without specify
the actual authentication mechanism. The application can specify
either HTTP Digest Authentication, or the log in forms, as needed.
See :ref:`example-flask-login-simple` and
:ref:`example-flask-login-large`.
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.
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`.
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
.. _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

@ -23,8 +23,6 @@ from __future__ import annotations
import typing as t import typing as t
from hashlib import md5 from hashlib import md5
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.
@ -49,38 +47,28 @@ def calc_response(
: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. :param qop: the quality of protection, either ``auth`` or ``auth-int``.
: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="MD5-sess". algorithm is ``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="auth-int". :param body: The request body, which must exists when qop is ``auth-int``.
:return: The response value. :return: The response value.
:raise UnauthorizedException: When the cnonce is missing with the MD5-sess :raise AssertionError: When cnonce is missing with algorithm is
algorithm, when the body is missing with the auth-int qop, or when the ``MD5-sess``, when body is missing with qop is ``auth-int``, or when
cnonce or nc is missing with the auth or auth-int qop. cnonce or nc is missing with qop exits.
""" """
def validate_required(field: t.Optional[str], error: str) -> None:
"""Validates a required field.
:param field: The field that is required.
:param error: The error message.
:return: None.
"""
if field is None:
raise UnauthorizedException(error)
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 UnauthorizedException: When the cnonce is missing with the MD5-sess :raise AssertionError: When cnonce is missing with
algorithm. algorithm is ``MD5-sess``.
""" """
if algorithm == "MD5-sess": if algorithm == "MD5-sess":
validate_required( assert cnonce is not None,\
cnonce, f"Missing \"cnonce\" with algorithm=\"{algorithm}\"") 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" # algorithm is None or algorithm == "MD5"
@ -90,11 +78,10 @@ def calc_response(
"""Calculates the second hash. """Calculates the second hash.
:return: The second hash. :return: The second hash.
:raise UnauthorizedException: When the body is missing with :raise AssertionError: When body is missing with qop is ``auth-int``.
qop="auth-int".
""" """
if qop == "auth-int": if qop == "auth-int":
validate_required(body, f"Missing \"body\" with qop=\"{qop}\"") assert body is not None, 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()
@ -104,8 +91,8 @@ def calc_response(
ha1: str = calc_ha1() ha1: str = calc_ha1()
ha2: str = calc_ha2() ha2: str = calc_ha2()
if qop == "auth" or qop == "auth-int": if qop == "auth" or qop == "auth-int":
validate_required(cnonce, f"Missing \"cnonce\" with the qop=\"{qop}\"") assert cnonce is not None, f"Missing \"cnonce\" with the qop=\"{qop}\""
validate_required(nc, f"Missing \"nc\" 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"))\ return md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode("utf8"))\
.hexdigest() .hexdigest()
# qop is None # qop is None

View File

@ -31,51 +31,6 @@ 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 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.
"""
class DigestAuth: class DigestAuth:
@ -103,7 +58,7 @@ class DigestAuth:
def login_required(self, view) -> t.Callable: def login_required(self, view) -> t.Callable:
"""The view decorator for HTTP digest authentication. """The view decorator for HTTP digest authentication.
:param view: :param view: The view.
:return: The login-protected view. :return: The login-protected view.
""" """
@ -217,10 +172,10 @@ class DigestAuth:
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.
""" """
def get_opaque() -> t.Optional[str]: def get_opaque() -> t.Optional[str]:
@ -400,3 +355,52 @@ class AuthState:
"""Constructs the authorization state.""" """Constructs the authorization state."""
self.opaque: t.Optional[str] = None self.opaque: t.Optional[str] = None
self.stale: t.Optional[bool] = 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

@ -36,8 +36,8 @@ class Client(WerkzeugClient):
"""Opens a request. """Opens a request.
:param args: The arguments. :param args: The arguments.
:param digest_auth: The username and password for the HTTP digest :param digest_auth: A tuple of the username and password for the HTTP
authentication. digest authentication.
:param kwargs: The keyword arguments. :param kwargs: The keyword arguments.
:return: The response. :return: The response.
""" """
@ -59,7 +59,7 @@ class Client(WerkzeugClient):
username: str, password: str) -> Authorization: username: str, password: str) -> Authorization:
"""Composes and returns the request 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 uri: The request URI.
:param username: The username. :param username: The username.
:param password: The password. :param password: The password.