diff --git a/src/accounting/utils/permission.py b/src/accounting/utils/permission.py index 11a4c71..071319a 100644 --- a/src/accounting/utils/permission.py +++ b/src/accounting/utils/permission.py @@ -21,7 +21,7 @@ This module should not import any other module from the application. """ import typing as t -from flask import abort, Blueprint +from flask import abort, Blueprint, Response from accounting.utils.user import get_current_user, UserUtilityInterface @@ -49,6 +49,10 @@ def has_permission(rule: t.Callable[[], bool]) -> t.Callable: :raise Forbidden: When the user is denied. """ if not rule(): + if get_current_user() is None: + response: Response | None = _unauthorized_func() + if response is not None: + return response abort(403) return view(*args, **kwargs) @@ -66,6 +70,9 @@ data.""" __can_admin_func: t.Callable[[], bool] = lambda: True """The callback that returns whether the current user can administrate the accounting settings.""" +_unauthorized_func: t.Callable[[], Response | None] \ + = lambda: Response(status=403) +"""The callback that returns the response to require the user to log in.""" def can_view() -> bool: @@ -111,10 +118,12 @@ def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None: :param user_utils: The user utilities. :return: None. """ - global __can_view_func, __can_edit_func, __can_admin_func + global __can_view_func, __can_edit_func, __can_admin_func, \ + _unauthorized_func __can_view_func = user_utils.can_view __can_edit_func = user_utils.can_edit __can_admin_func = user_utils.can_admin + _unauthorized_func = user_utils.unauthorized bp.add_app_template_global(user_utils.can_view, "accounting_can_view") bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit") bp.add_app_template_global(user_utils.can_admin, "accounting_can_admin") diff --git a/src/accounting/utils/user.py b/src/accounting/utils/user.py index 63b0f3a..d1d07fa 100644 --- a/src/accounting/utils/user.py +++ b/src/accounting/utils/user.py @@ -23,7 +23,7 @@ import typing as t from abc import ABC, abstractmethod import sqlalchemy as sa -from flask import g +from flask import g, Response from flask_sqlalchemy.model import Model T = t.TypeVar("T", bound=Model) @@ -59,6 +59,17 @@ class UserUtilityInterface(t.Generic[T], ABC): accounting settings, or False otherwise. """ + @abstractmethod + def unauthorized(self) -> Response | None: + """Returns the response to require the user to log in. + + This may be a redirection to the login page, or an HTTP 401 + Unauthorized response for HTTP Authentication. If this returns None, + an HTTP 403 Forbidden response is return to the user. + + :return: The response to require the user to log in. + """ + @property @abstractmethod def cls(self) -> t.Type[T]: diff --git a/tests/test_site/__init__.py b/tests/test_site/__init__.py index e1550dc..95e610a 100644 --- a/tests/test_site/__init__.py +++ b/tests/test_site/__init__.py @@ -22,7 +22,7 @@ import typing as t from secrets import token_urlsafe import click -from flask import Flask, Blueprint, render_template +from flask import Flask, Blueprint, render_template, redirect, Response from flask.cli import with_appcontext from flask_babel_js import BabelJS from flask_sqlalchemy import SQLAlchemy @@ -82,6 +82,9 @@ def create_app(is_testing: bool = False) -> Flask: return auth.current_user() is not None \ and auth.current_user().username == "admin" + def unauthorized(self) -> Response: + return redirect("/login") + @property def cls(self) -> t.Type[auth.User]: return auth.User