Skip to content

Commit f543f5a

Browse files
authored
🐛 Fixes invalid invitation link (ITISFoundation#7017)
1 parent 2165fd4 commit f543f5a

File tree

11 files changed

+282
-196
lines changed

11 files changed

+282
-196
lines changed

services/invitations/src/simcore_service_invitations/api/_invitations.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import logging
22
from typing import Annotated
33

4-
from fastapi import APIRouter, Depends, HTTPException, status
4+
from fastapi import APIRouter, Depends
55
from fastapi.security import HTTPBasicCredentials
66
from models_library.api_schemas_invitations.invitations import (
77
ApiEncryptedInvitation,
88
ApiInvitationContent,
99
ApiInvitationContentAndLink,
1010
ApiInvitationInputs,
1111
)
12+
from models_library.invitations import InvitationContent
1213

1314
from ..core.settings import ApplicationSettings
1415
from ..services.invitations import (
15-
InvalidInvitationCodeError,
1616
create_invitation_link_and_content,
1717
extract_invitation_code_from_query,
1818
extract_invitation_content,
@@ -71,18 +71,10 @@ async def extracts_invitation_from_code(
7171
):
7272
"""Decrypts the invitation code and returns its content"""
7373

74-
try:
75-
invitation = extract_invitation_content(
76-
invitation_code=extract_invitation_code_from_query(
77-
encrypted.invitation_url
78-
),
79-
secret_key=settings.INVITATIONS_SECRET_KEY.get_secret_value().encode(),
80-
default_product=settings.INVITATIONS_DEFAULT_PRODUCT,
81-
)
82-
except InvalidInvitationCodeError as err:
83-
raise HTTPException(
84-
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
85-
detail=INVALID_INVITATION_URL_MSG,
86-
) from err
74+
invitation: InvitationContent = extract_invitation_content(
75+
invitation_code=extract_invitation_code_from_query(encrypted.invitation_url),
76+
secret_key=settings.INVITATIONS_SECRET_KEY.get_secret_value().encode(),
77+
default_product=settings.INVITATIONS_DEFAULT_PRODUCT,
78+
)
8779

8880
return invitation

services/invitations/src/simcore_service_invitations/core/application.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
SUMMARY,
1616
)
1717
from ..api.routes import setup_api_routes
18+
from . import exceptions_handlers
1819
from .settings import ApplicationSettings
1920

2021

@@ -43,7 +44,7 @@ def create_app(settings: ApplicationSettings | None = None) -> FastAPI:
4344
setup_tracing(app, app.state.settings.INVITATIONS_TRACING, APP_NAME)
4445

4546
# ERROR HANDLERS
46-
# ... add here ...
47+
exceptions_handlers.setup(app)
4748

4849
# EVENTS
4950
async def _on_startup() -> None:
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import logging
2+
3+
from fastapi import FastAPI, Request, status
4+
from fastapi.responses import JSONResponse
5+
from servicelib.logging_errors import create_troubleshotting_log_kwargs
6+
7+
from ..services.invitations import InvalidInvitationCodeError
8+
9+
_logger = logging.getLogger(__name__)
10+
11+
INVALID_INVITATION_URL_MSG = "Invalid invitation link"
12+
13+
14+
def handle_invalid_invitation_code_error(request: Request, exception: Exception):
15+
assert isinstance(exception, InvalidInvitationCodeError) # nosec
16+
user_msg = INVALID_INVITATION_URL_MSG
17+
18+
_logger.warning(
19+
**create_troubleshotting_log_kwargs(
20+
user_msg,
21+
error=exception,
22+
error_context={
23+
"request.method": f"{request.method}",
24+
"request.url": f"{request.url}",
25+
"request.body": getattr(request, "_json", None),
26+
},
27+
tip="An invitation link could not be extracted",
28+
)
29+
)
30+
31+
return JSONResponse(
32+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
33+
content={"detail": user_msg},
34+
)
35+
36+
37+
def setup(app: FastAPI):
38+
app.add_exception_handler(
39+
InvalidInvitationCodeError, handle_invalid_invitation_code_error
40+
)

services/invitations/src/simcore_service_invitations/services/invitations.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,16 @@ def create_invitation_link_and_content(
118118
def extract_invitation_code_from_query(invitation_url: HttpUrl) -> str:
119119
"""Parses url and extracts invitation code from url's query"""
120120
if not invitation_url.fragment:
121-
raise InvalidInvitationCodeError
121+
msg = "Invalid link format: fragment missing"
122+
raise InvalidInvitationCodeError(msg)
122123

123124
try:
124125
query_params = dict(parse.parse_qsl(URL(invitation_url.fragment).query))
125126
invitation_code: str = query_params["invitation"]
126127
return invitation_code
127128
except KeyError as err:
128-
_logger.debug("Invalid invitation: %s", err)
129-
raise InvalidInvitationCodeError from err
129+
msg = "Invalid link format: fragment misses `invitation` link"
130+
raise InvalidInvitationCodeError(msg) from err
130131

131132

132133
def decrypt_invitation(
@@ -167,5 +168,7 @@ def extract_invitation_content(
167168
return content
168169

169170
except (InvalidToken, ValidationError, binascii.Error) as err:
170-
_logger.debug("Invalid code: %s", err)
171-
raise InvalidInvitationCodeError from err
171+
msg = (
172+
"Failed while decripting. TIP: secret key at encryption might be different"
173+
)
174+
raise InvalidInvitationCodeError(msg) from err

services/invitations/tests/unit/test__symmetric_encryption.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
4+
# pylint: disable=too-many-arguments
5+
16
import base64
27
import json
38
import os
49
from urllib.parse import parse_qsl, urlparse
510

611
import pytest
712
from cryptography.fernet import Fernet, InvalidToken
13+
from faker import Faker
814
from starlette.datastructures import URL
915

1016

@@ -44,18 +50,45 @@ def consume(url):
4450
raise
4551

4652
except InvalidToken as err:
47-
# TODO: cannot decode
4853
print("Invalid Key", err)
4954
raise
5055

5156

52-
def test_encrypt_and_decrypt(monkeypatch: pytest.MonkeyPatch):
57+
@pytest.fixture(
58+
params=[
59+
"en_US", # English (United States)
60+
"fr_FR", # French (France)
61+
"de_DE", # German (Germany)
62+
"ru_RU", # Russian
63+
"ja_JP", # Japanese
64+
"zh_CN", # Chinese (Simplified)
65+
"ko_KR", # Korean
66+
"ar_EG", # Arabic (Egypt)
67+
"he_IL", # Hebrew (Israel)
68+
"hi_IN", # Hindi (India)
69+
"th_TH", # Thai (Thailand)
70+
"vi_VN", # Vietnamese (Vietnam)
71+
"ta_IN", # Tamil (India)
72+
]
73+
)
74+
def fake_email(request):
75+
locale = request.param
76+
faker = Faker(locale)
77+
# Use a localized name for the username part of the email
78+
name = faker.name().replace(" ", "").replace(".", "").lower()
79+
# Construct the email address
80+
return f"{name}@example.{locale.split('_')[-1].lower()}"
81+
82+
83+
def test_encrypt_and_decrypt(monkeypatch: pytest.MonkeyPatch, fake_email: str):
5384
secret_key = Fernet.generate_key()
5485
monkeypatch.setenv("SECRET_KEY", secret_key.decode())
5586

5687
# invitation generator app
57-
invitation_url = produce(guest_email="[email protected]")
88+
invitation_url = produce(guest_email=fake_email)
89+
assert invitation_url.fragment
5890

5991
# osparc side
6092
invitation_data = consume(invitation_url)
6193
print(json.dumps(invitation_data, indent=1))
94+
assert invitation_data["guest"] == fake_email

services/invitations/tests/unit/test_invitations.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
# pylint: disable=too-many-arguments
55

66
import binascii
7-
from datetime import datetime, timezone
8-
from typing import Counter
7+
from collections import Counter
8+
from datetime import UTC, datetime
99
from urllib import parse
1010

1111
import cryptography.fernet
@@ -40,7 +40,7 @@ def test_import_and_export_invitation_alias_by_alias(
4040
):
4141
expected_content = InvitationContent(
4242
**invitation_data.model_dump(),
43-
created=datetime.now(tz=timezone.utc),
43+
created=datetime.now(tz=UTC),
4444
)
4545
raw_data = _ContentWithShortNames.serialize(expected_content)
4646

@@ -53,7 +53,7 @@ def test_export_by_alias_produces_smaller_strings(
5353
):
5454
content = InvitationContent(
5555
**invitation_data.model_dump(),
56-
created=datetime.now(tz=timezone.utc),
56+
created=datetime.now(tz=UTC),
5757
)
5858
raw_data = _ContentWithShortNames.serialize(content)
5959

services/web/server/src/simcore_service_webserver/invitations/_client.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import contextlib
2+
import functools
23
import logging
4+
from collections.abc import Callable
35
from dataclasses import dataclass
46

5-
from aiohttp import BasicAuth, ClientSession, web
7+
from aiohttp import BasicAuth, ClientResponseError, ClientSession, web
68
from aiohttp.client_exceptions import ClientError
79
from models_library.api_schemas_invitations.invitations import (
810
ApiInvitationContent,
@@ -11,17 +13,62 @@
1113
)
1214
from models_library.utils.fastapi_encoders import jsonable_encoder
1315
from pydantic import AnyHttpUrl
16+
from servicelib.aiohttp import status
1417
from yarl import URL
1518

1619
from .._constants import APP_SETTINGS_KEY
20+
from .errors import (
21+
InvalidInvitationError,
22+
InvitationsError,
23+
InvitationsServiceUnavailableError,
24+
)
1725
from .settings import InvitationsSettings
1826

1927
_logger = logging.getLogger(__name__)
2028

2129

22-
#
23-
# CLIENT
24-
#
30+
def _handle_exceptions_as_invitations_errors(member_func: Callable):
31+
@functools.wraps(member_func)
32+
async def _wrapper(*args, **kwargs):
33+
"""
34+
Raises:
35+
InvalidInvitationError:
36+
InvitationsServiceUnavailableError:
37+
"""
38+
try:
39+
40+
return await member_func(*args, **kwargs)
41+
42+
except ClientResponseError as err:
43+
44+
if err.status == status.HTTP_422_UNPROCESSABLE_ENTITY:
45+
raise InvalidInvitationError(
46+
api_funcname=member_func.__name__,
47+
status=err.status,
48+
message=err.message,
49+
url=err.request_info.real_url,
50+
) from err
51+
52+
assert err.status >= status.HTTP_400_BAD_REQUEST # nosec
53+
54+
# any other error status code
55+
raise InvitationsServiceUnavailableError(
56+
api_funcname=member_func.__name__,
57+
status=err.status,
58+
message=err.message,
59+
url=err.request_info.real_url,
60+
) from err
61+
62+
except InvitationsError:
63+
# bypass: prevents that the Exceptions handler catches this exception
64+
raise
65+
66+
except Exception as err:
67+
raise InvitationsServiceUnavailableError(
68+
unexpected_error=err,
69+
) from err
70+
71+
return _wrapper
2572

2673

2774
@dataclass(frozen=True)
@@ -79,6 +126,7 @@ async def ping(self) -> bool:
79126
# service API
80127
#
81128

129+
@_handle_exceptions_as_invitations_errors
82130
async def extract_invitation(
83131
self, invitation_url: AnyHttpUrl
84132
) -> ApiInvitationContent:
@@ -88,6 +136,7 @@ async def extract_invitation(
88136
)
89137
return ApiInvitationContent.model_validate(await response.json())
90138

139+
@_handle_exceptions_as_invitations_errors
91140
async def generate_invitation(
92141
self, params: ApiInvitationInputs
93142
) -> ApiInvitationContentAndLink:

0 commit comments

Comments
 (0)