Compare commits

...

12 Commits

9 changed files with 416 additions and 202 deletions

View File

@ -9,13 +9,243 @@ Description
*Flask-Digest-Auth* is an HTTP Digest Authentication implementation *Flask-Digest-Auth* is an HTTP Digest Authentication implementation
for Flask_ applications. It authenticates the user for the protected for Flask_ applications. It authenticates the user for the protected
views. It works with Flask-Login_, so that log in protection can be views. It works with Flask-Login_, so that log in protection can be
separated with the log in mechanism. You can write Flask modules that separated with the authentication mechanism. You can write Flask
work with different log in mechanisms. modules that work with different authentication mechanisms.
.. _Flask: https://flask.palletsprojects.com .. _Flask: https://flask.palletsprojects.com
.. _Flask-Login: https://flask-login.readthedocs.io .. _Flask-Login: https://flask-login.readthedocs.io
Installation
============
It's suggested that you install with ``pip``:
::
pip install flask-digest-auth
You may also install from the latest source from the
`flask-digest-auth Github repository`_.
::
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
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
=============
You can write tests with our test client that handles HTTP Digest
Authentication. Example for a unittest testcase:
::
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)
Copyright Copyright
========= =========

View File

@ -23,15 +23,16 @@ author_email = imacat@mail.imacat.idv.tw
description = The Flask HTTP Digest Authentication project. description = The Flask HTTP Digest Authentication project.
long_description = file: README.rst long_description = file: README.rst
long_description_content_type = text/x-rst long_description_content_type = text/x-rst
url = https://gitea.imacat.idv.tw/imacat/flask-digest-auth url = https://github.com/imacat/flask-digest-auth
project_urls = project_urls =
Bug Tracker = https://gitea.imacat.idv.tw/imacat/flask-digest-auth/issues Bug Tracker = https://github.com/imacat/flask-digest-auth/issues
classifiers = classifiers =
Programming Language :: Python :: 3 Programming Language :: Python :: 3
License :: OSI Approved :: Apache Software License License :: OSI Approved :: Apache Software License
Operating System :: OS Independent Operating System :: OS Independent
Framework :: Flask Framework :: Flask
Topic :: Office/Business :: Financial :: Accounting Topic :: System :: Systems Administration :: Authentication/Directory
Intended Audience :: Developers
[options] [options]
package_dir = package_dir =
@ -39,10 +40,14 @@ package_dir =
packages = find: packages = find:
python_requires = >=3.10 python_requires = >=3.10
install_requires = install_requires =
flask-login flask
tests_require = tests_require =
unittest unittest
flask-testing flask-testing
[options.packages.find] [options.packages.find]
where = src where = src
[options.extras_require]
flask_login =
flask-login

View File

@ -20,5 +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.flask_login import init_login_manager
from flask_digest_auth.test import Client from flask_digest_auth.test import Client

View File

