diff --git a/setup.cfg b/setup.cfg index 6272ab6..9b93063 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,10 +40,14 @@ package_dir = packages = find: python_requires = >=3.10 install_requires = - flask-login + flask tests_require = unittest flask-testing [options.packages.find] where = src + +[options.extras_require] +flask_login = + flask-login diff --git a/src/flask_digest_auth/__init__.py b/src/flask_digest_auth/__init__.py index 83186a7..d645b49 100644 --- a/src/flask_digest_auth/__init__.py +++ b/src/flask_digest_auth/__init__.py @@ -20,5 +20,4 @@ """ from flask_digest_auth.algo import make_password_hash, calc_response from flask_digest_auth.auth import DigestAuth -from flask_digest_auth.flask_login import init_login_manager from flask_digest_auth.test import Client diff --git a/src/flask_digest_auth/auth.py b/src/flask_digest_auth/auth.py index 05df6dd..52af2b3 100644 --- a/src/flask_digest_auth/auth.py +++ b/src/flask_digest_auth/auth.py @@ -27,7 +27,7 @@ from functools import wraps from random import random from secrets import token_urlsafe -from flask import g, request, Response, session, abort +from flask import g, request, Response, session, abort, Flask, Request from itsdangerous import URLSafeTimedSerializer, BadData from werkzeug.datastructures import Authorization @@ -195,6 +195,63 @@ class DigestAuth: """ self.__get_user = func + def init_app(self, app: Flask) -> None: + """Initializes the Flask application. + + :param app: The Flask application. + :return: None. + """ + + try: + 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.unauthorized_handler + def unauthorized() -> None: + """Handles when the user is unauthorized. + + :return: None. + """ + response: Response = Response() + response.status = 401 + response.headers["WWW-Authenticate"] \ + = self.make_response_header(g.digest_auth_state) + abort(response) + + @login_manager.request_loader + def load_user_from_request(req: Request) -> t.Optional[t.Any]: + """Loads the user from the request header. + + :param req: The request. + :return: The authenticated user, or None if the + authentication fails + """ + g.digest_auth_state = AuthState() + authorization: Authorization = req.authorization + try: + if authorization is None: + raise UnauthorizedException + if authorization.type != "digest": + raise UnauthorizedException( + "Not an HTTP digest authorization") + self.authenticate(g.digest_auth_state) + user = login_manager.user_callback( + authorization.username) + login_user(user) + return user + except UnauthorizedException as e: + if str(e) != "": + app.logger.warning(str(e)) + return None + + except ModuleNotFoundError: + raise ModuleNotFoundError( + "init_app() is only for Flask-Login integration") + class AuthState: """The authorization state.""" diff --git a/src/flask_digest_auth/flask_login.py b/src/flask_digest_auth/flask_login.py deleted file mode 100644 index 65358ca..0000000 --- a/src/flask_digest_auth/flask_login.py +++ /dev/null @@ -1,74 +0,0 @@ -# The Flask HTTP Digest Authentication Project. -# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/14 - -# Copyright (c) 2022 imacat. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The Flask-Login integration. - -""" - -import typing as t - -from flask import Response, abort, current_app, Request, g -from flask_login import LoginManager, login_user -from werkzeug.datastructures import Authorization - -from flask_digest_auth.auth import DigestAuth, AuthState -from flask_digest_auth.exception import UnauthorizedException - - -def init_login_manager(auth: DigestAuth, login_manager: LoginManager) -> None: - """Initialize the login manager. - - :param auth: The HTTP digest authentication. - :param login_manager: The login manager from FlaskLogin. - :return: None. - """ - - @login_manager.unauthorized_handler - def unauthorized() -> None: - """Handles when the user is unauthorized. - - :return: None. - """ - response: Response = Response() - response.status = 401 - response.headers["WWW-Authenticate"] = auth.make_response_header( - g.digest_auth_state) - abort(response) - - @login_manager.request_loader - def load_user_from_request(request: Request) -> t.Optional[t.Any]: - """Loads the user from the request header. - - :param request: The request. - :return: The authenticated user, or None if the authentication fails - """ - g.digest_auth_state = AuthState() - authorization: Authorization = request.authorization - try: - if authorization is None: - raise UnauthorizedException - if authorization.type != "digest": - raise UnauthorizedException( - "Not an HTTP digest authorization") - auth.authenticate(g.digest_auth_state) - user = login_manager.user_callback(authorization.username) - login_user(user) - return user - except UnauthorizedException as e: - if str(e) != "": - current_app.logger.warning(str(e)) - return None diff --git a/tests/test_flask_login.py b/tests/test_flask_login.py index ed5a040..1caa313 100644 --- a/tests/test_flask_login.py +++ b/tests/test_flask_login.py @@ -19,15 +19,13 @@ """ import typing as t +import unittest from secrets import token_urlsafe -import flask_login from flask import Response, Flask -from flask_login import LoginManager from flask_testing import TestCase -from flask_digest_auth import DigestAuth, make_password_hash, Client, \ - init_login_manager +from flask_digest_auth import DigestAuth, make_password_hash, Client _REALM: str = "testrealm@host.com" _USERNAME: str = "Mufasa" @@ -53,7 +51,7 @@ class User: class FlaskLoginTestCase(TestCase): """The test case with the Flask-Login integration.""" - def create_app(self): + def create_app(self) -> Flask: """Creates the Flask application. :return: The Flask application. @@ -65,11 +63,18 @@ class FlaskLoginTestCase(TestCase): }) app.test_client_class = Client - login_manager: LoginManager = LoginManager() + self.has_flask_login: bool = True + try: + import flask_login + except ModuleNotFoundError: + self.has_flask_login = False + return app + + login_manager: flask_login.LoginManager = flask_login.LoginManager() login_manager.init_app(app) auth: DigestAuth = DigestAuth(realm=_REALM) - init_login_manager(auth, login_manager) + auth.init_app(app) user_db: t.Dict[str, str] \ = {_USERNAME: make_password_hash(_REALM, _USERNAME, _PASSWORD)} @@ -117,6 +122,9 @@ class FlaskLoginTestCase(TestCase): :return: None. """ + 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")) self.assertEqual(response.status_code, 401) response = self.client.get(