Compare commits

...

11 Commits

5 changed files with 329 additions and 46 deletions

View File

@ -6,12 +6,30 @@ 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`_.
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.
.. _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 +37,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 +52,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
@ -50,7 +68,7 @@ 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__)
@ -71,13 +89,19 @@ In your ``my_app.py``:
def admin(): def admin():
... (Process the view) ... ... (Process the view) ...
@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
---------------------------------------------------------------------------------- ----------------------------------------------------------------------------------
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
@ -105,15 +129,21 @@ 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) ... ... (Process the view) ...
@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)
@ -134,7 +164,7 @@ 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
from flask_login import LoginManager from flask_login import LoginManager
@ -160,18 +190,27 @@ In your ``my_app.py``:
def admin(): def admin():
... (Process the view) ... ... (Process the view) ...
@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
---------------------------------------------------------------------------------- ----------------------------------------------------------------------------------
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 +222,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 +236,56 @@ 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) ... ... (Process the view) ...
@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.
Writing Tests Writing Tests

View File

@ -27,7 +27,8 @@ 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, 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
@ -112,6 +113,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:
@ -252,6 +256,24 @@ class DigestAuth:
raise ModuleNotFoundError( raise ModuleNotFoundError(
"init_app() is only for Flask-Login integration") "init_app() is only for Flask-Login integration")
@staticmethod
def logout() -> 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(current_app, "login_manager"):
from flask_login import logout_user
logout_user()
except ModuleNotFoundError:
pass
session["digest_auth_logout"] = True
class AuthState: class AuthState:
"""The authorization state.""" """The authorization state."""

View File

@ -49,13 +49,13 @@ 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.

View File

@ -22,8 +22,9 @@ import typing as t
from secrets import token_urlsafe from secrets import token_urlsafe
from types import SimpleNamespace 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
@ -70,24 +71,34 @@ 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.get("/login-required-1/auth", endpoint="auth-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 +106,90 @@ 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")
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)

View File

@ -21,8 +21,9 @@
import typing as t import typing as t
from secrets import token_urlsafe from secrets import token_urlsafe
from flask import Response, Flask 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,7 +33,13 @@ _PASSWORD: str = "Circle Of Life"
class User: class User:
"""A dummy user."""
def __init__(self, username: str): def __init__(self, username: str):
"""Constructs a dummy user.
:param username: The username.
"""
self.username: str = username self.username: str = username
self.is_authenticated: bool = True self.is_authenticated: bool = True
self.is_active: bool = True self.is_active: bool = True
@ -96,24 +103,34 @@ 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.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.username}! #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.username}! #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
def test_auth(self) -> None: def test_auth(self) -> None:
@ -124,14 +141,99 @@ 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")
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)