diff --git a/services/invitations/src/simcore_service_invitations/api/_invitations.py b/services/invitations/src/simcore_service_invitations/api/_invitations.py index 0a680189c61..36737653902 100644 --- a/services/invitations/src/simcore_service_invitations/api/_invitations.py +++ b/services/invitations/src/simcore_service_invitations/api/_invitations.py @@ -1,7 +1,7 @@ import logging from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends from fastapi.security import HTTPBasicCredentials from models_library.api_schemas_invitations.invitations import ( ApiEncryptedInvitation, @@ -9,10 +9,10 @@ ApiInvitationContentAndLink, ApiInvitationInputs, ) +from models_library.invitations import InvitationContent from ..core.settings import ApplicationSettings from ..services.invitations import ( - InvalidInvitationCodeError, create_invitation_link_and_content, extract_invitation_code_from_query, extract_invitation_content, @@ -71,18 +71,10 @@ async def extracts_invitation_from_code( ): """Decrypts the invitation code and returns its content""" - try: - invitation = extract_invitation_content( - invitation_code=extract_invitation_code_from_query( - encrypted.invitation_url - ), - secret_key=settings.INVITATIONS_SECRET_KEY.get_secret_value().encode(), - default_product=settings.INVITATIONS_DEFAULT_PRODUCT, - ) - except InvalidInvitationCodeError as err: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=INVALID_INVITATION_URL_MSG, - ) from err + invitation: InvitationContent = extract_invitation_content( + invitation_code=extract_invitation_code_from_query(encrypted.invitation_url), + secret_key=settings.INVITATIONS_SECRET_KEY.get_secret_value().encode(), + default_product=settings.INVITATIONS_DEFAULT_PRODUCT, + ) return invitation diff --git a/services/invitations/src/simcore_service_invitations/core/application.py b/services/invitations/src/simcore_service_invitations/core/application.py index a08dad3580d..98b798862c1 100644 --- a/services/invitations/src/simcore_service_invitations/core/application.py +++ b/services/invitations/src/simcore_service_invitations/core/application.py @@ -15,6 +15,7 @@ SUMMARY, ) from ..api.routes import setup_api_routes +from . import exceptions_handlers from .settings import ApplicationSettings @@ -43,7 +44,7 @@ def create_app(settings: ApplicationSettings | None = None) -> FastAPI: setup_tracing(app, app.state.settings.INVITATIONS_TRACING, APP_NAME) # ERROR HANDLERS - # ... add here ... + exceptions_handlers.setup(app) # EVENTS async def _on_startup() -> None: diff --git a/services/invitations/src/simcore_service_invitations/core/exceptions_handlers.py b/services/invitations/src/simcore_service_invitations/core/exceptions_handlers.py new file mode 100644 index 00000000000..47c72be56a8 --- /dev/null +++ b/services/invitations/src/simcore_service_invitations/core/exceptions_handlers.py @@ -0,0 +1,40 @@ +import logging + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from servicelib.logging_errors import create_troubleshotting_log_kwargs + +from ..services.invitations import InvalidInvitationCodeError + +_logger = logging.getLogger(__name__) + +INVALID_INVITATION_URL_MSG = "Invalid invitation link" + + +def handle_invalid_invitation_code_error(request: Request, exception: Exception): + assert isinstance(exception, InvalidInvitationCodeError) # nosec + user_msg = INVALID_INVITATION_URL_MSG + + _logger.warning( + **create_troubleshotting_log_kwargs( + user_msg, + error=exception, + error_context={ + "request.method": f"{request.method}", + "request.url": f"{request.url}", + "request.body": getattr(request, "_json", None), + }, + tip="An invitation link could not be extracted", + ) + ) + + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"detail": user_msg}, + ) + + +def setup(app: FastAPI): + app.add_exception_handler( + InvalidInvitationCodeError, handle_invalid_invitation_code_error + ) diff --git a/services/invitations/src/simcore_service_invitations/services/invitations.py b/services/invitations/src/simcore_service_invitations/services/invitations.py index e9d0a2a13ed..d6065b08d0c 100644 --- a/services/invitations/src/simcore_service_invitations/services/invitations.py +++ b/services/invitations/src/simcore_service_invitations/services/invitations.py @@ -118,15 +118,16 @@ def create_invitation_link_and_content( def extract_invitation_code_from_query(invitation_url: HttpUrl) -> str: """Parses url and extracts invitation code from url's query""" if not invitation_url.fragment: - raise InvalidInvitationCodeError + msg = "Invalid link format: fragment missing" + raise InvalidInvitationCodeError(msg) try: query_params = dict(parse.parse_qsl(URL(invitation_url.fragment).query)) invitation_code: str = query_params["invitation"] return invitation_code except KeyError as err: - _logger.debug("Invalid invitation: %s", err) - raise InvalidInvitationCodeError from err + msg = "Invalid link format: fragment misses `invitation` link" + raise InvalidInvitationCodeError(msg) from err def decrypt_invitation( @@ -167,5 +168,7 @@ def extract_invitation_content( return content except (InvalidToken, ValidationError, binascii.Error) as err: - _logger.debug("Invalid code: %s", err) - raise InvalidInvitationCodeError from err + msg = ( + "Failed while decripting. TIP: secret key at encryption might be different" + ) + raise InvalidInvitationCodeError(msg) from err diff --git a/services/invitations/tests/unit/test__symmetric_encryption.py b/services/invitations/tests/unit/test__symmetric_encryption.py index f1d0010e3fd..0b74a6ae37e 100644 --- a/services/invitations/tests/unit/test__symmetric_encryption.py +++ b/services/invitations/tests/unit/test__symmetric_encryption.py @@ -1,3 +1,8 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + import base64 import json import os @@ -5,6 +10,7 @@ import pytest from cryptography.fernet import Fernet, InvalidToken +from faker import Faker from starlette.datastructures import URL @@ -44,18 +50,45 @@ def consume(url): raise except InvalidToken as err: - # TODO: cannot decode print("Invalid Key", err) raise -def test_encrypt_and_decrypt(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture( + params=[ + "en_US", # English (United States) + "fr_FR", # French (France) + "de_DE", # German (Germany) + "ru_RU", # Russian + "ja_JP", # Japanese + "zh_CN", # Chinese (Simplified) + "ko_KR", # Korean + "ar_EG", # Arabic (Egypt) + "he_IL", # Hebrew (Israel) + "hi_IN", # Hindi (India) + "th_TH", # Thai (Thailand) + "vi_VN", # Vietnamese (Vietnam) + "ta_IN", # Tamil (India) + ] +) +def fake_email(request): + locale = request.param + faker = Faker(locale) + # Use a localized name for the username part of the email + name = faker.name().replace(" ", "").replace(".", "").lower() + # Construct the email address + return f"{name}@example.{locale.split('_')[-1].lower()}" + + +def test_encrypt_and_decrypt(monkeypatch: pytest.MonkeyPatch, fake_email: str): secret_key = Fernet.generate_key() monkeypatch.setenv("SECRET_KEY", secret_key.decode()) # invitation generator app - invitation_url = produce(guest_email="guest@gmail.com") + invitation_url = produce(guest_email=fake_email) + assert invitation_url.fragment # osparc side invitation_data = consume(invitation_url) print(json.dumps(invitation_data, indent=1)) + assert invitation_data["guest"] == fake_email diff --git a/services/invitations/tests/unit/test_invitations.py b/services/invitations/tests/unit/test_invitations.py index b37b79f4575..770dba67bb9 100644 --- a/services/invitations/tests/unit/test_invitations.py +++ b/services/invitations/tests/unit/test_invitations.py @@ -4,8 +4,8 @@ # pylint: disable=too-many-arguments import binascii -from datetime import datetime, timezone -from typing import Counter +from collections import Counter +from datetime import UTC, datetime from urllib import parse import cryptography.fernet @@ -40,7 +40,7 @@ def test_import_and_export_invitation_alias_by_alias( ): expected_content = InvitationContent( **invitation_data.model_dump(), - created=datetime.now(tz=timezone.utc), + created=datetime.now(tz=UTC), ) raw_data = _ContentWithShortNames.serialize(expected_content) @@ -53,7 +53,7 @@ def test_export_by_alias_produces_smaller_strings( ): content = InvitationContent( **invitation_data.model_dump(), - created=datetime.now(tz=timezone.utc), + created=datetime.now(tz=UTC), ) raw_data = _ContentWithShortNames.serialize(content) diff --git a/services/web/server/src/simcore_service_webserver/invitations/_client.py b/services/web/server/src/simcore_service_webserver/invitations/_client.py index 2822be94316..b7abdcf10ee 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/_client.py +++ b/services/web/server/src/simcore_service_webserver/invitations/_client.py @@ -1,8 +1,10 @@ import contextlib +import functools import logging +from collections.abc import Callable from dataclasses import dataclass -from aiohttp import BasicAuth, ClientSession, web +from aiohttp import BasicAuth, ClientResponseError, ClientSession, web from aiohttp.client_exceptions import ClientError from models_library.api_schemas_invitations.invitations import ( ApiInvitationContent, @@ -11,17 +13,62 @@ ) from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import AnyHttpUrl +from servicelib.aiohttp import status from yarl import URL from .._constants import APP_SETTINGS_KEY +from .errors import ( + InvalidInvitationError, + InvitationsError, + InvitationsServiceUnavailableError, +) from .settings import InvitationsSettings _logger = logging.getLogger(__name__) -# -# CLIENT -# +def _handle_exceptions_as_invitations_errors(member_func: Callable): + @functools.wraps(member_func) + async def _wrapper(*args, **kwargs): + """ + Raises: + InvalidInvitationError: + InvitationsServiceUnavailableError: + """ + try: + + return await member_func(*args, **kwargs) + + except ClientResponseError as err: + + if err.status == status.HTTP_422_UNPROCESSABLE_ENTITY: + raise InvalidInvitationError( + api_funcname=member_func.__name__, + status=err.status, + message=err.message, + url=err.request_info.real_url, + ) from err + + assert err.status >= status.HTTP_400_BAD_REQUEST # nosec + + # any other error status code + raise InvitationsServiceUnavailableError( + api_funcname=member_func.__name__, + status=err.status, + message=err.message, + url=err.request_info.real_url, + ) from err + + except InvitationsError: + # bypass: prevents that the Exceptions handler catches this exception + raise + + except Exception as err: + raise InvitationsServiceUnavailableError( + unexpected_error=err, + ) from err + + return _wrapper @dataclass(frozen=True) @@ -79,6 +126,7 @@ async def ping(self) -> bool: # service API # + @_handle_exceptions_as_invitations_errors async def extract_invitation( self, invitation_url: AnyHttpUrl ) -> ApiInvitationContent: @@ -88,6 +136,7 @@ async def extract_invitation( ) return ApiInvitationContent.model_validate(await response.json()) + @_handle_exceptions_as_invitations_errors async def generate_invitation( self, params: ApiInvitationInputs ) -> ApiInvitationContentAndLink: diff --git a/services/web/server/src/simcore_service_webserver/invitations/_core.py b/services/web/server/src/simcore_service_webserver/invitations/_core.py deleted file mode 100644 index fcd9e619742..00000000000 --- a/services/web/server/src/simcore_service_webserver/invitations/_core.py +++ /dev/null @@ -1,161 +0,0 @@ -import logging -from contextlib import contextmanager -from typing import Final - -from aiohttp import ClientResponseError, web -from models_library.api_schemas_invitations.invitations import ( - ApiInvitationContent, - ApiInvitationContentAndLink, - ApiInvitationInputs, -) -from models_library.emails import LowerCaseEmailStr -from pydantic import AnyHttpUrl, TypeAdapter, ValidationError -from servicelib.aiohttp import status - -from ..groups.api import is_user_by_email_in_group -from ..products.api import Product -from ._client import InvitationsServiceApi, get_invitations_service_api -from .errors import ( - MSG_INVALID_INVITATION_URL, - MSG_INVITATION_ALREADY_USED, - InvalidInvitationError, - InvitationsError, - InvitationsServiceUnavailableError, -) - -_logger = logging.getLogger(__name__) - - -@contextmanager -def _handle_exceptions_as_invitations_errors(): - try: - yield # API function calls happen - - except ClientResponseError as err: - # check possible errors - if err.status == status.HTTP_422_UNPROCESSABLE_ENTITY: - raise InvalidInvitationError( - invitations_api_response={ - "err": err, - "status": err.status, - "message": err.message, - "url": err.request_info.real_url, - }, - ) from err - - assert err.status >= status.HTTP_400_BAD_REQUEST # nosec - - # any other error status code - raise InvitationsServiceUnavailableError( - client_response_error=err, - ) from err - - except InvitationsError: - # bypass: prevents that the Exceptions handler catches this exception - raise - - except Exception as err: - raise InvitationsServiceUnavailableError( - unexpected_error=err, - ) from err - - -# -# API plugin CALLS -# - -_LONG_CODE_LEN: Final[int] = 100 # typically long strings - - -def is_service_invitation_code(code: str): - """Fast check to distinguish from confirmation-type of invitation code""" - return len(code) > _LONG_CODE_LEN - - -async def validate_invitation_url( - app: web.Application, - *, - current_product: Product, - guest_email: str, - invitation_url: str, -) -> ApiInvitationContent: - """Validates invitation and associated email/user and returns content upon success - - raises InvitationsError - """ - if current_product.group_id is None: - raise InvitationsServiceUnavailableError( - reason="Current product is not configured for invitations" - ) - - invitations_service: InvitationsServiceApi = get_invitations_service_api(app=app) - - with _handle_exceptions_as_invitations_errors(): - try: - valid_url = TypeAdapter(AnyHttpUrl).validate_python(invitation_url) - except ValidationError as err: - raise InvalidInvitationError(reason=MSG_INVALID_INVITATION_URL) from err - - # check with service - invitation = await invitations_service.extract_invitation( - invitation_url=valid_url - ) - - # check email - if invitation.guest.lower() != guest_email.lower(): - raise InvalidInvitationError( - reason="This invitation was issued for a different email" - ) - - # check product - assert current_product.group_id is not None # nosec - if ( - invitation.product is not None - and invitation.product != current_product.name - ): - raise InvalidInvitationError( - reason="This invitation was issued for a different product. " - f"Got '{invitation.product}', expected '{current_product.name}'" - ) - - # check invitation used - assert invitation.product == current_product.name # nosec - is_user_registered_in_product: bool = await is_user_by_email_in_group( - app, - user_email=LowerCaseEmailStr(invitation.guest), - group_id=current_product.group_id, - ) - if is_user_registered_in_product: - # NOTE: a user might be already registered but the invitation is for another product - raise InvalidInvitationError(reason=MSG_INVITATION_ALREADY_USED) - - return invitation - - -async def extract_invitation( - app: web.Application, invitation_url: str -) -> ApiInvitationContent: - """Validates invitation and returns content without checking associated user - - raises InvitationsError - """ - invitations_service: InvitationsServiceApi = get_invitations_service_api(app=app) - - with _handle_exceptions_as_invitations_errors(): - try: - valid_url = TypeAdapter(AnyHttpUrl).validate_python(invitation_url) - except ValidationError as err: - raise InvalidInvitationError(reason=MSG_INVALID_INVITATION_URL) from err - - # check with service - return await invitations_service.extract_invitation(invitation_url=valid_url) - - -async def generate_invitation( - app: web.Application, params: ApiInvitationInputs -) -> ApiInvitationContentAndLink: - invitations_service: InvitationsServiceApi = get_invitations_service_api(app=app) - - with _handle_exceptions_as_invitations_errors(): - # check with service - return await invitations_service.generate_invitation(params) diff --git a/services/web/server/src/simcore_service_webserver/invitations/_service.py b/services/web/server/src/simcore_service_webserver/invitations/_service.py new file mode 100644 index 00000000000..ae7cabaf616 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/invitations/_service.py @@ -0,0 +1,129 @@ +import logging +from typing import Final + +from aiohttp import web +from models_library.api_schemas_invitations.invitations import ( + ApiInvitationContent, + ApiInvitationContentAndLink, + ApiInvitationInputs, +) +from models_library.emails import LowerCaseEmailStr +from pydantic import AnyHttpUrl, TypeAdapter, ValidationError + +from ..groups.api import is_user_by_email_in_group +from ..products.api import Product +from ._client import get_invitations_service_api +from .errors import ( + MSG_INVALID_INVITATION_URL, + MSG_INVITATION_ALREADY_USED, + InvalidInvitationError, + InvitationsServiceUnavailableError, +) + +_logger = logging.getLogger(__name__) + + +# +# API plugin CALLS +# + +_LONG_CODE_LEN: Final[int] = 100 # typically long strings + + +def is_service_invitation_code(code: str): + """Fast check to distinguish from confirmation-type of invitation code""" + return len(code) > _LONG_CODE_LEN + + +async def validate_invitation_url( + app: web.Application, + *, + current_product: Product, + guest_email: str, + invitation_url: str, +) -> ApiInvitationContent: + """Validates invitation and associated email/user and returns content upon success + + Raises: + InvitationsError + InvalidInvitationError: + InvitationsServiceUnavailableError: + """ + if current_product.group_id is None: + raise InvitationsServiceUnavailableError( + reason="Current product is not configured for invitations" + ) + + try: + valid_url = TypeAdapter(AnyHttpUrl).validate_python(invitation_url) + except ValidationError as err: + raise InvalidInvitationError(reason=MSG_INVALID_INVITATION_URL) from err + + # check with service + invitation: ApiInvitationContent = await get_invitations_service_api( + app=app + ).extract_invitation(invitation_url=valid_url) + + # check email + if invitation.guest.lower() != guest_email.lower(): + raise InvalidInvitationError( + reason="This invitation was issued for a different email" + ) + + # check product + assert current_product.group_id is not None # nosec + if invitation.product is not None and invitation.product != current_product.name: + raise InvalidInvitationError( + reason="This invitation was issued for a different product. " + f"Got '{invitation.product}', expected '{current_product.name}'" + ) + + # check invitation used + assert invitation.product == current_product.name # nosec + is_user_registered_in_product: bool = await is_user_by_email_in_group( + app, + user_email=LowerCaseEmailStr(invitation.guest), + group_id=current_product.group_id, + ) + if is_user_registered_in_product: + # NOTE: a user might be already registered but the invitation is for another product + raise InvalidInvitationError(reason=MSG_INVITATION_ALREADY_USED) + + return invitation + + +async def extract_invitation( + app: web.Application, invitation_url: str +) -> ApiInvitationContent: + """Validates invitation and returns content without checking associated user + + Raises: + InvitationsError + InvalidInvitationError: + InvitationsServiceUnavailableError: + """ + try: + valid_url = TypeAdapter(AnyHttpUrl).validate_python(invitation_url) + except ValidationError as err: + raise InvalidInvitationError(reason=MSG_INVALID_INVITATION_URL) from err + + # check with service + invitation: ApiInvitationContent = await get_invitations_service_api( + app=app + ).extract_invitation(invitation_url=valid_url) + return invitation + + +async def generate_invitation( + app: web.Application, params: ApiInvitationInputs +) -> ApiInvitationContentAndLink: + """ + Raises: + InvitationsError + InvalidInvitationError: + InvitationsServiceUnavailableError: + """ + invitation: ApiInvitationContentAndLink = await get_invitations_service_api( + app=app + ).generate_invitation(params) + return invitation diff --git a/services/web/server/src/simcore_service_webserver/invitations/api.py b/services/web/server/src/simcore_service_webserver/invitations/api.py index b7f1f67172c..cc0a9ef7912 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/api.py +++ b/services/web/server/src/simcore_service_webserver/invitations/api.py @@ -1,4 +1,4 @@ -from ._core import ( +from ._service import ( extract_invitation, generate_invitation, is_service_invitation_code, diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py b/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py index 014ed5db536..247289ca322 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py @@ -116,7 +116,7 @@ def mock_invitations_service_http_api( # extract assert "/v1/invitations:extract" in oas["paths"] - def _extract(url, **kwargs): + def _extract_cbk(url, **kwargs): fake_code = URL(URL(f'{kwargs["json"]["invitation_url"]}').fragment).query[ "invitation" ] @@ -133,7 +133,7 @@ def _extract(url, **kwargs): aioresponses_mocker.post( f"{base_url}/v1/invitations:extract", - callback=_extract, + callback=_extract_cbk, repeat=True, # NOTE: this can be used many times )