17 Commits

Author SHA1 Message Date
2425d99492 Advanced to version 0.2.0. 2022-11-27 07:06:35 +11:00
be163d35fb Added the on-login callback for the log in bookkeeping. 2022-11-27 06:58:29 +11:00
2aaaa9f47f Changed the BasePasswordGetter and BaseUserGetter from abstract to non-abstract, to simplify the code. 2022-11-27 06:32:02 +11:00
cb3e313e21 Revised the FlaskLoginTestCase test case and README.rst for illustrations on how to retrieve the currently logged-in user. 2022-11-26 18:59:17 +11:00
6f49a180e3 Revised the DigestAuth class to deal with the dummy get_password_hash and get_user functions when they are not registered yet. 2022-11-26 09:37:13 +11:00
af8c3a484c Revised so that you always call digest_auth.init_app(), to avoid confusion. It remembers the current application. The logout() method no longer need current_app for the current application. 2022-11-25 09:14:57 +11:00
65c3322ecc Revised the routes in the examples in README.rst. 2022-11-25 08:41:30 +11:00
cb5cfaf7d4 Added logging out. 2022-11-25 08:40:27 +11:00
dda8472a76 Revised the test_stale_opaque tests of the AuthenticationTestCase and FlaskLoginTestCase test cases to use the super method instead of the overridden method of the Client class. 2022-11-25 00:18:43 +11:00
177f549786 Changed the test_stale tests to test_stale_opaque that also tests if the opaque value is still the same across the client authentication requests. 2022-11-24 23:55:49 +11:00
ff8ada129d Added the docstring documentation to the dummy user of the Flask-Login test case. 2022-11-24 23:48:09 +11:00
ccec1365bf Revised the AuthenticationTestCase and FlaskLoginTestCase test cases for simplicity and readability. 2022-11-24 23:46:45 +11:00
78514a8f17 Added to test the stale value of the WWW-Authenticate response header in the AuthenticationTestCase and FlaskLoginTestCase test cases. 2022-11-24 21:53:21 +11:00
3dcc409bef Changed the make_authorization method from private to public in the test client. 2022-11-24 21:45:11 +11:00
2156aa710f Added the instructions on setting the password hash in README.rst. 2022-11-24 21:34:48 +11:00
a43c6aea89 Added rationale to HTTP Digest Authentication in README.rst. 2022-11-24 21:25:01 +11:00
8e29c91f92 Revised README.rst. 2022-11-24 21:10:25 +11:00
6 changed files with 562 additions and 80 deletions

View File

