Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 Fixes invalid invitation link #7017

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
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,
ApiInvitationContent,
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,
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
SUMMARY,
)
from ..api.routes import setup_api_routes
from . import exceptions_handlers
from .settings import ApplicationSettings


Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
39 changes: 36 additions & 3 deletions services/invitations/tests/unit/test__symmetric_encryption.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# pylint: disable=redefined-outer-name
# pylint: disable=unused-argument
# pylint: disable=unused-variable
# pylint: disable=too-many-arguments

import base64
import json
import os
from urllib.parse import parse_qsl, urlparse

import pytest
from cryptography.fernet import Fernet, InvalidToken
from faker import Faker
from starlette.datastructures import URL


Expand Down Expand Up @@ -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="[email protected]")
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
8 changes: 4 additions & 4 deletions services/invitations/tests/unit/test_invitations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -79,6 +126,7 @@ async def ping(self) -> bool:
# service API
#

@_handle_exceptions_as_invitations_errors
async def extract_invitation(
pcrespov marked this conversation as resolved.
Show resolved Hide resolved
self, invitation_url: AnyHttpUrl
) -> ApiInvitationContent:
Expand All @@ -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:
Expand Down
Loading
Loading