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 registration in multiple products via invitations #5055

Merged
merged 34 commits into from
Jan 6, 2024
Merged
Changes from 33 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9b421b3
WIP
pcrespov Nov 20, 2023
ce4edcd
common fixture app_env
pcrespov Nov 20, 2023
2e636ec
cleanup
pcrespov Nov 20, 2023
cc72ccb
cleanup login getstration
pcrespov Nov 20, 2023
51ab5df
cleanup
pcrespov Nov 20, 2023
2e8736e
groups offer new api
pcrespov Nov 20, 2023
b3a8895
changes msg
pcrespov Nov 20, 2023
e47e15d
check considers product
pcrespov Nov 20, 2023
d712482
comment
pcrespov Jan 3, 2024
3bdcd11
default password
pcrespov Jan 3, 2024
46ce9ed
fix tests: adds product to user
pcrespov Jan 3, 2024
2c9a4c1
fix tests: adds product to user
pcrespov Jan 3, 2024
1366466
minor
pcrespov Jan 3, 2024
5a3b4a3
minor
pcrespov Jan 3, 2024
d05717a
renaming
pcrespov Jan 3, 2024
cf9a32f
is disabled
pcrespov Jan 3, 2024
59676a5
match user-status
pcrespov Jan 3, 2024
33a1fb3
rename fixture
pcrespov Jan 3, 2024
79b4d8d
mionr
pcrespov Jan 3, 2024
755259b
adds test for multiple invitations
pcrespov Jan 3, 2024
83d46c5
rename
pcrespov Jan 3, 2024
9165f16
simplify
pcrespov Jan 3, 2024
70596c9
invitation_url
pcrespov Jan 4, 2024
c7a73bf
compare lowercase
pcrespov Jan 4, 2024
2a18fca
fake encoding
pcrespov Jan 4, 2024
511439c
tests on different deployed products
pcrespov Jan 4, 2024
a9a98a2
typo
pcrespov Jan 4, 2024
384348f
minor
pcrespov Jan 4, 2024
e4f9a35
cleanup
pcrespov Jan 4, 2024
fb8427c
@GitHK review: const
pcrespov Jan 5, 2024
b8a57ef
@GitHK review: message
pcrespov Jan 5, 2024
29be72b
@GitHK review: doc
pcrespov Jan 5, 2024
da9e9bd
@matusdrobuliak66 review: refactor api
pcrespov Jan 5, 2024
8aab3b8
mypy
pcrespov Jan 5, 2024
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
@@ -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
Original file line number Diff line number Diff line change
@@ -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]:
@@ -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)
Original file line number Diff line number Diff line change
@@ -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


@@ -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)
@@ -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()
@@ -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):
@@ -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,
Original file line number Diff line number Diff line change
@@ -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
@@ -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,
)
4 changes: 2 additions & 2 deletions services/invitations/src/simcore_service_invitations/cli.py
Original file line number Diff line number Diff line change
@@ -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,
)

@@ -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(),
Original file line number Diff line number Diff line change
@@ -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

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
@@ -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
pcrespov marked this conversation as resolved.
Show resolved Hide resolved


async def add_new_user_in_group(
conn: SAConnection,
user_id: UserID,
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
@@ -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
@@ -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, # type: ignore
group_id=group_id,
)


async def add_user_in_group(
app: web.Application,
user_id: UserID,
@@ -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,
Original file line number Diff line number Diff line change
@@ -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 (
@@ -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:
@@ -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},
)
@@ -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"
)
@@ -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)

Empty file.
Original file line number Diff line number Diff line change
@@ -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"
@@ -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[
Loading