@ -6,12 +6,74 @@ Flask HTTP Digest Authentication
Description 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.
separated with the authentication mechanism. You can write Flask
modules that work with different authentication mechanisms.
HTTP Digest Authentication is specified in `RFC 2617`_.
Why HTTP Digest Authentication?
-------------------------------
HTTP Digest Authentication has the advantage that it does not send the
actual password to the server, which greatly enhances the security.
It uses the challenge-response authentication scheme. The client
returns the response calculated from the challenge and the password,
but not the original password.
Log in forms has the advantage of freedom, in the senses of both the
visual design and the actual implementation. You may implement your
own challenge-response log in form, but then you are reinventing the
wheels. If a pretty log in form is not critical to your project, HTTP
Digest Authentication should be a good choice.
Flask-Digest-Auth works with Flask-Login_. Log in protection can be
separated with the authentication mechanism. You can create protected
Flask modules without knowing the actual authentication mechanisms.
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.
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 Out Support
###############
Flask-Digest-Auth supports log out. The user will be prompted for
new username and password.
Log In Bookkeeping
##################
You can register a callback to run when the user logs in.
.. _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: https://flask.palletsprojects.com
.. _Flask-Login: https://flask-login.readthedocs.io .. _Flask-Login: https://flask-login.readthedocs.io
@ -19,14 +81,14 @@ modules that work with different authentication mechanisms.
Installation Installation
============ ============
It's suggested that you install with ``pip``: You can install Flask-Digest-Auth with ``pip``:
:: ::
pip install flask-digest-auth pip install Flask-Digest-Auth
You may also install the latest source from the You may also install the latest source from the
`flask-digest-auth Github repository`_. `Flask-Digest-Auth GitHub repository`_.
:: ::
@ -34,7 +96,7 @@ You may also install the latest source from the
cd flask-digest-auth cd flask-digest-auth
pip install . pip install .
.. _flask-digest-auth Github repository: https://github.com/imacat/flask-digest-auth .. _Flask-Digest-Auth GitHub repository: https://github.com/imacat/flask-digest-auth
Flask-Digest-Auth Alone Flask-Digest-Auth Alone
@ -42,6 +104,8 @@ Flask-Digest-Auth Alone
Flask-Digest-Auth can authenticate the users alone. Flask-Digest-Auth can authenticate the users alone.
The currently logged-in user can be retrieved at ``g.user``, if any.
Example for Simple Applications with Flask-Digest-Auth Alone Example for Simple Applications with Flask-Digest-Auth Alone
------------------------------------------------------------ ------------------------------------------------------------
@ -50,13 +114,14 @@ In your ``my_app.py``:
:: ::
from flask import Flask from flask import Flask, request, redirect
from flask_digest_auth import DigestAuth from flask_digest_auth import DigestAuth
app: flask = Flask(__name__) app: flask = Flask(__name__)
... (Configure the Flask application) ... ... (Configure the Flask application) ...
auth: DigestAuth = DigestAuth(realm="Admin") auth: DigestAuth = DigestAuth(realm="Admin")
auth.init_app(app)
@auth.register_get_password @auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]: def get_password_hash(username: str) -> t.Optional[str]:
@ -69,7 +134,13 @@ In your ``my_app.py``:
@app.get("/admin") @app.get("/admin")
@auth.login_required @auth.login_required
def admin(): def admin():
... (Process the view) ... return f"Hello, {g.user.username}!"
@app.post("/logout")
@auth.login_required
def logout():
auth.logout()
return redirect(request.form.get("next"))
Example for Larger Applications with ``create_app()`` with Flask-Digest-Auth Alone Example for Larger Applications with ``create_app()`` with Flask-Digest-Auth Alone
@ -77,7 +148,7 @@ Example for Larger Applications with ``create_app()`` with Flask-Digest-Auth Alo
In your ``my_app/__init__.py``: In your ``my_app/__init__.py``:
::: ::
from flask import Flask from flask import Flask
from flask_digest_auth import DigestAuth from flask_digest_auth import DigestAuth
@ -89,6 +160,7 @@ In your ``my_app/__init__.py``:
... (Configure the Flask application) ... ... (Configure the Flask application) ...
auth.realm = app.config["REALM"] auth.realm = app.config["REALM"]
auth.init_app(app)
@auth.register_get_password @auth.register_get_password
def get_password_hash(username: str) -> t.Optional[str]: def get_password_hash(username: str) -> t.Optional[str]:
@ -105,14 +177,20 @@ In your ``my_app/views.py``:
:: ::
from my_app import auth from my_app import auth
from flask import Flask, Blueprint from flask import Flask, Blueprint, request, redirect
bp = Blueprint("admin", __name__, url_prefix="/admin") bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/") @bp.get("/admin")
@auth.login_required @auth.login_required
def admin(): def admin():
... (Process the view) ... return f"Hello, {g.user.username}!"
@app.post("/logout")
@auth.login_required
def logout():
auth.logout()
return redirect(request.form.get("next"))
def init_app(app: Flask) -> None: def init_app(app: Flask) -> None:
app.register_blueprint(bp) app.register_blueprint(bp)
@ -126,6 +204,12 @@ module that requires log in, without specifying the authentication
mechanism. The Flask application can specify the actual mechanism. The Flask application can specify the actual
authentication mechanism as it sees fit. authentication mechanism as it sees fit.
``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.
Example for Simple Applications with Flask-Login Integration Example for Simple Applications with Flask-Login Integration
------------------------------------------------------------ ------------------------------------------------------------
@ -134,14 +218,14 @@ In your ``my_app.py``:
:: ::
from flask import Flask import flask_login
from flask import Flask, request, redirect
from flask_digest_auth import DigestAuth from flask_digest_auth import DigestAuth
from flask_login import LoginManager
app: flask = Flask(__name__) app: flask = Flask(__name__)
... (Configure the Flask application) ... ... (Configure the Flask application) ...
login_manager: LoginManager = LoginManager() login_manager: flask_login.LoginManager = flask_login.LoginManager()
login_manager.init_app(app) login_manager.init_app(app)
@login_manager.user_loader @login_manager.user_loader
@ -156,9 +240,16 @@ In your ``my_app.py``:
... (Load the password hash) ... ... (Load the password hash) ...
@app.get("/admin") @app.get("/admin")
@login_manager.login_required @flask_login.login_required
def admin(): def admin():
... (Process the view) ... return f"Hello, {flask_login.current_user.get_id()}!"
@app.post("/logout")
@flask_login.login_required
def logout():
auth.logout()
# Do not call flask_login.logout_user()
return redirect(request.form.get("next"))
Example for Larger Applications with ``create_app()`` with Flask-Login Integration Example for Larger Applications with ``create_app()`` with Flask-Login Integration
@ -166,12 +257,14 @@ Example for Larger Applications with ``create_app()`` with Flask-Login Integrati
In your ``my_app/__init__.py``: In your ``my_app/__init__.py``:
::: ::
from flask import Flask from flask import Flask
from flask_digest_auth import DigestAuth from flask_digest_auth import DigestAuth
from flask_login import LoginManager from flask_login import LoginManager
auth: DigestAuth = DigestAuth()
def create_app(test_config = None) -> Flask: def create_app(test_config = None) -> Flask:
app: flask = Flask(__name__) app: flask = Flask(__name__)
... (Configure the Flask application) ... ... (Configure the Flask application) ...
@ -183,7 +276,7 @@ In your ``my_app/__init__.py``:
def load_user(user_id: str) -> t.Optional[User]: def load_user(user_id: str) -> t.Optional[User]:
... (Load the user with the username) ... ... (Load the user with the username) ...
auth: DigestAuth = DigestAuth(realm=app.config["REALM"]) auth.realm = app.config["REALM"]
auth.init_app(app) auth.init_app(app)
@auth.register_get_password @auth.register_get_password
@ -197,23 +290,69 @@ In your ``my_app/views.py``:
:: ::
import flask_login import flask_login
from flask import Flask, Blueprint from flask import Flask, Blueprint, request, redirect
from my_app import auth
bp = Blueprint("admin", __name__, url_prefix="/admin") bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/") @bp.get("/admin")
@flask_login.login_required @flask_login.login_required
def admin(): def admin():
... (Process the view) ... return f"Hello, {flask_login.current_user.get_id()}!"
@app.post("/logout")
@flask_login.login_required
def logout():
auth.logout()
# Do not call flask_login.logout_user()
return redirect(request.form.get("next"))
def init_app(app: Flask) -> None: def init_app(app: Flask) -> None:
app.register_blueprint(bp) app.register_blueprint(bp)
The views only depend on Flask-Login, but not its underlying The views only depend on Flask-Login, but not the actual
authentication mechanism. You can always change the authentication mechanism. You can change the actual authentication
authentication mechanism without changing the views, or release a mechanism without changing the views.
protected Flask module without specifying the authentication
mechanism.
Setting the Password Hash
=========================
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.
Log Out
=======
Call ``auth.logout()`` when the user wants to log out.
Besides the usual log out routine, ``auth.logout()`` actually causes
the next browser automatic authentication to fail, forcing the browser
to ask the user for the username and password again.
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
Writing Tests Writing Tests

