Compare commits

..

No commits in common. "65c3322ecc2e00432b5c950326d958de35a20957" and "8ec1e6fd028f726e6bbf5dcd76a42b62224ad760" have entirely different histories.

5 changed files with 46 additions and 329 deletions

View File

@ -6,30 +6,12 @@ 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. views. It works with Flask-Login_, so that log in protection can be
separated with the authentication mechanism. You can write Flask
modules that work with different authentication mechanisms.
HTTP Digest Authentication is specified in `RFC 2617`_.
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
@ -37,14 +19,14 @@ Flask modules without knowing the actual authentication mechanisms.
Installation Installation
============ ============
You can install Flask-Digest-Auth with ``pip``: It's suggested that you install 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`_.
:: ::
@ -52,7 +34,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
@ -68,7 +50,7 @@ In your ``my_app.py``:
:: ::
from flask import Flask, request, redirect from flask import Flask
from flask_digest_auth import DigestAuth from flask_digest_auth import DigestAuth
app: flask = Flask(__name__) app: flask = Flask(__name__)
@ -89,19 +71,13 @@ 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
@ -129,21 +105,15 @@ In your ``my_app/views.py``:
:: ::
from my_app import auth from my_app import auth
from flask import Flask, Blueprint, request, redirect from flask import Flask, Blueprint
bp = Blueprint("admin", __name__, url_prefix="/admin") bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/admin") @bp.get("/")
@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)
@ -164,7 +134,7 @@ In your ``my_app.py``:
:: ::
from flask import Flask, request, redirect 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
@ -190,27 +160,18 @@ 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) ...
@ -222,7 +183,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.realm = app.config["REALM"] auth: DigestAuth = DigestAuth(realm=app.config["REALM"])
auth.init_app(app) auth.init_app(app)
@auth.register_get_password @auth.register_get_password
@ -236,56 +197,23 @@ In your ``my_app/views.py``:
:: ::
import flask_login import flask_login
from flask import Flask, Blueprint, request, redirect from flask import Flask, Blueprint
from my_app import auth
bp = Blueprint("admin", __name__, url_prefix="/admin") bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.get("/admin") @bp.get("/")
@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 the actual The views only depend on Flask-Login, but not its underlying
authentication mechanism. You can change the actual authentication authentication mechanism. You can always change the
mechanism without changing the views. authentication mechanism without changing the views, or release a
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,8 +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, 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
@ -113,9 +112,6 @@ 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:
@ -256,24 +252,6 @@ 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,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

@ -22,9 +22,8 @@ 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, redirect, request from flask import Response, Flask, g
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
@ -71,34 +70,24 @@ 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("/admin-1/auth", endpoint="admin-1") @app.get("/login-required-1/auth", endpoint="auth-1")
@auth.login_required @auth.login_required
def admin_1() -> str: def login_required_1() -> str:
"""The first administration section. """The first dummy view.
:return: The response. :return: The response.
""" """
return f"Hello, {g.user.username}! #1" return f"Hello, {g.user.username}! #1"
@app.get("/admin-2/auth", endpoint="admin-2") @app.get("/login-required-2/auth", endpoint="auth-2")
@auth.login_required @auth.login_required
def admin_2() -> str: def login_required_2() -> str:
"""The second administration section. """The second dummy view.
:return: The response. :return: The response.
""" """
return f"Hello, {g.user.username}! #2" return f"Hello, {g.user.username}! #2"
@app.post("/logout", 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:
@ -106,90 +95,14 @@ class AuthenticationTestCase(TestCase):
:return: None. :return: None.
""" """
response: Response = self.client.get(self.app.url_for("admin-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(
self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD)) self.app.url_for("auth-1"), digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.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("admin-2")) response: Response = self.client.get(self.app.url_for("auth-2"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.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,9 +21,8 @@
import typing as t import typing as t
from secrets import token_urlsafe from secrets import token_urlsafe
from flask import Response, Flask, g, redirect, request from flask import Response, Flask
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
@ -33,13 +32,7 @@ _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
@ -103,34 +96,24 @@ 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("/admin-1/auth", endpoint="admin-1") @app.get("/login-required-1/auth", endpoint="auth-1")
@flask_login.login_required @flask_login.login_required
def admin_1() -> str: def login_required_1() -> str:
"""The first administration section. """The first dummy view.
:return: The response. :return: The response.
""" """
return f"Hello, {flask_login.current_user.username}! #1" return f"Hello, {flask_login.current_user.username}! #1"
@app.get("/admin-2/auth", endpoint="admin-2") @app.get("/login-required-2/auth", endpoint="auth-2")
@flask_login.login_required @flask_login.login_required
def admin_2() -> str: def login_required_2() -> str:
"""The second administration section. """The second dummy view.
:return: The response. :return: The response.
""" """
return f"Hello, {flask_login.current_user.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:
@ -141,99 +124,14 @@ 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("admin-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(
self.app.url_for("admin-1"), digest_auth=(_USERNAME, _PASSWORD)) self.app.url_for("auth-1"), digest_auth=(_USERNAME, _PASSWORD))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.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("admin-2")) response: Response = self.client.get(self.app.url_for("auth-2"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.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)