Skip to content

Commit

Permalink
🐛 Fixes registration in multiple products via invitations (ITISFounda…
Browse files Browse the repository at this point in the history
  • Loading branch information
pcrespov authored Jan 6, 2024
1 parent b429614 commit 87a98f1
Show file tree
Hide file tree
Showing 21 changed files with 448 additions and 379 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ async def get_default_product_name(conn: DBConnection) -> str:
sa.select(products.c.name).order_by(products.c.priority)
)
if not product_name:
raise ValueError("No product defined in database")
msg = "No product defined in database"
raise ValueError(msg)

assert isinstance(product_name, str) # nosec
return product_name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ def _compute_hash(password: str) -> str:
return hashlib.sha224(password.encode("ascii")).hexdigest()


_DEFAULT_HASH = _compute_hash("secret")
DEFAULT_PASSWORD = "secret" * 3 # Password must be at least 12 characters long
_DEFAULT_HASH = _compute_hash(DEFAULT_PASSWORD)


def random_user(**overrides) -> dict[str, Any]:
Expand All @@ -77,6 +78,7 @@ def random_user(**overrides) -> dict[str, Any]:
# transform password in hash
password = overrides.pop("password", None)
if password:
assert len(password) >= 12
overrides["password_hash"] = _compute_hash(password)

data.update(overrides)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage
from yarl import URL

from .rawdata_fakers import FAKE, random_user
from .rawdata_fakers import DEFAULT_PASSWORD, FAKE, random_user
from .utils_assert import assert_status


Expand Down Expand Up @@ -52,10 +52,12 @@ def parse_link(text):
return URL(link).path


