diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e01f7523..f6ed65fa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,24 +26,15 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.8, 3.9] - requirements-level: [min, pypi] + python-version: ["3.9", "3.10", "3.11", "3.12"] + requirements-level: [pypi] cache-service: [redis] - db-service: [postgresql14, postgresql13, mysql8] - exclude: - - python-version: 3.8 - requirements-level: min - - - python-version: 3.9 - requirements-level: min + db-service: [postgresql14, mysql8] include: - db-service: postgresql14 DB_EXTRAS: "postgresql" - - db-service: postgresql13 - DB_EXTRAS: "postgresql" - - db-service: mysql8 DB_EXTRAS: "mysql" @@ -53,28 +44,15 @@ jobs: EXTRAS: tests,admin,${{ matrix.DB_EXTRAS }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: setup.cfg - - name: Generate dependencies - run: | - python -m pip install --upgrade pip setuptools py wheel requirements-builder - requirements-builder -e "$EXTRAS" --level=${{ matrix.requirements-level }} setup.py > .${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt - - - name: Cache pip - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('.${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt') }} - - # TODO: Circular dependency. Invenio-Admin brings on Invenio-Accounts - # v1.x which brings Flask-Security which conflicts with - # Flask-Security-Invenio. - # pip install -r .${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt - name: Install dependencies run: | pip install ".[$EXTRAS]" diff --git a/docs/conf.py b/docs/conf.py index b620ac5c..3858a3ec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,18 +2,13 @@ # # This file is part of Invenio. # Copyright (C) 2015-2018 CERN. -# Copyright (C) 2022 Graz University of Technology. +# Copyright (C) 2022-2023 Graz University of Technology. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. """Sphinx configuration.""" -import os -import sys - -import sphinx.environment - from invenio_accounts import __version__ # -- General configuration ------------------------------------------------ @@ -38,6 +33,11 @@ celery_task_prefix = "()" +nitpick_ignore = [ + ("py:attr", "Meta"), +] + + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -324,8 +324,8 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - "https://docs.python.org/": None, - "http://pythonhosted.org/simplekv": None, + "python": ("https://docs.python.org/", None), + "simplekv": ("http://pythonhosted.org/simplekv", None), } # Autodoc configuraton. diff --git a/invenio_accounts/ext.py b/invenio_accounts/ext.py index 88d4eacf..74952c6e 100644 --- a/invenio_accounts/ext.py +++ b/invenio_accounts/ext.py @@ -3,6 +3,7 @@ # This file is part of Invenio. # Copyright (C) 2015-2024 CERN. # Copyright (C) 2021 TU Wien. +# Copyright (C) 2023-2024 Graz University of Technology. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -15,9 +16,12 @@ from flask import Blueprint, abort, current_app, request_finished, session from flask_kvsession import KVSessionExtension from flask_login import LoginManager, user_logged_in, user_logged_out +from flask_menu import current_menu from flask_principal import AnonymousIdentity from flask_security import Security, user_confirmed from invenio_db import db +from invenio_i18n import lazy_gettext as _ +from invenio_theme.proxies import current_theme_icons from passlib.registry import register_crypt_handler from werkzeug.utils import cached_property @@ -250,11 +254,6 @@ def _enable_session_activity(self, app): user_logged_in.connect(csrf_token_reset, app) user_logged_out.connect(logout_listener, app) user_logged_out.connect(csrf_token_reset, app) - from .views.security import revoke_session, security - from .views.settings import blueprint - - blueprint.route("/security/", methods=["GET"])(security) - blueprint.route("/sessions/revoke/", methods=["POST"])(revoke_session) class InvenioAccountsREST(InvenioAccounts): @@ -333,3 +332,63 @@ def make_session_permanent(self, app): @app.before_request def make_session_permanent(): session.permanent = True + + +def finalize_app(app): + """Finalize app.""" + set_default_config(app) + check_security_settings(app) + init_menu(app) + + +def set_default_config(app): + """Set default values.""" + app.config.setdefault( + "ACCOUNTS_SITENAME", app.config.get("THEME_SITENAME", "Invenio") + ) + app.config.setdefault( + "ACCOUNTS_BASE_TEMPLATE", + app.config.get("BASE_TEMPLATE", "invenio_accounts/base.html"), + ) + app.config.setdefault( + "ACCOUNTS_COVER_TEMPLATE", + app.config.get("COVER_TEMPLATE", "invenio_accounts/base_cover.html"), + ) + app.config.setdefault( + "ACCOUNTS_SETTINGS_TEMPLATE", + app.config.get("SETTINGS_TEMPLATE", "invenio_accounts/settings/base.html"), + ) + + +def check_security_settings(app): + """Warn if session cookie is not secure in production.""" + in_production = not (app.debug or app.testing) + secure = app.config.get("SESSION_COOKIE_SECURE") + if in_production and not secure: + app.logger.warning( + "SESSION_COOKIE_SECURE setting must be set to True to prevent the " + "session cookie from being leaked over an insecure channel." + ) + + +def init_menu(app): + """Init menu.""" + current_menu.submenu("settings.security").register( + endpoint="invenio_accounts.security", + text=_( + "%(icon)s Security", icon=f'' + ), + order=2, + ) + + # - Register menu + # - Change password + if app.config.get("SECURITY_CHANGEABLE", True): + current_menu.submenu("settings.change_password").register( + endpoint=f"{app.config['SECURITY_BLUEPRINT_NAME']}.change_password", + text=_( + "%(icon)s Change password", + icon=f'', + ), + order=1, + ) diff --git a/invenio_accounts/views/__init__.py b/invenio_accounts/views/__init__.py index fceb5214..6d02eea4 100644 --- a/invenio_accounts/views/__init__.py +++ b/invenio_accounts/views/__init__.py @@ -9,26 +9,7 @@ """Invenio-accounts views.""" -from flask import abort, current_app, request -from flask_security.views import anonymous_user_required -from flask_security.views import login as base_login -from .settings import blueprint +from .settings import login - -@anonymous_user_required -@blueprint.route("/login") -def login(*args, **kwargs): - """Disable login credential submission if local login is disabled.""" - local_login_enabled = current_app.config.get("ACCOUNTS_LOCAL_LOGIN_ENABLED", True) - - login_form_submitted = request.method == "POST" - if login_form_submitted and not local_login_enabled: - # only allow GET requests, - # avoid credential submission/login via POST - abort(404) - - return base_login(*args, **kwargs) - - -__all__ = ("blueprint", "login") +__all__ = ("login",) diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py index c0bdd080..a539774b 100644 --- a/invenio_accounts/views/rest.py +++ b/invenio_accounts/views/rest.py @@ -77,7 +77,7 @@ def role_to_dict(role): ) -def create_blueprint(app): +def create_rest_blueprint(app): """Conditionally creates the blueprint.""" blueprint = Blueprint("invenio_accounts_rest_auth", __name__) diff --git a/invenio_accounts/views/security.py b/invenio_accounts/views/security.py index 2d81035e..29e11b80 100644 --- a/invenio_accounts/views/security.py +++ b/invenio_accounts/views/security.py @@ -9,35 +9,16 @@ """Invenio user management and authentication.""" from flask import abort, current_app, flash, redirect, render_template, request, url_for -from flask_breadcrumbs import register_breadcrumb from flask_login import login_required -from flask_menu import register_menu from flask_security import current_user from invenio_db import db -from invenio_i18n import lazy_gettext as _ -from invenio_theme.proxies import current_theme_icons -from speaklater import make_lazy_string from ..forms import RevokeForm from ..models import SessionActivity from ..sessions import delete_session -from .settings import blueprint @login_required -@register_menu( - blueprint, - "settings.security", - # NOTE: Menu item text (icon replaced by a user icon). - _( - "%(icon)s Security", - icon=make_lazy_string( - lambda: ''.format(icon=current_theme_icons.shield) - ), - ), - order=2, -) -@register_breadcrumb(blueprint, "breadcrumbs.settings.security", _("Security")) def security(): """View for security page.""" sessions = SessionActivity.query_by_user(user_id=current_user.get_id()).all() diff --git a/invenio_accounts/views/settings.py b/invenio_accounts/views/settings.py index a452e5b6..f553d5ba 100644 --- a/invenio_accounts/views/settings.py +++ b/invenio_accounts/views/settings.py @@ -2,97 +2,50 @@ # # This file is part of Invenio. # Copyright (C) 2015-2018 CERN. +# Copyright (C) 2024 Graz University of Technology. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. """Invenio user management and authentication.""" -from flask import Blueprint, current_app -from flask_breadcrumbs import register_breadcrumb -from flask_menu import current_menu -from invenio_i18n import lazy_gettext as _ -from invenio_theme.proxies import current_theme_icons +from flask import Blueprint, abort, current_app, request +from flask_security.views import anonymous_user_required +from flask_security.views import login as base_login -blueprint = Blueprint( - "invenio_accounts", - __name__, - url_prefix="/account/settings", - template_folder="../templates", - static_folder="static", -) +from .security import revoke_session, security -@blueprint.record_once -def post_ext_init(state): - """.""" - app = state.app +@anonymous_user_required +def login(*args, **kwargs): + """Disable login credential submission if local login is disabled.""" + local_login_enabled = current_app.config.get("ACCOUNTS_LOCAL_LOGIN_ENABLED", True) - app.config.setdefault( - "ACCOUNTS_SITENAME", app.config.get("THEME_SITENAME", "Invenio") - ) - app.config.setdefault( - "ACCOUNTS_BASE_TEMPLATE", - app.config.get("BASE_TEMPLATE", "invenio_accounts/base.html"), - ) - app.config.setdefault( - "ACCOUNTS_COVER_TEMPLATE", - app.config.get("COVER_TEMPLATE", "invenio_accounts/base_cover.html"), - ) - app.config.setdefault( - "ACCOUNTS_SETTINGS_TEMPLATE", - app.config.get("SETTINGS_TEMPLATE", "invenio_accounts/settings/base.html"), - ) + login_form_submitted = request.method == "POST" + if login_form_submitted and not local_login_enabled: + # only allow GET requests, + # avoid credential submission/login via POST + abort(404) + return base_login(*args, **kwargs) -@blueprint.before_app_first_request -def init_menu(): - """Initialize menu before first request.""" - # Register root breadcrumbs - item = current_menu.submenu("breadcrumbs.settings") - item.register("invenio_userprofiles.profile", _("Account")) - if current_app.config.get("ACCOUNTS_REGISTER_BLUEPRINT") is False: - return - # - Register menu - # - Change password - if current_app.config.get("SECURITY_CHANGEABLE", True): - view_name = "{}.change_password".format( - current_app.config["SECURITY_BLUEPRINT_NAME"] - ) +def create_settings_blueprint(app): + """Create settings blueprint.""" + blueprint = Blueprint( + "invenio_accounts", + __name__, + url_prefix="/account/settings", + template_folder="../templates", + static_folder="static", + ) - item = current_menu.submenu("settings.change_password") - item.register( - view_name, - # NOTE: Menu item text (icon replaced by a key icon). - _( - "%(icon)s Change password", - icon=(''.format(icon=current_theme_icons.key)), - ), - order=1, - ) + blueprint.add_url_rule("/login", view_func=login) - # Breadcrumb for change password - # - # The breadcrumbs works by decorating the view functions with a - # __breadcrumb__ field. Since the change password view is defined in - # Flask-Security, we need to this hack to in order to decorate the view - # function with the __breadcrumb__ field. - decorator = register_breadcrumb( - current_app, "breadcrumbs.settings.change_password", _("Change password") - ) - current_app.view_functions[view_name] = decorator( - current_app.view_functions[view_name] + if app.config["ACCOUNTS_SESSION_ACTIVITY_ENABLED"]: + blueprint.add_url_rule("/security", view_func=security, methods=["GET"]) + blueprint.add_url_rule( + "/sessions/revoke", view_func=revoke_session, methods=["POST"] ) - -@blueprint.before_app_first_request -def check_security_settings(): - """Warn if session cookie is not secure in production.""" - in_production = not (current_app.debug or current_app.testing) - secure = current_app.config.get("SESSION_COOKIE_SECURE") - if in_production and not secure: - current_app.logger.warning( - "SESSION_COOKIE_SECURE setting must be set to True to prevent the " - "session cookie from being leaked over an insecure channel." - ) + return blueprint diff --git a/setup.cfg b/setup.cfg index 9ba350fe..ee453cf0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -71,9 +71,11 @@ invenio_base.api_apps = invenio_base.apps = invenio_accounts_ui = invenio_accounts:InvenioAccountsUI invenio_base.blueprints = - invenio_accounts = invenio_accounts.views.settings:blueprint + invenio_accounts = invenio_accounts.views.settings:create_settings_blueprint invenio_base.api_blueprints = - invenio_accounts_rest_auth = invenio_accounts.views.rest:create_blueprint + invenio_accounts_rest_auth = invenio_accounts.views.rest:create_rest_blueprint +invenio_base.finalize_app = + invenio_accounts = invenio_accounts.ext:finalize_app invenio_celery.tasks = invenio_accounts = invenio_accounts.tasks invenio_db.alembic = diff --git a/tests/conftest.py b/tests/conftest.py index 0bee29e9..a8fb3a9f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,17 +18,49 @@ from flask_admin import Admin from flask_celeryext import FlaskCeleryExt from flask_mail import Mail -from flask_menu import Menu +from flask_webpackext.manifest import ( + JinjaManifest, + JinjaManifestEntry, + JinjaManifestLoader, +) +from invenio_assets import InvenioAssets from invenio_db import InvenioDB, db from invenio_i18n import Babel, InvenioI18N from invenio_rest import InvenioREST +from invenio_theme import InvenioTheme from simplekv.memory.redisstore import RedisStore from sqlalchemy_utils.functions import create_database, database_exists, drop_database +from webargs import fields +from werkzeug.exceptions import NotFound from invenio_accounts import InvenioAccounts, InvenioAccountsREST from invenio_accounts.admin import role_adminview, session_adminview, user_adminview from invenio_accounts.testutils import create_test_user -from invenio_accounts.views.rest import create_blueprint +from invenio_accounts.views.rest import RegisterView, create_rest_blueprint, use_kwargs +from invenio_accounts.views.settings import create_settings_blueprint + + +# +# Mock the webpack manifest to avoid having to compile the full assets. +# +class MockJinjaManifest(JinjaManifest): + """Mock manifest.""" + + def __getitem__(self, key): + """Get a manifest entry.""" + return JinjaManifestEntry(key, [key]) + + def __getattr__(self, name): + """Get a manifest entry.""" + return JinjaManifestEntry(name, [name]) + + +class MockManifestLoader(JinjaManifestLoader): + """Manifest loader creating a mocked manifest.""" + + def load(self, filepath): + """Load the manifest.""" + return MockJinjaManifest() def _app_factory(config=None): @@ -81,14 +113,24 @@ def _app_factory(config=None): SERVER_NAME="example.com", TESTING=True, WTF_CSRF_ENABLED=False, + ACCOUNTS_SITENAME="invenio", + ACCOUNTS_BASE_TEMPLATE="invenio_accounts/base.html", + ACCOUNTS_SETTINGS_TEMPLATE="invenio_accounts/settings/base.html", + ACCOUNTS_COVER_TEMPLATE="invenio_accounts/base_cover.html", + WEBPACKEXT_MANIFEST_LOADER=MockManifestLoader, ) app.config.update(config or {}) - Menu(app) Babel(app) Mail(app) InvenioDB(app) InvenioI18N(app) + InvenioAssets(app) + InvenioTheme(app) + + # it overrides the custom error handler set by InvenioTheme, by setting it + # back to the default 404 error handler. + app.register_error_handler(404, NotFound) def delete_user_from_cache(exception): """Delete user from `flask.g` when the request is tearing down. @@ -150,9 +192,7 @@ def app(request): app.config.update(ACCOUNTS_USERINFO_HEADERS=True) InvenioAccounts(app) - from invenio_accounts.views.settings import blueprint - - app.register_blueprint(blueprint) + app.register_blueprint(create_settings_blueprint(app)) _database_setup(app, request) yield app @@ -173,7 +213,7 @@ def api(request): InvenioREST(api_app) InvenioAccountsREST(api_app) - api_app.register_blueprint(create_blueprint(api_app)) + api_app.register_blueprint(create_rest_blueprint(api_app)) _database_setup(api_app, request) @@ -187,9 +227,7 @@ def app_with_redis_url(request): app.config.update(ACCOUNTS_USERINFO_HEADERS=True) InvenioAccounts(app) - from invenio_accounts.views.settings import blueprint - - app.register_blueprint(blueprint) + app.register_blueprint(create_settings_blueprint(app)) _database_setup(app, request) yield app @@ -198,9 +236,6 @@ def app_with_redis_url(request): @pytest.fixture() def app_with_flexible_registration(request): """Flask application fixture with Invenio Accounts.""" - from webargs import fields - - from invenio_accounts.views.rest import RegisterView, use_kwargs class MyRegisterView(RegisterView): post_args = {**RegisterView.post_args, "active": fields.Boolean(required=True)} @@ -216,7 +251,7 @@ def post(self, **kwargs): api_app.config["ACCOUNTS_REST_AUTH_VIEWS"]["register"] = MyRegisterView - api_app.register_blueprint(create_blueprint(api_app)) + api_app.register_blueprint(create_rest_blueprint(api_app)) _database_setup(api_app, request) yield api_app diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 336afdbb..41a0c2c0 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -24,7 +24,7 @@ from sqlalchemy_utils.functions import create_database, database_exists, drop_database from invenio_accounts import InvenioAccounts -from invenio_accounts.views.settings import blueprint +from invenio_accounts.views.settings import create_settings_blueprint @pytest.fixture(scope="session") @@ -56,7 +56,7 @@ def app(request): Mail(app) InvenioDB(app) InvenioAccounts(app) - app.register_blueprint(blueprint) + app.register_blueprint(create_settings_blueprint(app)) with app.app_context(): if not database_exists(str(db.engine.url)): diff --git a/tests/test_invenio_accounts.py b/tests/test_invenio_accounts.py index 428008b3..aa458ea5 100644 --- a/tests/test_invenio_accounts.py +++ b/tests/test_invenio_accounts.py @@ -3,6 +3,7 @@ # This file is part of Invenio. # Copyright (C) 2015-2023 CERN. # Copyright (C) 2021 TU Wien. +# Copyright (C) 2024 Graz University of Technology. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -20,7 +21,7 @@ from invenio_accounts import InvenioAccounts, InvenioAccountsREST, testutils from invenio_accounts.models import Role, User -from invenio_accounts.views.settings import blueprint +from invenio_accounts.views.settings import create_settings_blueprint def test_version(): @@ -114,8 +115,8 @@ def test_accounts_settings_blueprint(base_app): app.config["ACCOUNTS_REGISTER_BLUEPRINT"] = False InvenioAccounts(app) # register settings blueprint - app.register_blueprint(blueprint) - + # app.register_blueprint(blueprint) + create_settings_blueprint(app) with app.app_context(): with app.test_client() as client: client.get("/account/settings") diff --git a/tests/test_views.py b/tests/test_views.py index 07dd96f9..e5ad772a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -44,19 +44,19 @@ def test_no_log_in_message_for_logged_in_users(app): test_password = "test1234" resp = client.post( url_for_security("register"), - data=dict( - email=test_email, - password=test_password, - ), + data={ + "email": test_email, + "password": test_password, + }, environ_base={"REMOTE_ADDR": "127.0.0.1"}, ) resp = client.post( url_for_security("login"), - data=dict( - email=test_email, - password=test_password, - ), + data={ + "email": test_email, + "password": test_password, + }, ) resp = client.get(forgot_password_url, follow_redirects=True)