diff --git a/README.rst b/README.rst index c252853..c128085 100644 --- a/README.rst +++ b/README.rst @@ -68,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__) @@ -89,6 +89,12 @@ 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 ---------------------------------------------------------------------------------- @@ -123,7 +129,7 @@ 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") @@ -132,6 +138,12 @@ In your ``my_app/views.py``: 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) @@ -152,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 @@ -178,6 +190,13 @@ 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 ---------------------------------------------------------------------------------- @@ -190,6 +209,8 @@ In your ``my_app/__init__.py``: 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) ... @@ -201,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 @@ -215,7 +236,8 @@ 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") @@ -224,6 +246,13 @@ In your ``my_app/views.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")) + def init_app(app: Flask) -> None: app.register_blueprint(bp) @@ -250,6 +279,15 @@ 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 ============= diff --git a/src/flask_digest_auth/auth.py b/src/flask_digest_auth/auth.py index 52af2b3..df94654 100644 --- a/src/flask_digest_auth/auth.py +++ b/src/flask_digest_auth/auth.py @@ -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.""" diff --git a/tests/test_auth.py b/tests/test_auth.py index 8d4ebf9..5467048 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -22,7 +22,7 @@ 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 @@ -89,6 +89,16 @@ class AuthenticationTestCase(TestCase): """ 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: @@ -146,3 +156,40 @@ class AuthenticationTestCase(TestCase): 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) diff --git a/tests/test_flask_login.py b/tests/test_flask_login.py index 4551c41..bb85e4a 100644 --- a/tests/test_flask_login.py +++ b/tests/test_flask_login.py @@ -21,7 +21,7 @@ import typing as t from secrets import token_urlsafe -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 @@ -121,6 +121,16 @@ class FlaskLoginTestCase(TestCase): """ 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: @@ -190,3 +200,40 @@ class FlaskLoginTestCase(TestCase): 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)