@ -60,8 +60,45 @@ def calc_response(
algorithm, when the body is missing with the auth-int qop, or when the algorithm, when the body is missing with the auth-int qop, or when the
cnonce or nc is missing with the auth or auth-int qop. cnonce or nc is missing with the auth or auth-int qop.
""" """
ha1: str = __calc_ha1(password_hash, nonce, algorithm, cnonce)
ha2: str = __calc_ha2(method, uri, qop, body) def calc_ha1() -> str:
"""Calculates and returns the first hash.
:return: The first hash.
:raise UnauthorizedException: When the cnonce is missing with the MD5-sess
algorithm.
"""
if algorithm is None or algorithm == "MD5":
return password_hash
if algorithm == "MD5-sess":
if cnonce is None:
raise UnauthorizedException(
f"Missing \"cnonce\" with algorithm=\"{algorithm}\"")
return md5(f"{password_hash}:{nonce}:{cnonce}".encode("utf8")) \
.hexdigest()
raise UnauthorizedException(
f"Unsupported algorithm=\"{algorithm}\"")
def calc_ha2() -> str:
"""Calculates the second hash.
:return: The second hash.
: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 body is None:
raise UnauthorizedException(
f"Missing \"body\" with qop=\"{qop}\"")
return md5(
f"{method}:{uri}:{md5(body).hexdigest()}".encode("utf8")) \
.hexdigest()
raise UnauthorizedException(f"Unsupported qop=\"{qop}\"")
ha1: str = calc_ha1()
ha2: str = calc_ha2()
if qop is None: if qop is None:
return md5(f"{ha1}:{nonce}:{ha2}".encode("utf8")).hexdigest() return md5(f"{ha1}:{nonce}:{ha2}".encode("utf8")).hexdigest()
if qop == "auth" or qop == "auth-int": if qop == "auth" or qop == "auth-int":
@ -76,52 +113,3 @@ def calc_response(
if cnonce is None: if cnonce is None:
raise UnauthorizedException( raise UnauthorizedException(
f"Unsupported qop=\"{qop}\"") f"Unsupported qop=\"{qop}\"")
def __calc_ha1(password_hash: str, nonce: str,
algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = None,
cnonce: t.Optional[str] = None) -> str:
"""Calculates and returns the first hash.
:param password_hash: The password hash for the HTTP digest authentication.
:param nonce: The nonce.
:param algorithm: The algorithm, either "MD5", "MD5-sess", or None.
:param cnonce: The client nonce. It must be provided when the algorithm is
"MD5-sess".
:return: The first hash.
:raise UnauthorizedException: When the cnonce is missing with the MD5-sess
algorithm.
"""
if algorithm is None or algorithm == "MD5":
return password_hash
if algorithm == "MD5-sess":
if cnonce is None:
raise UnauthorizedException(
f"Missing \"cnonce\" with algorithm=\"{algorithm}\"")
return md5(f"{password_hash}:{nonce}:{cnonce}".encode("utf8"))\
.hexdigest()
raise UnauthorizedException(
f"Unsupported algorithm=\"{algorithm}\"")
def __calc_ha2(method: str, uri: str,
qop: t.Optional[t.Literal["auth", "auth-int"]] = None,
body: t.Optional[bytes] = None) -> str:
"""Calculates the second hash.
:param method: The request method.
:param uri: The request URI.
:param qop: The quality of protection, either "auth", "auth-int" or None.
:param body: The request body. It must be provided when the quality of
protection is "auth-int".
:return: The second hash.
: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 body is None:
raise UnauthorizedException(f"Missing \"body\" with qop=\"{qop}\"")
return md5(f"{method}:{uri}:{md5(body).hexdigest()}".encode("utf8"))\
.hexdigest()
raise UnauthorizedException(f"Unsupported qop=\"{qop}\"")

View File

@ -27,7 +27,7 @@ from functools import wraps
from random import random from random import random
from secrets import token_urlsafe from secrets import token_urlsafe
from flask import g, request, Response, session, abort from flask import g, request, Response, session, abort, Flask, Request
from itsdangerous import URLSafeTimedSerializer, BadData from itsdangerous import URLSafeTimedSerializer, BadData
from werkzeug.datastructures import Authorization from werkzeug.datastructures import Authorization
@ -195,6 +195,63 @@ class DigestAuth:
""" """
self.__get_user = func self.__get_user = func
def init_app(self, app: Flask) -> None:
"""Initializes the Flask application.
:param app: The Flask application.
:return: None.
"""
try:
from flask_login import LoginManager, login_user
if not hasattr(app, "login_manager"):
raise AttributeError(
"Please run the Flask-Login init-app() first")
login_manager: LoginManager = getattr(app, "login_manager")
@login_manager.unauthorized_handler
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:
raise ModuleNotFoundError(
"init_app() is only for Flask-Login integration")
class AuthState: class AuthState:
"""The authorization state.""" """The authorization state."""

View File

@ -1,74 +0,0 @@
# The Flask HTTP Digest Authentication Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/14
# 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 Flask-Login integration.
"""
import typing as t
from flask import Response, abort, current_app, Request, g
from flask_login import LoginManager, login_user
from werkzeug.datastructures import Authorization
from flask_digest_auth.auth import DigestAuth, AuthState
from flask_digest_auth.exception import UnauthorizedException
def init_login_manager(auth: DigestAuth, login_manager: LoginManager) -> None:
"""Initialize the login manager.
:param auth: The HTTP digest authentication.
:param login_manager: The login manager from FlaskLogin.
:return: None.
"""
@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"] = auth.make_response_header(
g.digest_auth_state)
abort(response)
@login_manager.request_loader
def load_user_from_request(request: Request) -> t.Optional[t.Any]:
"""Loads the user from the request header.
:param request: The request.
:return: The authenticated user, or None if the authentication fails
"""
g.digest_auth_state = AuthState()
authorization: Authorization = request.authorization
try:
if authorization is None:
raise UnauthorizedException
if authorization.type != "digest":
raise UnauthorizedException(
"Not an HTTP digest authorization")
auth.authenticate(g.digest_auth_state)
user = login_manager.user_callback(authorization.username)
login_user(user)
return user
except UnauthorizedException as e:
if str(e) != "":
current_app.logger.warning(str(e))
return None

View File

@ -49,15 +49,15 @@ class Client(WerkzeugClient):
return response return response
if hasattr(g, "_login_user"): if hasattr(g, "_login_user"):
delattr(g, "_login_user") delattr(g, "_login_user")
auth_data: Authorization = _get_req_auth( auth_data: Authorization = self.__class__.__make_authorization(
www_authenticate, args[0], digest_auth[0], digest_auth[1]) www_authenticate, args[0], digest_auth[0], digest_auth[1])
response = super(Client, self).open(*args, auth=auth_data, **kwargs) response = super(Client, self).open(*args, auth=auth_data, **kwargs)
return response return response
@staticmethod
def _get_req_auth(www_authenticate: WWWAuthenticate, uri: str, def __make_authorization(www_authenticate: WWWAuthenticate, uri: str,
username: str, password: str) -> Authorization: username: str, password: str) -> Authorization:
"""Returns the request authorization from the response header. """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.
@ -83,7 +83,8 @@ def _get_req_auth(www_authenticate: WWWAuthenticate, uri: str,
password_hash=make_password_hash(www_authenticate.realm, password_hash=make_password_hash(www_authenticate.realm,
username, password), username, password),
nonce=www_authenticate.nonce, qop=qop, nonce=www_authenticate.nonce, qop=qop,
algorithm=www_authenticate.algorithm, cnonce=cnonce, nc=nc, body=None) algorithm=www_authenticate.algorithm, cnonce=cnonce, nc=nc,
body=None)
data: t.Dict[str, str] = { data: t.Dict[str, str] = {
"username": username, "realm": www_authenticate.realm, "username": username, "realm": www_authenticate.realm,

View File

@ -40,6 +40,13 @@ class AuthenticationTestCase(TestCase):
:return: The Flask application. :return: The Flask application.
""" """
app: Flask = Flask(__name__)
app.config.from_mapping({
"SECRET_KEY": token_urlsafe(32),
"TESTING": True
})
app.test_client_class = Client
auth: DigestAuth = DigestAuth(realm=_REALM) auth: DigestAuth = DigestAuth(realm=_REALM)
user_db: t.Dict[str, str] \ user_db: t.Dict[str, str] \
= {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)} = {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)}
@ -63,14 +70,7 @@ class AuthenticationTestCase(TestCase):
return SimpleNamespace(username=username) if username in user_db \ return SimpleNamespace(username=username) if username in user_db \
else None else None
app: Flask = Flask(__name__) @app.get("/login-required-1/auth", endpoint="auth-1")
app.config.from_mapping({
"SECRET_KEY": token_urlsafe(32),
"TESTING": True
})
app.test_client_class = Client
@app.route("/login-required-1/auth", endpoint="auth-1")
@auth.login_required @auth.login_required
def login_required_1() -> str: def login_required_1() -> str:
"""The first dummy view. """The first dummy view.
@ -79,7 +79,7 @@ class AuthenticationTestCase(TestCase):
""" """
return f"Hello, {g.user.username}! #1" return f"Hello, {g.user.username}! #1"
@app.route("/login-required-2/auth", endpoint="auth-2") @app.get("/login-required-2/auth", endpoint="auth-2")
@auth.login_required @auth.login_required
def login_required_2() -> str: def login_required_2() -> str:
"""The second dummy view. """The second dummy view.

View File

@ -21,13 +21,10 @@
import typing as t import typing as t
from secrets import token_urlsafe from secrets import token_urlsafe
import flask_login
from flask import Response, Flask from flask import Response, Flask
from flask_login import LoginManager
from flask_testing import TestCase from flask_testing import TestCase
from flask_digest_auth import DigestAuth, make_password_hash, Client, \ from flask_digest_auth import DigestAuth, make_password_hash, Client
init_login_manager
_REALM: str = "testrealm@host.com" _REALM: str = "testrealm@host.com"
_USERNAME: str = "Mufasa" _USERNAME: str = "Mufasa"
@ -53,13 +50,31 @@ class User:
class FlaskLoginTestCase(TestCase): class FlaskLoginTestCase(TestCase):
"""The test case with the Flask-Login integration.""" """The test case with the Flask-Login integration."""
def create_app(self): def create_app(self) -> Flask:
"""Creates the Flask application. """Creates the Flask application.
:return: The Flask application. :return: The Flask application.
""" """
app: Flask = Flask(__name__)
app.config.from_mapping({
"SECRET_KEY": token_urlsafe(32),
"TESTING": True
})
app.test_client_class = Client
self.has_flask_login: bool = True
try:
import flask_login
except ModuleNotFoundError:
self.has_flask_login = False
return app
login_manager: flask_login.LoginManager = flask_login.LoginManager()
login_manager.init_app(app)
auth: DigestAuth = DigestAuth(realm=_REALM) auth: DigestAuth = DigestAuth(realm=_REALM)
login_manager: LoginManager = LoginManager() auth.init_app(app)
user_db: t.Dict[str, str] \ user_db: t.Dict[str, str] \
= {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)} = {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)}
@ -72,16 +87,6 @@ class FlaskLoginTestCase(TestCase):
""" """
return user_db[username] if username in user_db else None return user_db[username] if username in user_db else None
app: Flask = Flask(__name__)
app.config.from_mapping({
"SECRET_KEY": token_urlsafe(32),
"TESTING": True
})
app.test_client_class = Client
login_manager.init_app(app)
init_login_manager(auth, login_manager)
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id: str) -> t.Optional[User]: def load_user(user_id: str) -> t.Optional[User]:
"""Loads a user. """Loads a user.
@ -91,7 +96,7 @@ class FlaskLoginTestCase(TestCase):
""" """
return User(user_id) if user_id in user_db else None return User(user_id) if user_id in user_db else None
@app.route("/login-required-1/auth", endpoint="auth-1") @app.get("/login-required-1/auth", endpoint="auth-1")
@flask_login.login_required @flask_login.login_required
def login_required_1() -> str: def login_required_1() -> str:
"""The first dummy view. """The first dummy view.
@ -100,7 +105,7 @@ class FlaskLoginTestCase(TestCase):
""" """
return f"Hello, {flask_login.current_user.username}! #1" return f"Hello, {flask_login.current_user.username}! #1"
@app.route("/login-required-2/auth", endpoint="auth-2") @app.get("/login-required-2/auth", endpoint="auth-2")
@flask_login.login_required @flask_login.login_required
def login_required_2() -> str: def login_required_2() -> str:
"""The second dummy view. """The second dummy view.
@ -116,6 +121,9 @@ class FlaskLoginTestCase(TestCase):
:return: None. :return: None.
""" """
if not self.has_flask_login:
self.skipTest("Skipped testing Flask-Login integration without it.")
response: Response = self.client.get(self.app.url_for("auth-1")) response: Response = self.client.get(self.app.url_for("auth-1"))
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
response = self.client.get( response = self.client.get(