diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index f17e46c65a28..b17ab6ad2387 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -153,17 +153,15 @@ async def logout(_body: LogoutBody): @router.get( "/auth:check", - operation_id="check_authentication", status_code=status.HTTP_204_NO_CONTENT, responses={ status.HTTP_401_UNAUTHORIZED: { "model": EnvelopedError, - "description": "unauthorized reset due to invalid token code", } }, ) async def check_auth(): - """checks if user is authenticated in the platform""" + """checks whether user request is authenticated""" @router.post( diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 106d4ac00050..a868f07b12a2 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.69.0 +0.69.1 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 19db53dcef80..8c57c75f43ca 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.69.0 +current_version = 0.69.1 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index db70d6dab521..52ad898b1d26 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.69.0 + version: 0.69.1 servers: - url: '' description: webserver @@ -238,13 +238,13 @@ paths: tags: - auth summary: Check Auth - description: checks if user is authenticated in the platform - operationId: check_authentication + description: checks whether user request is authenticated + operationId: check_auth responses: '204': description: Successful Response '401': - description: unauthorized reset due to invalid token code + description: Unauthorized content: application/json: schema: diff --git a/services/web/server/src/simcore_service_webserver/application.py b/services/web/server/src/simcore_service_webserver/application.py index a4ce13d2bc0c..4af3a5c27e40 100644 --- a/services/web/server/src/simcore_service_webserver/application.py +++ b/services/web/server/src/simcore_service_webserver/application.py @@ -29,6 +29,7 @@ from .invitations.plugin import setup_invitations from .licenses.plugin import setup_licenses from .login.plugin import setup_login +from .login_auth.plugin import setup_login_auth from .long_running_tasks import setup_long_running_tasks from .notifications.plugin import setup_notifications from .payments.plugin import setup_payments @@ -169,6 +170,25 @@ def create_application() -> web.Application: return app +def create_application_auth() -> web.Application: + app = create_safe_application() + setup_settings(app) + setup_rest(app) + setup_db(app) + + setup_login_auth(app) + + # NOTE: *last* events + app.on_startup.append(_welcome_banner) + app.on_shutdown.append(_finished_banner) + + _logger.debug( + "Routes in application-auth: \n %s", pformat(app.router.named_resources()) + ) + + return app + + def run_service(app: web.Application, config: dict[str, Any]): web.run_app( app, @@ -177,9 +197,3 @@ def run_service(app: web.Application, config: dict[str, Any]): # this gets overriden by the gunicorn config in /docker/boot.sh access_log_format='%a %t "%r" %s %b --- [%Dus] "%{Referer}i" "%{User-Agent}i"', ) - - -__all__: tuple[str, ...] = ( - "create_application", - "run_service", -) diff --git a/services/web/server/src/simcore_service_webserver/application_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index 40747e7ac170..72aefe6a363b 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -1,6 +1,6 @@ import logging from functools import cached_property -from typing import Annotated, Any, Final +from typing import Annotated, Any, Final, Literal from aiohttp import web from common_library.basic_types import DEFAULT_FACTORY @@ -95,6 +95,13 @@ class ApplicationSettings(BaseApplicationSettings, MixinLoggingSettings): Field(None, description="Stack name defined upon deploy (see main Makefile)"), ] + WEBSERVER_APP_FACTORY_NAME: Annotated[ + Literal["WEBSERVER_FULL_APP_FACTORY", "WEBSERVER_AUTHZ_APP_FACTORY"], + Field( + description="Application factory to be lauched by the gunicorn server", + ), + ] = "WEBSERVER_FULL_APP_FACTORY" + WEBSERVER_DEV_FEATURES_ENABLED: Annotated[ bool, Field( diff --git a/services/web/server/src/simcore_service_webserver/cli.py b/services/web/server/src/simcore_service_webserver/cli.py index feec4b29e0cc..e911bf6d1753 100644 --- a/services/web/server/src/simcore_service_webserver/cli.py +++ b/services/web/server/src/simcore_service_webserver/cli.py @@ -1,4 +1,4 @@ -""" Application's command line . +"""Application's command line . Why does this file exist, and why not put this in __main__? @@ -15,13 +15,12 @@ import logging import os -from typing import Final +from typing import Annotated, Final import typer from aiohttp import web from common_library.json_serialization import json_dumps from settings_library.utils_cli import create_settings_command -from typing_extensions import Annotated from .application_settings import ApplicationSettings from .login import cli as login_cli @@ -42,7 +41,6 @@ def _setup_app_from_settings( # NOTE: keeping imports here to reduce CLI load time from .application import create_application from .application_settings_utils import convert_to_app_config - from .log import setup_logging # NOTE: By having an equivalent config allows us # to keep some of the code from the previous @@ -51,30 +49,39 @@ def _setup_app_from_settings( # given configs and changing those would not have # a meaningful RoI. config = convert_to_app_config(settings) - - setup_logging( - level=settings.log_level, - slow_duration=settings.AIODEBUG_SLOW_DURATION_SECS, - log_format_local_dev_enabled=settings.WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED, - logger_filter_mapping=settings.WEBSERVER_LOG_FILTER_MAPPING, - tracing_settings=settings.WEBSERVER_TRACING, - ) - app = create_application() return (app, config) async def app_factory() -> web.Application: """Created to launch app from gunicorn (see docker/boot.sh)""" + from .application import create_application_auth + from .log import setup_logging + app_settings = ApplicationSettings.create_from_envs() - assert app_settings.SC_BUILD_TARGET # nosec _logger.info( "Application settings: %s", json_dumps(app_settings, indent=2, sort_keys=True), ) - app, _ = _setup_app_from_settings(app_settings) + _logger.info( + "Using application factory: %s", app_settings.WEBSERVER_APP_FACTORY_NAME + ) + + setup_logging( + level=app_settings.log_level, + slow_duration=app_settings.AIODEBUG_SLOW_DURATION_SECS, + log_format_local_dev_enabled=app_settings.WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED, + logger_filter_mapping=app_settings.WEBSERVER_LOG_FILTER_MAPPING, + tracing_settings=app_settings.WEBSERVER_TRACING, + ) + + if app_settings.WEBSERVER_APP_FACTORY_NAME == "WEBSERVER_AUTHZ_APP_FACTORY": + + app = create_application_auth() + else: + app, _ = _setup_app_from_settings(app_settings) return app diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py index b0bdc0d59db5..ebb8fed1cb97 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py @@ -287,19 +287,3 @@ async def logout(request: web.Request) -> web.Response: await security_web.forget_identity(request, response) return response - - -@routes.get(f"/{API_VTAG}/auth:check", name="check_authentication") -@login_required -async def check_auth(request: web.Request) -> web.Response: - # lightweight endpoint for checking if users are authenticated - # used primarily by Traefik auth middleware to verify session cookies - - # NOTE: for future development - # if database access is added here, services like jupyter-math - # which load a lot of resources will have a big performance hit - # consider caching some properties required by this endpoint or rely on Redis - - assert request # nosec - - return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/login/decorators.py b/services/web/server/src/simcore_service_webserver/login/decorators.py index f8ee7eb28b97..011c25bbc083 100644 --- a/services/web/server/src/simcore_service_webserver/login/decorators.py +++ b/services/web/server/src/simcore_service_webserver/login/decorators.py @@ -1,73 +1,7 @@ -import functools -import inspect -from typing import cast +from ..login_auth.decorators import get_user_id, login_required -from aiohttp import web -from models_library.users import UserID -from servicelib.aiohttp.typing_extension import HandlerAnyReturn -from servicelib.request_keys import RQT_USERID_KEY - -from ..products import products_web -from ..security import security_web - - -def login_required(handler: HandlerAnyReturn) -> HandlerAnyReturn: - """Decorator that restrict access only for authorized users with permissions to access a given product - - - User is considered authorized if check_authorized(request) raises no exception - - If authorized, it injects user_id in request[RQT_USERID_KEY] - - Use this decorator instead of aiohttp_security.api.login_required! - - WARNING: Add always @router. decorator FIRST, e.g. - - @router.get("/foo") - @login_required - async def get_foo(request: web.Request): - ... - - and NOT as - - @login_required - @router.get("/foo") - async def get_foo(request: web.Request): - ... - - since the latter will register in `router` get_foo **without** `login_required` - """ - assert set(inspect.signature(handler).parameters.values()) == { # nosec - inspect.Parameter( - name="request", - kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, - annotation=web.Request, - ) - }, f"Expected {handler.__name__} with request as signature, got {handler.__annotations__}" - - @functools.wraps(handler) - async def _wrapper(request: web.Request): - """ - Raises: - HTTPUnauthorized: if unauthorized user - HTTPForbidden: if user not allowed in product - """ - # WARNING: note that check_authorized is patched in some tests. - # Careful when changing the function signature - user_id = await security_web.check_user_authorized(request) - product_name = products_web.get_product_name(request) - - await security_web.check_user_permission( - request, - security_web.PERMISSION_PRODUCT_LOGIN_KEY, - context=security_web.AuthContextDict( - product_name=product_name, - authorized_uid=user_id, - ), - ) - - request[RQT_USERID_KEY] = user_id - return await handler(request) - - return _wrapper - - -def get_user_id(request: web.Request) -> UserID: - return cast(UserID, request[RQT_USERID_KEY]) +__all__: tuple[str, ...] = ( + "get_user_id", + "login_required", +) +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/login/plugin.py b/services/web/server/src/simcore_service_webserver/login/plugin.py index fa7c7b452480..f5a03d7cb488 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -22,6 +22,7 @@ from ..email.plugin import setup_email from ..email.settings import get_plugin_settings as get_email_plugin_settings from ..invitations.plugin import setup_invitations +from ..login_auth.plugin import setup_login_auth from ..products import products_service from ..products.models import ProductName from ..products.plugin import setup_products @@ -65,13 +66,13 @@ async def _setup_login_storage_ctx(app: web.Application): yield # ---------------- -@ensure_single_setup(f"{__name__}.setup_login_storage", logger=log) +@ensure_single_setup(f"{__name__}.storage", logger=log) def setup_login_storage(app: web.Application): if _setup_login_storage_ctx not in app.cleanup_ctx: app.cleanup_ctx.append(_setup_login_storage_ctx) -@ensure_single_setup(f"{__name__}._setup_login_options", logger=log) +@ensure_single_setup(f"{__name__}.login_options", logger=log) def _setup_login_options(app: web.Application): settings: SMTPSettings = get_email_plugin_settings(app) @@ -145,6 +146,8 @@ def setup_login(app: web.Application): # routes app.router.add_routes(auth.routes) + setup_login_auth(app) + app.router.add_routes(confirmation.routes) app.router.add_routes(registration.routes) app.router.add_routes(preregistration.routes) diff --git a/services/web/server/src/simcore_service_webserver/login_auth/__init__.py b/services/web/server/src/simcore_service_webserver/login_auth/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/services/web/server/src/simcore_service_webserver/login_auth/_controller_rest.py b/services/web/server/src/simcore_service_webserver/login_auth/_controller_rest.py new file mode 100644 index 000000000000..8c22e32fac42 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login_auth/_controller_rest.py @@ -0,0 +1,30 @@ +import logging + +from aiohttp import web +from aiohttp.web import RouteTableDef +from servicelib.aiohttp import status + +from .._meta import API_VTAG +from .decorators import login_required + +_logger = logging.getLogger(__name__) + + +routes = RouteTableDef() + + +@routes.get(f"/{API_VTAG}/auth:check", name="check_auth") +@login_required +async def check_auth(request: web.Request) -> web.Response: + """Lightweight endpoint for checking if users are authenticated & authorized to this product + + Used primarily by Traefik auth middleware to verify session cookies + SEE https://doc.traefik.io/traefik/middlewares/http/forwardauth + """ + # NOTE: for future development + # if database access is added here, services like jupyter-math + # which load a lot of resources will have a big performance hit + # consider caching some properties required by this endpoint or rely on Redis + assert request # nosec + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/login_auth/decorators.py b/services/web/server/src/simcore_service_webserver/login_auth/decorators.py new file mode 100644 index 000000000000..f8ee7eb28b97 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login_auth/decorators.py @@ -0,0 +1,73 @@ +import functools +import inspect +from typing import cast + +from aiohttp import web +from models_library.users import UserID +from servicelib.aiohttp.typing_extension import HandlerAnyReturn +from servicelib.request_keys import RQT_USERID_KEY + +from ..products import products_web +from ..security import security_web + + +def login_required(handler: HandlerAnyReturn) -> HandlerAnyReturn: + """Decorator that restrict access only for authorized users with permissions to access a given product + + - User is considered authorized if check_authorized(request) raises no exception + - If authorized, it injects user_id in request[RQT_USERID_KEY] + - Use this decorator instead of aiohttp_security.api.login_required! + + WARNING: Add always @router. decorator FIRST, e.g. + + @router.get("/foo") + @login_required + async def get_foo(request: web.Request): + ... + + and NOT as + + @login_required + @router.get("/foo") + async def get_foo(request: web.Request): + ... + + since the latter will register in `router` get_foo **without** `login_required` + """ + assert set(inspect.signature(handler).parameters.values()) == { # nosec + inspect.Parameter( + name="request", + kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=web.Request, + ) + }, f"Expected {handler.__name__} with request as signature, got {handler.__annotations__}" + + @functools.wraps(handler) + async def _wrapper(request: web.Request): + """ + Raises: + HTTPUnauthorized: if unauthorized user + HTTPForbidden: if user not allowed in product + """ + # WARNING: note that check_authorized is patched in some tests. + # Careful when changing the function signature + user_id = await security_web.check_user_authorized(request) + product_name = products_web.get_product_name(request) + + await security_web.check_user_permission( + request, + security_web.PERMISSION_PRODUCT_LOGIN_KEY, + context=security_web.AuthContextDict( + product_name=product_name, + authorized_uid=user_id, + ), + ) + + request[RQT_USERID_KEY] = user_id + return await handler(request) + + return _wrapper + + +def get_user_id(request: web.Request) -> UserID: + return cast(UserID, request[RQT_USERID_KEY]) diff --git a/services/web/server/src/simcore_service_webserver/login_auth/plugin.py b/services/web/server/src/simcore_service_webserver/login_auth/plugin.py new file mode 100644 index 000000000000..e8850c1b274a --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login_auth/plugin.py @@ -0,0 +1,20 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp.application_setup import ensure_single_setup + +from ..products.plugin import setup_products_without_rpc +from ..rest.plugin import setup_rest +from ..security.plugin import setup_security +from . import _controller_rest + +_logger = logging.getLogger(__name__) + + +@ensure_single_setup(__name__, logger=_logger) +def setup_login_auth(app: web.Application): + setup_products_without_rpc(app) + setup_security(app) + setup_rest(app) + + app.add_routes(_controller_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/products/_web_helpers.py b/services/web/server/src/simcore_service_webserver/products/_web_helpers.py index 4aa9be9586dc..90b5c07b3e5c 100644 --- a/services/web/server/src/simcore_service_webserver/products/_web_helpers.py +++ b/services/web/server/src/simcore_service_webserver/products/_web_helpers.py @@ -24,8 +24,7 @@ def get_product_name(request: web.Request) -> str: try: product_name: str = request[RQ_PRODUCT_KEY] except KeyError as exc: - error = UnknownProductError() - error.add_note("TIP: Check products middleware") + error = UnknownProductError(tip="Check products middleware") raise error from exc return product_name diff --git a/services/web/server/src/simcore_service_webserver/products/plugin.py b/services/web/server/src/simcore_service_webserver/products/plugin.py index 5aea6edcf7e2..54ac806211e3 100644 --- a/services/web/server/src/simcore_service_webserver/products/plugin.py +++ b/services/web/server/src/simcore_service_webserver/products/plugin.py @@ -11,33 +11,45 @@ import logging from aiohttp import web -from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup +from servicelib.aiohttp.application_setup import ( + ModuleCategory, + app_module_setup, + ensure_single_setup, +) _logger = logging.getLogger(__name__) -@app_module_setup( - __name__, - ModuleCategory.ADDON, - depends=["simcore_service_webserver.db"], - settings_name="WEBSERVER_PRODUCTS", - logger=_logger, -) -def setup_products(app: web.Application): +@ensure_single_setup(f"{__name__}.without_rpc", logger=_logger) +def setup_products_without_rpc(app: web.Application): # # NOTE: internal import speeds up booting app # specially if this plugin is not set up to be loaded # from ..constants import APP_SETTINGS_KEY from . import _web_events, _web_middlewares - from ._controller import rest, rpc + from ._controller import rest assert app[APP_SETTINGS_KEY].WEBSERVER_PRODUCTS is True # nosec + # rest API app.middlewares.append(_web_middlewares.discover_product_middleware) - app.router.add_routes(rest.routes) - rpc.setup_rpc(app) - _web_events.setup_web_events(app) + + +@app_module_setup( + __name__, + ModuleCategory.ADDON, + depends=["simcore_service_webserver.db"], + settings_name="WEBSERVER_PRODUCTS", + logger=_logger, +) +def setup_products(app: web.Application): + from ._controller import rpc + + setup_products_without_rpc(app) + + # rpc API (optional) + rpc.setup_rpc(app) diff --git a/services/web/server/src/simcore_service_webserver/security/plugin.py b/services/web/server/src/simcore_service_webserver/security/plugin.py index 49fa986937cc..cde3ae23bb38 100644 --- a/services/web/server/src/simcore_service_webserver/security/plugin.py +++ b/services/web/server/src/simcore_service_webserver/security/plugin.py @@ -1,10 +1,10 @@ -""" Security subsystem. +"""Security subsystem. - - Responsible of authentication and authorization +- Responsible of authentication and authorization - See login/decorators.py - Based on https://aiohttp-security.readthedocs.io/en/latest/ +See login/decorators.py +Based on https://aiohttp-security.readthedocs.io/en/latest/ """ import logging @@ -13,6 +13,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup +from ..db.plugin import setup_db from ..session.plugin import setup_session from ._authz_access_model import RoleBasedAccessModel from ._authz_access_roles import ROLES_PERMISSIONS @@ -26,8 +27,10 @@ __name__, ModuleCategory.SYSTEM, settings_name="WEBSERVER_SECURITY", logger=_logger ) def setup_security(app: web.Application): - + # NOTE: No need to add a dependency with products domain, i.e. do not call setup_products. + # The logic about the product is obtained via the security repository setup_session(app) + setup_db(app) # Identity Policy: uses sessions to identify (SEE how sessions are setup in session/plugin.py) identity_policy = SessionIdentityPolicy() diff --git a/services/web/server/src/simcore_service_webserver/session/plugin.py b/services/web/server/src/simcore_service_webserver/session/plugin.py index 702c5df40dff..936f308041c0 100644 --- a/services/web/server/src/simcore_service_webserver/session/plugin.py +++ b/services/web/server/src/simcore_service_webserver/session/plugin.py @@ -41,4 +41,9 @@ def setup_session(app: web.Application): samesite=settings.SESSION_COOKIE_SAMESITE, ) aiohttp_session.setup(app=app, storage=encrypted_cookie_sessions) - app.middlewares[-1].__middleware_name__ = f"{__name__}.session" # type: ignore[attr-defined] # PC this attribute does not exist and mypy does not like it + setattr( # noqa: B010 + # aiohttp_session.setup has appended a middleware. We add an identifier (mostly for debugging) + app.middlewares[-1], + "__middleware_name__", + f"{__name__}.session", + ) diff --git a/services/web/server/tests/unit/isolated/conftest.py b/services/web/server/tests/unit/isolated/conftest.py index b369a92df3d3..b91599649a12 100644 --- a/services/web/server/tests/unit/isolated/conftest.py +++ b/services/web/server/tests/unit/isolated/conftest.py @@ -5,7 +5,7 @@ import pytest from faker import Faker -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.monkeypatch_envs import ( setenvs_from_dict, setenvs_from_envfile, @@ -19,7 +19,7 @@ def dir_with_random_content(tmpdir, faker: Faker) -> Path: def make_files_in_dir(dir_path: Path, file_count: int) -> None: for _ in range(file_count): (dir_path / f"{faker.file_name(extension='bin')}").write_bytes( - os.urandom(random.randint(1, 10)) + os.urandom(random.randint(1, 10)) # noqa: S311 ) def ensure_dir(path_to_ensure: Path) -> Path: @@ -30,13 +30,13 @@ def make_subdirectory_with_content(subdir_name: Path, max_file_count: int) -> No subdir_name = ensure_dir(subdir_name) make_files_in_dir( dir_path=subdir_name, - file_count=random.randint(1, max_file_count), + file_count=random.randint(1, max_file_count), # noqa: S311 ) def make_subdirectories_with_content( subdir_name: Path, max_subdirectories_count: int, max_file_count: int ) -> None: - subdirectories_count = random.randint(1, max_subdirectories_count) + subdirectories_count = random.randint(1, max_subdirectories_count) # noqa: S311 for _ in range(subdirectories_count): make_subdirectory_with_content( subdir_name=subdir_name / f"{faker.word()}", @@ -241,19 +241,32 @@ def mocked_login_required(mocker: MockerFixture): # patches @login_required decorator # avoids having to start database etc... mocker.patch( - "simcore_service_webserver.login.decorators.security_web.check_user_authorized", + "simcore_service_webserver.login_auth.decorators.security_web.check_user_authorized", spec=True, return_value=user_id, ) mocker.patch( - "simcore_service_webserver.login.decorators.security_web.check_user_permission", + "simcore_service_webserver.login_auth.decorators.security_web.check_user_permission", spec=True, return_value=None, ) mocker.patch( - "simcore_service_webserver.login.decorators.products_web.get_product_name", + "simcore_service_webserver.login_auth.decorators.products_web.get_product_name", spec=True, return_value="osparc", ) + + +@pytest.fixture +def mocked_db_setup_in_setup_security(mocker: MockerFixture) -> MockType: + """Mocking avoids setting up a full db""" + import simcore_service_webserver.security.plugin + + return mocker.patch.object( + simcore_service_webserver.security.plugin, + "setup_db", + autospec=True, + return_value=True, + ) diff --git a/services/web/server/tests/unit/isolated/test_activity.py b/services/web/server/tests/unit/isolated/test_activity.py index 8f041245f998..28f9b7ef1755 100644 --- a/services/web/server/tests/unit/isolated/test_activity.py +++ b/services/web/server/tests/unit/isolated/test_activity.py @@ -10,7 +10,7 @@ import pytest from aiohttp.client_exceptions import ClientConnectionError from aiohttp.test_utils import TestClient -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict @@ -22,7 +22,6 @@ setup_settings, ) from simcore_service_webserver.rest.plugin import setup_rest -from simcore_service_webserver.security.plugin import setup_security from simcore_service_webserver.session.plugin import setup_session @@ -101,6 +100,7 @@ async def client( aiohttp_client: Callable[..., Awaitable[TestClient]], mock_orphaned_services: MagicMock, app_environment: EnvVarsDict, + mocked_db_setup_in_setup_security: MockType, ): # app_environment are in place assert {key: os.environ[key] for key in app_environment} == app_environment @@ -112,8 +112,9 @@ async def client( assert expected_activity_settings == settings.WEBSERVER_ACTIVITY setup_session(app) - setup_security(app) setup_rest(app) + assert mocked_db_setup_in_setup_security.called + assert setup_activity(app) return await aiohttp_client(app) diff --git a/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py b/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py index ab0883aaa69e..1fd8cf8afb33 100644 --- a/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py +++ b/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py @@ -14,6 +14,7 @@ import simcore_service_webserver from aiohttp import web from aiohttp.test_utils import TestClient +from pytest_mock import MockType from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict @@ -29,7 +30,6 @@ from simcore_service_webserver.diagnostics.plugin import setup_diagnostics from simcore_service_webserver.diagnostics.settings import DiagnosticsSettings from simcore_service_webserver.rest.plugin import setup_rest -from simcore_service_webserver.security.plugin import setup_security from tenacity import retry from tenacity.before import before_log from tenacity.stop import stop_after_attempt @@ -104,6 +104,7 @@ def mock_environment( @pytest.fixture async def client( + mocked_db_setup_in_setup_security: MockType, unused_tcp_port_factory: Callable, aiohttp_client: Callable[..., Awaitable[TestClient]], api_version_prefix: str, @@ -155,7 +156,6 @@ async def delay_response(request: web.Request): # activates some sub-modules assert setup_settings(app) - setup_security(app) setup_rest(app) setup_diagnostics(app) diff --git a/services/web/server/tests/unit/isolated/test_rest.py b/services/web/server/tests/unit/isolated/test_rest.py index 02094f11dc29..1bc502a3e976 100644 --- a/services/web/server/tests/unit/isolated/test_rest.py +++ b/services/web/server/tests/unit/isolated/test_rest.py @@ -9,7 +9,7 @@ import pytest from aiohttp import web from aiohttp.test_utils import TestClient -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.typing_env import EnvVarsDict from servicelib.aiohttp import status @@ -26,6 +26,7 @@ async def client( api_version_prefix: str, mock_env_devel_environment: EnvVarsDict, mock_env_deployer_pipeline: EnvVarsDict, + mocked_db_setup_in_setup_security: MockType, ) -> TestClient: app = create_safe_application() diff --git a/services/web/server/tests/unit/isolated/test_security_web.py b/services/web/server/tests/unit/isolated/test_security_web.py index fae01e47ffd8..9fe584c361b0 100644 --- a/services/web/server/tests/unit/isolated/test_security_web.py +++ b/services/web/server/tests/unit/isolated/test_security_web.py @@ -18,7 +18,7 @@ from models_library.emails import LowerCaseEmailStr from models_library.products import ProductName from pydantic import TypeAdapter -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.typing_env import EnvVarsDict from servicelib.aiohttp import status from simcore_postgres_database.models.products import LOGIN_SETTINGS_DEFAULT, products @@ -214,6 +214,7 @@ async def client( ], app_routes: RouteTableDef, mock_env_devel_environment: EnvVarsDict, + mocked_db_setup_in_setup_security: MockType, ): app = web.Application() app.router.add_routes(app_routes) @@ -227,7 +228,9 @@ async def client( return_value=session_settings, ) + assert not mocked_db_setup_in_setup_security.called setup_security(app) + assert mocked_db_setup_in_setup_security.called # mocks 'setup_products': patch to avoid database set_products_in_app_state(app, app_products) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py index 490adf6a5c1e..1a0b01184fb4 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py @@ -1,7 +1,10 @@ +# pylint: disable=protected-access # pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments # pylint: disable=unused-argument # pylint: disable=unused-variable + import asyncio import json import time @@ -14,9 +17,10 @@ from cryptography import fernet from faker import Faker from pytest_simcore.helpers.assert_checks import assert_status -from pytest_simcore.helpers.webserver_login import NewUser +from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from servicelib.aiohttp import status from settings_library.utils_session import DEFAULT_SESSION_COOKIE_NAME +from simcore_postgres_database.models.users import UserRole from simcore_service_webserver.constants import APP_SETTINGS_KEY from simcore_service_webserver.db.models import UserStatus from simcore_service_webserver.login._constants import ( @@ -31,6 +35,25 @@ from simcore_service_webserver.session.settings import get_plugin_settings +@pytest.mark.parametrize( + "user_role", [role for role in UserRole if role >= UserRole.USER] +) +async def test_check_auth(client: TestClient, logged_user: UserInfoDict): + assert client.app + + url = client.app.router["check_auth"].url_for() + assert url.path == "/v0/auth:check" + + response = await client.get("/v0/auth:check") + await assert_status(response, status.HTTP_204_NO_CONTENT) + + response = await client.post("/v0/auth/logout") + await assert_status(response, status.HTTP_200_OK) + + response = await client.get("/v0/auth:check") + await assert_status(response, status.HTTP_401_UNAUTHORIZED) + + def test_login_plugin_setup_succeeded(client: TestClient): assert client.app print(client.app[APP_SETTINGS_KEY].model_dump_json(indent=1)) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py index b8744b15473a..c7d539d7a8ef 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py @@ -63,22 +63,6 @@ def mocked_captcha_session(mocker: MockerFixture) -> MagicMock: ) -@pytest.mark.parametrize( - "user_role", [role for role in UserRole if role >= UserRole.USER] -) -async def test_check_auth(client: TestClient, logged_user: UserInfoDict): - assert client.app - - response = await client.get("/v0/auth:check") - await assert_status(response, status.HTTP_204_NO_CONTENT) - - response = await client.post("/v0/auth/logout") - await assert_status(response, status.HTTP_200_OK) - - response = await client.get("/v0/auth:check") - await assert_status(response, status.HTTP_401_UNAUTHORIZED) - - @pytest.mark.parametrize( "user_role", [role for role in UserRole if role >= UserRole.USER] ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_login_auth_app.py b/services/web/server/tests/unit/with_dbs/03/test_login_auth_app.py new file mode 100644 index 000000000000..351de51e218f --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/test_login_auth_app.py @@ -0,0 +1,90 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from collections.abc import Callable + +import pytest +import pytest_asyncio +import sqlalchemy as sa +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.typing_env import EnvVarsDict +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.application import create_application_auth +from simcore_service_webserver.security import security_web + + +@pytest.fixture +async def auth_app( + app_environment: EnvVarsDict, + disable_static_webserver: Callable, +) -> web.Application: + assert app_environment + + # creates auth application instead + app = create_application_auth() + + # checks endpoint exposed + url = app.router["check_auth"].url_for() + assert url.path == "/v0/auth:check" + + disable_static_webserver(app) + return app + + +@pytest_asyncio.fixture(loop_scope="function") +async def web_server( + postgres_db: sa.engine.Engine, + auth_app: web.Application, + webserver_test_server_port: int, + # tools + aiohttp_server: Callable, + mocked_send_email: None, +) -> TestServer: + # Overrides tests/unit/with_dbs/context.py:web_server fixture + + # Add test routes for login/logout + async def test_login(request: web.Request) -> web.Response: + data = await request.json() + response = web.Response(status=200) + return await security_web.remember_identity( + request, response, user_email=data["email"] + ) + + async def test_logout(request: web.Request) -> web.Response: + response = web.Response(status=200) + await security_web.forget_identity(request, response) + return response + + auth_app.router.add_post("/v0/test/login", test_login) + auth_app.router.add_post("/v0/test/logout", test_logout) + + return await aiohttp_server(auth_app, port=webserver_test_server_port) + + +# @pytest.mark.parametrize( +# "user_role", [role for role in UserRole if role > UserRole.ANONYMOUS] +# ) +async def test_check_endpoint_in_auth_app(client: TestClient, user: UserInfoDict): + assert client.app + + # user is not signed it (ANONYMOUS) + response = await client.get("/v0/auth:check") + await assert_status(response, status.HTTP_401_UNAUTHORIZED) + + # Sign in using test login route + await client.post("/v0/test/login", json={"email": user["email"]}) + + # Now user should be authorized + response = await client.get("/v0/auth:check") + await assert_status(response, status.HTTP_204_NO_CONTENT) + + await client.post("/v0/test/logout") + + response = await client.get("/v0/auth:check") + await assert_status(response, status.HTTP_401_UNAUTHORIZED)