View File

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

View File

@ -35,6 +35,50 @@ from flask_digest_auth.algo import calc_response
from flask_digest_auth.exception import UnauthorizedException 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:
"""The HTTP digest authentication.""" """The HTTP digest authentication."""
@ -51,9 +95,11 @@ class DigestAuth:
self.use_opaque: bool = True self.use_opaque: bool = True
self.domain: t.List[str] = [] self.domain: t.List[str] = []
self.qop: t.List[str] = ["auth", "auth-int"] self.qop: t.List[str] = ["auth", "auth-int"]
self.__get_password_hash: t.Callable[[str], t.Optional[str]] \ self.app: t.Optional[Flask] = None
= lambda x: None self.__get_password_hash: BasePasswordHashGetter \
self.__get_user: t.Callable[[str], t.Optional] = lambda x: None = BasePasswordHashGetter()
self.__get_user: BaseUserGetter = BaseUserGetter()
self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback()
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.
@ -92,7 +138,9 @@ class DigestAuth:
"Not an HTTP digest authorization") "Not an HTTP digest authorization")
self.authenticate(state) self.authenticate(state)
session["user"] = authorization.username session["user"] = authorization.username
g.user = self.__get_user(authorization.username) user = self.__get_user(authorization.username)
g.user = user
self.__on_login(user)
return view(*args, **kwargs) return view(*args, **kwargs)
except UnauthorizedException as e: except UnauthorizedException as e:
if len(e.args) > 0: if len(e.args) > 0:
@ -112,6 +160,9 @@ class DigestAuth:
: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:
@ -123,8 +174,8 @@ class DigestAuth:
except BadData: except BadData:
raise UnauthorizedException("Invalid opaque") raise UnauthorizedException("Invalid opaque")
state.opaque = authorization.opaque state.opaque = authorization.opaque
password_hash: t.Optional[str] = self.__get_password_hash( password_hash: t.Optional[str] \
authorization.username) = self.__get_password_hash(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}\"")
@ -183,7 +234,20 @@ class DigestAuth:
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):
"""The base password hash getter."""
@staticmethod
def __call__(username: str) -> t.Optional[str]:
"""Returns the password hash of a user.
:param username: The username.
:return: The password hash, or None if the user does not exist.
"""
return func(username)
self.__get_password_hash = PasswordHashGetter()
def register_get_user(self, func: t.Callable[[str], t.Optional[t.Any]])\ def register_get_user(self, func: t.Callable[[str], t.Optional[t.Any]])\
-> None: -> None:
@ -193,7 +257,41 @@ class DigestAuth:
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) -> t.Optional[t.Any]:
"""Returns a user.
:param username: The username.
:return: The user, or None if the user does not exist.
"""
return func(username)
self.__get_user = UserGetter()
def register_on_login(self, func: t.Callable[[t.Any], None]) -> None:
"""Registers the callback when the user logs in.
:param func: The callback given the logged-in user.
:return: None.
"""
class OnLogInCallback:
"""The callback when the user logs in."""
@staticmethod
def __call__(user: t.Any) -> None:
"""Runs the callback when the user logs in.
:param user: The logged-in user.
:return: None.
"""
func(user)
self.__on_login = OnLogInCallback()
def init_app(self, app: Flask) -> None: def init_app(self, app: Flask) -> None:
"""Initializes the Flask application. """Initializes the Flask application.
@ -201,13 +299,12 @@ class DigestAuth:
:param app: The Flask application. :param app: The Flask application.
:return: None. :return: None.
""" """
app.digest_auth = self
self.app = app
try: if hasattr(app, "login_manager"):
from flask_login import LoginManager, login_user 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: LoginManager = getattr(app, "login_manager")
@login_manager.unauthorized_handler @login_manager.unauthorized_handler
@ -242,15 +339,29 @@ class DigestAuth:
user = login_manager.user_callback( user = login_manager.user_callback(
authorization.username) authorization.username)
login_user(user) login_user(user)
self.__on_login(user)
return user return user
except UnauthorizedException as e: except UnauthorizedException as e:
if str(e) != "": if str(e) != "":
app.logger.warning(str(e)) app.logger.warning(str(e))
return None 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.
:return: None.
"""
if "user" in session:
del session["user"]
try:
if hasattr(self.app, "login_manager"):
from flask_login import logout_user
logout_user()
except ModuleNotFoundError: except ModuleNotFoundError:
raise ModuleNotFoundError( pass
"init_app() is only for Flask-Login integration") session["digest_auth_logout"] = True
class AuthState: class AuthState:

View File

@ -49,14 +49,14 @@ 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 = self.__class__.__make_authorization( 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 @staticmethod
def __make_authorization(www_authenticate: WWWAuthenticate, uri: str, def make_authorization(www_authenticate: WWWAuthenticate, uri: str,
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.

View File

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

View File

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