Compare commits

...

11 Commits

5 changed files with 329 additions and 46 deletions

View File

@ -6,12 +6,30 @@ Flask HTTP Digest Authentication
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
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.
views.
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-Login: https://flask-login.readthedocs.io
@ -19,14 +37,14 @@ modules that work with different authentication mechanisms.
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
`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
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
@ -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
app: flask = Flask(__name__)
@ -71,13 +89,19 @@ In your ``my_app.py``:
def admin():
... (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
----------------------------------------------------------------------------------
In your ``my_app/__init__.py``:
:::
::
from flask import Flask
from flask_digest_auth import DigestAuth
@ -105,15 +129,21 @@ In your ``my_app/views.py``:
::
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.get("/")
@bp.get("/admin")
@auth.login_required
def admin():
... (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:
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_login import LoginManager
@ -160,18 +190,27 @@ In your ``my_app.py``:
def admin():
... (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
----------------------------------------------------------------------------------
In your ``my_app/__init__.py``:
:::
::
from flask import Flask
from flask_digest_auth import DigestAuth
from flask_login import LoginManager
auth: DigestAuth = DigestAuth()
def create_app(test_config = None) -> Flask:
app: flask = Flask(__name__)
... (Configure the Flask application) ...
@ -183,7 +222,7 @@ In your ``my_app/__init__.py``:
def load_user(user_id: str) -> t.Optional[User]:
... (Load the user with the username) ...
auth: DigestAuth = DigestAuth(realm=app.config["REALM"])
auth.realm = app.config["REALM"]
auth.init_app(app)
@auth.register_get_password
@ -197,23 +236,56 @@ In your ``my_app/views.py``:
::
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.get("/")
@bp.get("/admin")
@flask_login.login_required
def admin():
... (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:
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.
The views only depend on Flask-Login, but not the actual
authentication mechanism. You can change the actual authentication
mechanism without changing the views.
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

View File

@ -27,7 +27,8 @@ from functools import wraps
from random import random
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 werkzeug.datastructures import Authorization
@ -112,6 +113,9 @@ class DigestAuth:
:return: None.
: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
if self.use_opaque:
if authorization.opaque is None:
@ -252,6 +256,24 @@ class DigestAuth:
raise ModuleNotFoundError(
"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:
"""The authorization state."""

View File

@ -49,13 +49,13 @@ class Client(WerkzeugClient):
return response
if hasattr(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])
response = super(Client, self).open(*args, auth=auth_data, **kwargs)
return response
@staticmethod
def __make_authorization(www_authenticate: WWWAuthenticate, uri: str,
def make_authorization(www_authenticate: WWWAuthenticate, uri: str,
username: str, password: str) -> Authorization:
"""Composes and returns the request authorization.

View File

@ -22,8 +22,9 @@ import typing as t
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 werkzeug.datastructures import WWWAuthenticate, Authorization
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 \
else None
@app.get("/login-required-1/auth", endpoint="auth-1")
@app.get("/admin-1/auth", endpoint="admin-1")
@auth.login_required
def login_required_1() -> str:
"""The first dummy view.
def admin_1() -> str:
"""The first administration section.
:return: The response.
"""
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
def login_required_2() -> str:
"""The second dummy view.
def admin_2() -> str:
"""The second administration section.
:return: The response.
"""
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
def test_auth(self) -> None:
@ -95,14 +106,90 @@ class AuthenticationTestCase(TestCase):
: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)
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.data.decode("UTF-8"),
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.data.decode("UTF-8"),
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
from secrets import token_urlsafe
from flask import Response, Flask
from flask import Response, Flask, g, redirect, request
from flask_testing import TestCase
from werkzeug.datastructures import WWWAuthenticate, Authorization
from flask_digest_auth import DigestAuth, make_password_hash, Client
@ -32,7 +33,13 @@ _PASSWORD: str = "Circle Of Life"
class User:
"""A dummy user."""
def __init__(self, username: str):
"""Constructs a dummy user.
:param username: The username.
"""
self.username: str = username
self.is_authenticated: 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
@app.get("/login-required-1/auth", endpoint="auth-1")
@app.get("/admin-1/auth", endpoint="admin-1")
@flask_login.login_required
def login_required_1() -> str:
"""The first dummy view.
def admin_1() -> str:
"""The first administration section.
:return: The response.
"""
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
def login_required_2() -> str:
"""The second dummy view.
def admin_2() -> str:
"""The second administration section.
:return: The response.
"""
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
def test_auth(self) -> None:
@ -124,14 +141,99 @@ class FlaskLoginTestCase(TestCase):
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("admin-1"))
self.assertEqual(response.status_code, 401)
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.data.decode("UTF-8"),
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.data.decode("UTF-8"),
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)