async def create_fake_user(db: AsyncpgStorage, data=None) -> UserInfoDict:
async def _insert_fake_user(db: AsyncpgStorage, data=None) -> UserInfoDict:
"""Creates a fake user and inserts it in the users table in the database"""
data = data or {}
data.setdefault("password", "secret")
data.setdefault(
"password", DEFAULT_PASSWORD
) # Password must be at least 12 characters long
data.setdefault("status", UserStatus.ACTIVE.name)
data.setdefault("role", UserRole.USER.name)
params = random_user(**data)
Expand All @@ -72,7 +74,7 @@ async def log_client_in(
assert client.app
db: AsyncpgStorage = get_plugin_storage(client.app)

user = await create_fake_user(db, user_data)
user = await _insert_fake_user(db, user_data)

# login
url = client.app.router["auth_login"].url_for()
Expand All @@ -98,7 +100,7 @@ def __init__(self, params=None, app: web.Application | None = None):
self.db = get_plugin_storage(app)

async def __aenter__(self) -> UserInfoDict:
self.user = await create_fake_user(self.db, self.params)
self.user = await _insert_fake_user(self.db, self.params)
return self.user

async def __aexit__(self, *args):
Expand Down Expand Up @@ -139,7 +141,7 @@ async def __aenter__(self) -> "NewInvitation":
# creates host user
assert self.client.app
db: AsyncpgStorage = get_plugin_storage(self.client.app)
self.user = await create_fake_user(db, self.params)
self.user = await _insert_fake_user(db, self.params)

self.confirmation = await create_invitation_token(
self.db,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from ..services.invitations import (
InvalidInvitationCodeError,
create_invitation_link_and_content,
extract_invitation_code_from,
extract_invitation_code_from_query,
extract_invitation_content,
)
from ._dependencies import get_settings, get_validated_credentials
Expand Down Expand Up @@ -73,7 +73,9 @@ async def extracts_invitation_from_code(

try:
invitation = extract_invitation_content(
invitation_code=extract_invitation_code_from(encrypted.invitation_url),
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,
)
Expand Down
4 changes: 2 additions & 2 deletions services/invitations/src/simcore_service_invitations/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .services.invitations import (
InvalidInvitationCodeError,
create_invitation_link_and_content,
extract_invitation_code_from,
extract_invitation_code_from_query,
extract_invitation_content,
)

Expand Down Expand Up @@ -141,7 +141,7 @@ def extract(ctx: typer.Context, invitation_url: str):

try:
invitation: InvitationContent = extract_invitation_content(
invitation_code=extract_invitation_code_from(
invitation_code=extract_invitation_code_from_query(
parse_obj_as(HttpUrl, invitation_url)
),
secret_key=settings.INVITATIONS_SECRET_KEY.get_secret_value().encode(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ def create_invitation_link_and_content(
return link, content


def extract_invitation_code_from(invitation_url: HttpUrl) -> str:
"""Parses url and extracts invitation"""
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

Expand Down
11 changes: 11 additions & 0 deletions services/web/server/src/simcore_service_webserver/groups/_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,17 @@ async def auto_add_user_to_product_group(
return product_group_id


async def is_user_by_email_in_group(
conn: SAConnection, email: str, group_id: GroupID
) -> bool:
user_id = await conn.scalar(
sa.select(users.c.id)
.select_from(sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id))
.where((users.c.email == email) & (user_to_groups.c.gid == group_id))
)
return user_id is not None


async def add_new_user_in_group(
conn: SAConnection,
user_id: UserID,
Expand Down
18 changes: 16 additions & 2 deletions services/web/server/src/simcore_service_webserver/groups/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from aiohttp import web
from aiopg.sa.result import RowProxy
from models_library.emails import LowerCaseEmailStr
from models_library.users import GroupID, UserID

from ..db.plugin import get_database_engine
Expand Down Expand Up @@ -96,6 +97,17 @@ async def auto_add_user_to_product_group(
)


async def is_user_by_email_in_group(
app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID
) -> bool:
async with get_database_engine(app).acquire() as conn:
return await _db.is_user_by_email_in_group(
conn,
email=user_email,
group_id=group_id,
)


async def add_user_in_group(
app: web.Application,
user_id: UserID,
Expand All @@ -113,15 +125,17 @@ async def add_user_in_group(
"""

if not new_user_id and not new_user_email:
raise GroupsError("Invalid method call, missing user id or user email")
msg = "Invalid method call, missing user id or user email"
raise GroupsError(msg)

async with get_database_engine(app).acquire() as conn:
if new_user_email:
user: RowProxy = await _db.get_user_from_email(conn, new_user_email)
new_user_id = user["id"]

if not new_user_id:
raise GroupsError("Missing new user in arguments")
msg = "Missing new user in arguments"
raise GroupsError(msg)

return await _db.add_new_user_in_group(
conn,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,17 @@
from contextlib import contextmanager
from typing import Final

import sqlalchemy as sa
from aiohttp import ClientError, ClientResponseError, web
from models_library.api_schemas_invitations.invitations import (
ApiInvitationContent,
ApiInvitationContentAndLink,
ApiInvitationInputs,
)
from models_library.users import GroupID
from models_library.emails import LowerCaseEmailStr
from pydantic import AnyHttpUrl, ValidationError, parse_obj_as
from servicelib.error_codes import create_error_code
from simcore_postgres_database.models.groups import user_to_groups
from simcore_postgres_database.models.users import users

from ..db.plugin import get_database_engine
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 (
Expand All @@ -29,31 +26,6 @@
_logger = logging.getLogger(__name__)


async def _is_user_registered_in_platform(app: web.Application, email: str) -> bool:
pg_engine = get_database_engine(app=app)
async with pg_engine.acquire() as conn:
user_id = await conn.scalar(sa.select(users.c.id).where(users.c.email == email))
return user_id is not None


async def _is_user_registered_in_product(
app: web.Application, email: str, product_group_id: GroupID
) -> bool:
pg_engine = get_database_engine(app=app)

async with pg_engine.acquire() as conn:
user_id = await conn.scalar(
sa.select(users.c.id)
.select_from(
sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id)
)
.where(
(users.c.email == email) & (user_to_groups.c.gid == product_group_id)
)
)
return user_id is not None


@contextmanager
def _handle_exceptions_as_invitations_errors():
try:
Expand All @@ -64,8 +36,7 @@ def _handle_exceptions_as_invitations_errors():
if err.status == web.HTTPUnprocessableEntity.status_code:
error_code = create_error_code(err)
_logger.exception(
"Invitation request %s unexpectedly failed [%s]",
f"{err=} ",
"Invitation request unexpectedly failed [%s]",
f"{error_code}",
extra={"error_code": error_code},
)
Expand Down Expand Up @@ -130,7 +101,7 @@ async def validate_invitation_url(
)

# check email
if invitation.guest != guest_email:
if invitation.guest.lower() != guest_email.lower():
raise InvalidInvitation(
reason="This invitation was issued for a different email"
)
Expand All @@ -148,9 +119,12 @@ async def validate_invitation_url(

# check invitation used
assert invitation.product == current_product.name # nosec
if await _is_user_registered_in_product(
app=app, email=invitation.guest, product_group_id=current_product.group_id
):
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 InvalidInvitation(reason=MSG_INVITATION_ALREADY_USED)

Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
str
] = "Please click on the verification link we sent to your new email address"
MSG_EMAIL_CHANGED: Final[str] = "Your email is changed"
MSG_EMAIL_EXISTS: Final[str] = "This email is already registered"
MSG_EMAIL_ALREADY_REGISTERED: Final[
str
] = "The email you have provided is already registered" # NOTE: avoid the wording 'product'. User only tries to register in a website.
MSG_EMAIL_SENT: Final[
str
] = "An email has been sent to {email} with further instructions"
Expand Down Expand Up @@ -53,6 +55,11 @@
MSG_USER_EXPIRED: Final[
str
] = "This account has expired and does not have anymore access. Please contact support for further details: {support_email}"

MSG_USER_DISABLED: Final[
str
] = "This account was disabled and cannot be registered. Please contact support for further details: {support_email}"

MSG_WRONG_2FA_CODE: Final[str] = "Invalid code (wrong or expired)"
MSG_WRONG_PASSWORD: Final[str] = "Wrong password"
MSG_WEAK_PASSWORD: Final[
Expand Down
Loading

0 comments on commit 87a98f1

Please sign in to comment.