From 33898685d140ca0b0d3af16af1006fc84241f549 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:42:16 +0200 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9C=A8=20Implement=20ConfirmationRepos?= =?UTF-8?q?itory=20for=20managing=20user=20confirmation=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/_confirmation_repository.py | 147 ++++++++++++++++++ .../login/_login_repository_legacy.py | 3 + .../login/_models.py | 17 ++ 3 files changed, 167 insertions(+) create mode 100644 services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py new file mode 100644 index 000000000000..7da8bd08c0b5 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py @@ -0,0 +1,147 @@ +import logging +from datetime import datetime +from typing import Any + +import sqlalchemy as sa +from models_library.users import UserID +from servicelib.utils_secrets import generate_passcode +from simcore_postgres_database.models.confirmations import confirmations +from simcore_postgres_database.models.users import users +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) +from sqlalchemy.engine import Row +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..db.base_repository import BaseRepository +from ._models import ActionLiteralStr, Confirmation + +_logger = logging.getLogger(__name__) + + +def _to_domain(confirmation_row: Row) -> Confirmation: + return Confirmation.model_validate(confirmation_row) + + +class ConfirmationRepository(BaseRepository): + + async def create_confirmation( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + action: ActionLiteralStr, + data: str | None = None, + ) -> Confirmation: + """Create a new confirmation token for a user action.""" + async with pass_or_acquire_connection(self.engine, connection) as conn: + # Generate unique code + while True: + # NOTE: use only numbers since front-end does not handle well url encoding + numeric_code: str = generate_passcode(20) + + # Check if code already exists + check_query = sa.select(confirmations.c.code).where( + confirmations.c.code == numeric_code + ) + result = await conn.execute(check_query) + if result.one_or_none() is None: + break + + # Insert confirmation + insert_query = ( + sa.insert(confirmations) + .values( + code=numeric_code, + user_id=user_id, + action=action, + data=data, + created_at=datetime.utcnow(), + ) + .returning(*confirmations.c) + ) + + result = await conn.execute(insert_query) + row = result.one() + return _to_domain(row) + + async def get_confirmation( + self, + connection: AsyncConnection | None = None, + *, + filter_dict: dict[str, Any], + ) -> Confirmation | None: + """Get a confirmation by filter criteria.""" + # Handle legacy "user" key + if "user" in filter_dict: + filter_dict["user_id"] = filter_dict.pop("user")["id"] + + # Build where conditions + where_conditions = [] + for key, value in filter_dict.items(): + if hasattr(confirmations.c, key): + where_conditions.append(getattr(confirmations.c, key) == value) + + query = sa.select(*confirmations.c).where(sa.and_(*where_conditions)) + + async with pass_or_acquire_connection(self.engine, connection) as conn: + result = await conn.execute(query) + if row := result.one_or_none(): + return _to_domain(row) + return None + + async def delete_confirmation( + self, + connection: AsyncConnection | None = None, + *, + confirmation: Confirmation, + ) -> None: + """Delete a confirmation token.""" + query = sa.delete(confirmations).where( + confirmations.c.code == confirmation.code + ) + + async with pass_or_acquire_connection(self.engine, connection) as conn: + await conn.execute(query) + + async def delete_confirmation_and_user( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + confirmation: Confirmation, + ) -> None: + """Atomically delete confirmation and user.""" + async with transaction_context(self.engine, connection) as conn: + # Delete confirmation + await conn.execute( + sa.delete(confirmations).where( + confirmations.c.code == confirmation.code + ) + ) + + # Delete user + await conn.execute(sa.delete(users).where(users.c.id == user_id)) + + async def delete_confirmation_and_update_user( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + updates: dict[str, Any], + confirmation: Confirmation, + ) -> None: + """Atomically delete confirmation and update user.""" + async with transaction_context(self.engine, connection) as conn: + # Delete confirmation + await conn.execute( + sa.delete(confirmations).where( + confirmations.c.code == confirmation.code + ) + ) + + # Update user + await conn.execute( + sa.update(users).where(users.c.id == user_id).values(**updates) + ) diff --git a/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py index 33be73a0fb1a..e7f904e1544d 100644 --- a/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py +++ b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py @@ -125,6 +125,9 @@ async def delete_confirmation_and_update_user( conn, self.user_tbl, {"id": user_id}, updates ) + # NOTE: This class is deprecated. Use ConfirmationRepository instead. + # Keeping for backwards compatibility during migration. + def get_plugin_storage(app: web.Application) -> AsyncpgStorage: storage = cast(AsyncpgStorage, app.get(APP_LOGIN_STORAGE_KEY)) diff --git a/services/web/server/src/simcore_service_webserver/login/_models.py b/services/web/server/src/simcore_service_webserver/login/_models.py index b7243d76039a..9947cb49f230 100644 --- a/services/web/server/src/simcore_service_webserver/login/_models.py +++ b/services/web/server/src/simcore_service_webserver/login/_models.py @@ -1,9 +1,26 @@ from collections.abc import Callable +from datetime import datetime +from typing import Literal +from models_library.users import UserID from pydantic import BaseModel, ConfigDict, SecretStr, ValidationInfo from .constants import MSG_PASSWORD_MISMATCH +ActionLiteralStr = Literal[ + "REGISTRATION", "INVITATION", "RESET_PASSWORD", "CHANGE_EMAIL" +] + + +class Confirmation(BaseModel): + model_config = ConfigDict(from_attributes=True) + + code: str + user_id: UserID + action: ActionLiteralStr + data: str | None + created_at: datetime + class InputSchema(BaseModel): model_config = ConfigDict( From c25904ab7159878c25ad81909645b2d55515b245 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:45:21 +0200 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9C=A8=20Refactor=20confirmation=20ser?= =?UTF-8?q?vice=20and=20repository=20integration=20for=20improved=20token?= =?UTF-8?q?=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/_confirmation_service.py | 127 +++++++++--------- .../login/_models.py | 4 +- .../tests/unit/with_dbs/03/login/conftest.py | 27 +++- .../login/test_login_confirmation_service.py | 93 ++++++------- ...test_login_controller_confirmation_rest.py | 27 ++-- .../with_dbs/03/login/test_login_logout.py | 8 +- .../03/login/test_login_registration.py | 40 +++++- .../with_dbs/03/login/test_login_twofa.py | 11 +- 8 files changed, 197 insertions(+), 140 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py index dc8d4b027c82..0f14f9f8c94c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py @@ -7,74 +7,81 @@ """ import logging -from datetime import datetime +from datetime import UTC, datetime from models_library.users import UserID -from ._login_repository_legacy import ( - ActionLiteralStr, - AsyncpgStorage, - ConfirmationTokenDict, -) +from ._confirmation_repository import ConfirmationRepository +from ._models import ActionLiteralStr, Confirmation from .settings import LoginOptions _logger = logging.getLogger(__name__) -async def get_or_create_confirmation_without_data( - cfg: LoginOptions, - db: AsyncpgStorage, - user_id: UserID, - action: ActionLiteralStr, -) -> ConfirmationTokenDict: - - confirmation: ConfirmationTokenDict | None = await db.get_confirmation( - {"user": {"id": user_id}, "action": action} - ) - - if confirmation is not None and is_confirmation_expired(cfg, confirmation): - await db.delete_confirmation(confirmation) - _logger.warning( - "Used expired token [%s]. Deleted from confirmations table.", - confirmation, +class ConfirmationService: + """Service for managing confirmation tokens and codes.""" + + def __init__( + self, + confirmation_repository: ConfirmationRepository, + login_options: LoginOptions, + ) -> None: + self._repository = confirmation_repository + self._options = login_options + + @property + def options(self) -> LoginOptions: + """Access to login options for testing purposes.""" + return self._options + + async def get_or_create_confirmation_without_data( + self, + user_id: UserID, + action: ActionLiteralStr, + ) -> Confirmation: + """Get existing or create new confirmation token for user action.""" + confirmation = await self._repository.get_confirmation( + filter_dict={"user_id": user_id, "action": action} ) - confirmation = None - - if confirmation is None: - confirmation = await db.create_confirmation(user_id, action=action) - - return confirmation - - -def get_expiration_date( - cfg: LoginOptions, confirmation: ConfirmationTokenDict -) -> datetime: - lifetime = cfg.get_confirmation_lifetime(confirmation["action"]) - return confirmation["created_at"] + lifetime - - -def is_confirmation_expired(cfg: LoginOptions, confirmation: ConfirmationTokenDict): - age = datetime.utcnow() - confirmation["created_at"] - lifetime = cfg.get_confirmation_lifetime(confirmation["action"]) - return age > lifetime - - -async def validate_confirmation_code( - code: str, db: AsyncpgStorage, cfg: LoginOptions -) -> ConfirmationTokenDict | None: - """ - Returns None if validation fails - """ - assert not code.startswith("***"), "forgot .get_secret_value()??" # nosec - confirmation: ConfirmationTokenDict | None = await db.get_confirmation( - {"code": code} - ) - if confirmation and is_confirmation_expired(cfg, confirmation): - await db.delete_confirmation(confirmation) - _logger.warning( - "Used expired token [%s]. Deleted from confirmations table.", - confirmation, + if confirmation is not None and self.is_confirmation_expired(confirmation): + await self._repository.delete_confirmation(confirmation=confirmation) + _logger.warning( + "Used expired token [%s]. Deleted from confirmations table.", + confirmation, + ) + confirmation = None + + if confirmation is None: + confirmation = await self._repository.create_confirmation( + user_id=user_id, action=action + ) + + return confirmation + + def get_expiration_date(self, confirmation: Confirmation) -> datetime: + """Get expiration date for confirmation token.""" + lifetime = self._options.get_confirmation_lifetime(confirmation.action) + return confirmation.created_at + lifetime + + def is_confirmation_expired(self, confirmation: Confirmation) -> bool: + """Check if confirmation token has expired.""" + age = datetime.now(tz=UTC) - confirmation.created_at + lifetime = self._options.get_confirmation_lifetime(confirmation.action) + return age > lifetime + + async def validate_confirmation_code(self, code: str) -> Confirmation | None: + """Validate confirmation code and return confirmation if valid.""" + assert not code.startswith("***"), "forgot .get_secret_value()??" # nosec + + confirmation = await self._repository.get_confirmation( + filter_dict={"code": code} ) - return None - return confirmation + if confirmation and self.is_confirmation_expired(confirmation): + await self._repository.delete_confirmation(confirmation=confirmation) + _logger.warning( + "Used expired token [%s]. Deleted from confirmations table.", + confirmation, + ) + return None + return confirmation diff --git a/services/web/server/src/simcore_service_webserver/login/_models.py b/services/web/server/src/simcore_service_webserver/login/_models.py index 9947cb49f230..e411fa133205 100644 --- a/services/web/server/src/simcore_service_webserver/login/_models.py +++ b/services/web/server/src/simcore_service_webserver/login/_models.py @@ -13,12 +13,10 @@ class Confirmation(BaseModel): - model_config = ConfigDict(from_attributes=True) - code: str user_id: UserID action: ActionLiteralStr - data: str | None + data: str | None = None created_at: datetime diff --git a/services/web/server/tests/unit/with_dbs/03/login/conftest.py b/services/web/server/tests/unit/with_dbs/03/login/conftest.py index a6b1dd9d5969..92631213d9d3 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/login/conftest.py @@ -17,6 +17,10 @@ from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict from simcore_postgres_database.models.users import users from simcore_postgres_database.models.wallets import wallets +from simcore_service_webserver.login._confirmation_repository import ( + ConfirmationRepository, +) +from simcore_service_webserver.login._confirmation_service import ConfirmationService from simcore_service_webserver.login._login_repository_legacy import ( AsyncpgStorage, get_plugin_storage, @@ -84,7 +88,7 @@ def fake_weak_password(faker: Faker) -> str: @pytest.fixture -def db(client: TestClient) -> AsyncpgStorage: +def db_storage_deprecated(client: TestClient) -> AsyncpgStorage: """login database repository instance""" assert client.app db: AsyncpgStorage = get_plugin_storage(client.app) @@ -92,6 +96,27 @@ def db(client: TestClient) -> AsyncpgStorage: return db +@pytest.fixture +def confirmation_repository(client: TestClient) -> ConfirmationRepository: + """Modern confirmation repository instance""" + assert client.app + # Get the async engine from the application + engine = client.app["postgres_db_engine"] + return ConfirmationRepository(engine) + + +@pytest.fixture +def confirmation_service( + confirmation_repository: ConfirmationRepository, login_options: LoginOptions +) -> ConfirmationService: + """Confirmation service instance""" + from simcore_service_webserver.login._confirmation_service import ( + ConfirmationService, + ) + + return ConfirmationService(confirmation_repository, login_options) + + @pytest.fixture def login_options(client: TestClient) -> LoginOptions: """app's login options""" diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py index 30f06a474fac..d1d09bce7f5b 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py @@ -1,47 +1,47 @@ from datetime import timedelta from aiohttp.test_utils import make_mocked_request -from aiohttp.web import Application +from aiohttp.web import Application, Response from pytest_simcore.helpers.webserver_users import UserInfoDict -from simcore_service_webserver.login import _confirmation_service, _confirmation_web -from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage -from simcore_service_webserver.login.settings import LoginOptions +from simcore_service_webserver.login import _confirmation_web +from simcore_service_webserver.login._confirmation_service import ConfirmationService async def test_confirmation_token_workflow( - db: AsyncpgStorage, login_options: LoginOptions, registered_user: UserInfoDict + confirmation_service: ConfirmationService, + registered_user: UserInfoDict, ): # Step 1: Create a new confirmation token user_id = registered_user["id"] action = "RESET_PASSWORD" - confirmation = await _confirmation_service.get_or_create_confirmation_without_data( - login_options, db, user_id=user_id, action=action + confirmation = await confirmation_service.get_or_create_confirmation_without_data( + user_id=user_id, action=action ) assert confirmation is not None - assert confirmation["user_id"] == user_id - assert confirmation["action"] == action + assert confirmation.user_id == user_id + assert confirmation.action == action # Step 2: Check that the token is not expired - assert not _confirmation_service.is_confirmation_expired( - login_options, confirmation - ) + assert not confirmation_service.is_confirmation_expired(confirmation) # Step 3: Validate the confirmation code - code = confirmation["code"] - validated_confirmation = await _confirmation_service.validate_confirmation_code( - code, db, login_options - ) + code = confirmation.code + validated_confirmation = await confirmation_service.validate_confirmation_code(code) assert validated_confirmation is not None - assert validated_confirmation["code"] == code - assert validated_confirmation["user_id"] == user_id - assert validated_confirmation["action"] == action + assert validated_confirmation.code == code + assert validated_confirmation.user_id == user_id + assert validated_confirmation.action == action # Step 4: Create confirmation link app = Application() + + async def mock_handler(request): + return Response() + app.router.add_get( - "/auth/confirmation/{code}", lambda request: None, name="auth_confirmation" + "/auth/confirmation/{code}", mock_handler, name="auth_confirmation" ) request = make_mocked_request( "GET", @@ -52,53 +52,48 @@ async def test_confirmation_token_workflow( # Create confirmation link confirmation_link = _confirmation_web.make_confirmation_link( - request, confirmation["code"] + request, confirmation.code ) # Assertions assert confirmation_link.startswith("https://example.com/auth/confirmation/") - assert confirmation["code"] in confirmation_link + assert confirmation.code in confirmation_link async def test_expired_confirmation_token( - db: AsyncpgStorage, login_options: LoginOptions, registered_user: UserInfoDict + confirmation_service: ConfirmationService, + registered_user: UserInfoDict, ): user_id = registered_user["id"] action = "CHANGE_EMAIL" # Create a brand new confirmation token - confirmation_1 = ( - await _confirmation_service.get_or_create_confirmation_without_data( - login_options, db, user_id=user_id, action=action - ) + confirmation_1 = await confirmation_service.get_or_create_confirmation_without_data( + user_id=user_id, action=action ) assert confirmation_1 is not None - assert confirmation_1["user_id"] == user_id - assert confirmation_1["action"] == action + assert confirmation_1.user_id == user_id + assert confirmation_1.action == action # Check that the token is not expired - assert not _confirmation_service.is_confirmation_expired( - login_options, confirmation_1 - ) - assert _confirmation_service.get_expiration_date(login_options, confirmation_1) + assert not confirmation_service.is_confirmation_expired(confirmation_1) + assert confirmation_service.get_expiration_date(confirmation_1) - confirmation_2 = ( - await _confirmation_service.get_or_create_confirmation_without_data( - login_options, db, user_id=user_id, action=action - ) + confirmation_2 = await confirmation_service.get_or_create_confirmation_without_data( + user_id=user_id, action=action ) assert confirmation_2 == confirmation_1 # Enforce ALL EXPIRED - login_options.CHANGE_EMAIL_CONFIRMATION_LIFETIME = 0 - assert login_options.get_confirmation_lifetime(action) == timedelta(seconds=0) + confirmation_service.options.CHANGE_EMAIL_CONFIRMATION_LIFETIME = 0 + assert confirmation_service.options.get_confirmation_lifetime(action) == timedelta( + seconds=0 + ) - confirmation_3 = ( - await _confirmation_service.get_or_create_confirmation_without_data( - login_options, db, user_id=user_id, action=action - ) + confirmation_3 = await confirmation_service.get_or_create_confirmation_without_data( + user_id=user_id, action=action ) # when expired, it gets renewed @@ -106,20 +101,14 @@ async def test_expired_confirmation_token( # now all have expired assert ( - await _confirmation_service.validate_confirmation_code( - confirmation_1["code"], db, login_options - ) + await confirmation_service.validate_confirmation_code(confirmation_1.code) is None ) assert ( - await _confirmation_service.validate_confirmation_code( - confirmation_2["code"], db, login_options - ) + await confirmation_service.validate_confirmation_code(confirmation_2.code) is None ) assert ( - await _confirmation_service.validate_confirmation_code( - confirmation_3["code"], db, login_options - ) + await confirmation_service.validate_confirmation_code(confirmation_3.code) is None ) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_controller_confirmation_rest.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_controller_confirmation_rest.py index ce12c1244f6f..24a0b9413106 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_controller_confirmation_rest.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_controller_confirmation_rest.py @@ -13,28 +13,31 @@ from models_library.products import ProductName from pytest_simcore.helpers.webserver_users import UserInfoDict from servicelib.aiohttp import status -from simcore_service_webserver.login._login_repository_legacy import ( - ActionLiteralStr, - AsyncpgStorage, - ConfirmationTokenDict, +from simcore_service_webserver.login._confirmation_repository import ( + ConfirmationRepository, ) +from simcore_service_webserver.login._models import ActionLiteralStr, Confirmation from simcore_service_webserver.users import _users_service from simcore_service_webserver.wallets import _api as _wallets_service from simcore_service_webserver.wallets import _db as _wallets_repository CreateTokenCallable: TypeAlias = Callable[ - [int, ActionLiteralStr, str | None], Coroutine[Any, Any, ConfirmationTokenDict] + [int, ActionLiteralStr, str | None], Coroutine[Any, Any, Confirmation] ] @pytest.fixture -async def create_valid_confirmation_token(db: AsyncpgStorage) -> CreateTokenCallable: +async def create_valid_confirmation_token( + confirmation_repository: ConfirmationRepository, +) -> CreateTokenCallable: """Fixture to create a valid confirmation token for a given action.""" async def _create_token( user_id: int, action: ActionLiteralStr, data: str | None = None - ) -> ConfirmationTokenDict: - return await db.create_confirmation(user_id=user_id, action=action, data=data) + ) -> Confirmation: + return await confirmation_repository.create_confirmation( + user_id=user_id, action=action, data=data + ) return _create_token @@ -50,7 +53,7 @@ async def test_confirm_registration( confirmation = await create_valid_confirmation_token( target_user_id, "REGISTRATION", None ) - code = confirmation["code"] + code = confirmation.code # clicks link to confirm registration response = await client.get(f"/v0/auth/confirmation/{code}") @@ -91,7 +94,7 @@ async def test_confirm_change_email( confirmation = await create_valid_confirmation_token( user_id, "CHANGE_EMAIL", "new_" + registered_user["email"] ) - code = confirmation["code"] + code = confirmation.code # clicks link to confirm registration response = await client.get(f"/v0/auth/confirmation/{code}") @@ -112,7 +115,7 @@ async def test_confirm_reset_password( confirmation = await create_valid_confirmation_token( user_id, "RESET_PASSWORD", None ) - code = confirmation["code"] + code = confirmation.code response = await client.get(f"/v0/auth/confirmation/{code}") assert response.status == status.HTTP_200_OK @@ -134,7 +137,7 @@ async def test_handler_exception_logging( ): user_id = registered_user["id"] confirmation = await create_valid_confirmation_token(user_id, "REGISTRATION", None) - code = confirmation["code"] + code = confirmation.code with patch( "simcore_service_webserver.login._controller.rest.confirmation._handle_confirm_registration", diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_logout.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_logout.py index 52ab61ed19ed..810a4d57e1b8 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_logout.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_logout.py @@ -6,10 +6,14 @@ from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser from servicelib.aiohttp import status -from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage +from simcore_service_webserver.login._confirmation_repository import ( + ConfirmationRepository, +) -async def test_logout(client: TestClient, db: AsyncpgStorage): +async def test_logout( + client: TestClient, confirmation_repository: ConfirmationRepository +): assert client.app logout_url = client.app.router["auth_logout"].url_for() diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py index d54213cb29b2..e469c1ce7579 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -20,6 +20,9 @@ from simcore_service_webserver.db.models import UserStatus from simcore_service_webserver.groups.api import auto_add_user_to_product_group from simcore_service_webserver.login import _auth_service +from simcore_service_webserver.login._confirmation_repository import ( + ConfirmationRepository, +) from simcore_service_webserver.login._confirmation_web import _url_for_confirmation from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage from simcore_service_webserver.login.constants import ( @@ -144,7 +147,7 @@ async def test_registration_with_registered_user( async def test_registration_invitation_stays_valid_if_once_tried_with_weak_password( client: TestClient, login_options: LoginOptions, - db: AsyncpgStorage, + confirmation_repository: ConfirmationRepository, mocker: MockerFixture, user_email: str, user_password: str, @@ -157,9 +160,12 @@ async def test_registration_invitation_stays_valid_if_once_tried_with_weak_passw "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( + LOGIN_ACCOUNT_DELETION_RETENTION_DAYS=30, LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, LOGIN_REGISTRATION_INVITATION_REQUIRED=True, LOGIN_TWILIO=None, + LOGIN_2FA_REQUIRED=False, + LOGIN_PASSWORD_MIN_LENGTH=12, ), ) @@ -201,7 +207,9 @@ async def test_registration_invitation_stays_valid_if_once_tried_with_weak_passw "invitation": confirmation["code"], }, ) - assert not await db.get_confirmation(confirmation) + assert not await confirmation_repository.get_confirmation( + filter_dict={"code": confirmation["code"]} + ) async def test_registration_with_weak_password_fails( @@ -235,7 +243,7 @@ async def test_registration_with_weak_password_fails( async def test_registration_with_invalid_confirmation_code( client: TestClient, login_options: LoginOptions, - db: AsyncpgStorage, + db_storage_deprecated: AsyncpgStorage, mocker: MockerFixture, cleanup_db_tables: None, ): @@ -245,9 +253,12 @@ async def test_registration_with_invalid_confirmation_code( "simcore_service_webserver.login.settings.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( + LOGIN_ACCOUNT_DELETION_RETENTION_DAYS=30, LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=True, LOGIN_REGISTRATION_INVITATION_REQUIRED=False, # <----- NO invitation LOGIN_TWILIO=None, + LOGIN_2FA_REQUIRED=False, + LOGIN_PASSWORD_MIN_LENGTH=12, ), ) @@ -274,9 +285,12 @@ async def test_registration_without_confirmation( "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( + LOGIN_ACCOUNT_DELETION_RETENTION_DAYS=30, LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, LOGIN_REGISTRATION_INVITATION_REQUIRED=False, LOGIN_TWILIO=None, + LOGIN_2FA_REQUIRED=False, + LOGIN_PASSWORD_MIN_LENGTH=12, ), ) @@ -311,9 +325,12 @@ async def test_registration_with_confirmation( "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( + LOGIN_ACCOUNT_DELETION_RETENTION_DAYS=30, LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=True, LOGIN_REGISTRATION_INVITATION_REQUIRED=False, LOGIN_TWILIO=None, + LOGIN_2FA_REQUIRED=False, + LOGIN_PASSWORD_MIN_LENGTH=12, ), ) @@ -367,7 +384,7 @@ async def test_registration_with_confirmation( async def test_registration_with_invitation( client: TestClient, login_options: LoginOptions, - db: AsyncpgStorage, + confirmation_repository: ConfirmationRepository, is_invitation_required: bool, has_valid_invitation: bool, expected_response: HTTPStatus, @@ -381,9 +398,12 @@ async def test_registration_with_invitation( "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( + LOGIN_ACCOUNT_DELETION_RETENTION_DAYS=30, LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, LOGIN_REGISTRATION_INVITATION_REQUIRED=is_invitation_required, LOGIN_TWILIO=None, + LOGIN_2FA_REQUIRED=False, + LOGIN_PASSWORD_MIN_LENGTH=12, ), ) @@ -424,7 +444,9 @@ async def test_registration_with_invitation( await assert_status(response, expected_response) if is_invitation_required and has_valid_invitation: - assert not await db.get_confirmation(confirmation) + assert not await confirmation_repository.get_confirmation( + filter_dict={"code": confirmation["code"]} + ) async def test_registraton_with_invitation_for_trial_account( @@ -441,9 +463,12 @@ async def test_registraton_with_invitation_for_trial_account( "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( + LOGIN_ACCOUNT_DELETION_RETENTION_DAYS=30, LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, LOGIN_REGISTRATION_INVITATION_REQUIRED=True, LOGIN_TWILIO=None, + LOGIN_2FA_REQUIRED=False, + LOGIN_PASSWORD_MIN_LENGTH=12, ), ) @@ -498,6 +523,9 @@ async def test_registraton_with_invitation_for_trial_account( data, _ = await assert_status(response, status.HTTP_200_OK) profile = MyProfileRestGet.model_validate(data) - expected = invitation.user["created_at"] + timedelta(days=TRIAL_DAYS) + from datetime import UTC, datetime + + created_at = invitation.user.get("created_at") or datetime.now(UTC) + expected = created_at + timedelta(days=TRIAL_DAYS) assert profile.expiration_date assert profile.expiration_date == expected.date() diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py index b31b1fae3fcb..832da7c5b9b6 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py @@ -5,6 +5,7 @@ import asyncio import logging +import re from contextlib import AsyncExitStack import pytest @@ -22,7 +23,9 @@ from simcore_service_webserver.application_settings import ApplicationSettings from simcore_service_webserver.db.models import UserStatus from simcore_service_webserver.login import _auth_service -from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage +from simcore_service_webserver.login._confirmation_repository import ( + ConfirmationRepository, +) from simcore_service_webserver.login._twofa_service import ( _do_create_2fa_code, create_2fa_code, @@ -120,10 +123,10 @@ async def test_2fa_code_operations(client: TestClient): assert await get_2fa_code(client.app, email) is None -@pytest.mark.acceptance_test() +@pytest.mark.acceptance_test async def test_workflow_register_and_login_with_2fa( client: TestClient, - db: AsyncpgStorage, + confirmation_repository: ConfirmationRepository, capsys: pytest.CaptureFixture, user_email: str, user_password: str, @@ -401,7 +404,7 @@ async def test_send_email_code( product=Product( name="osparc", display_name="The Foo Product", - host_regex=r".+", + host_regex=re.compile(r".+"), vendor={}, short_name="foo", support_email=support_email, From 988e059260b2cab06329ca39ab8dede7b35b73a9 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 23 Jul 2025 23:17:40 +0200 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9C=A8=20Enhance=20confirmation=20serv?= =?UTF-8?q?ice=20integration:=20add=20methods=20for=20managing=20confirmat?= =?UTF-8?q?ion=20tokens=20in=20user=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/_confirmation_service.py | 40 ++++++++++ .../login/_controller/rest/change.py | 42 +++++----- .../login/_controller/rest/confirmation.py | 76 +++++++++++-------- .../login/_controller/rest/registration.py | 21 ++++- 4 files changed, 128 insertions(+), 51 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py index 0f14f9f8c94c..0df516798f35 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py @@ -8,6 +8,7 @@ import logging from datetime import UTC, datetime +from typing import Any from models_library.users import UserID @@ -85,3 +86,42 @@ async def validate_confirmation_code(self, code: str) -> Confirmation | None: ) return None return confirmation + + async def delete_confirmation(self, confirmation: Confirmation) -> None: + """Delete a confirmation token.""" + await self._repository.delete_confirmation(confirmation=confirmation) + + async def delete_confirmation_and_update_user( + self, + confirmation: Confirmation, + user_id: UserID, + updates: dict[str, Any], + ) -> None: + """Atomically delete confirmation and update user.""" + await self._repository.delete_confirmation_and_update_user( + confirmation=confirmation, + user_id=user_id, + updates=updates, + ) + + async def get_confirmation( + self, filter_dict: dict[str, Any] + ) -> Confirmation | None: + """Get a confirmation by filter criteria.""" + return await self._repository.get_confirmation(filter_dict=filter_dict) + + async def create_confirmation( + self, user_id: UserID, action: ActionLiteralStr, data: str | None = None + ) -> Confirmation: + """Create a new confirmation token for a user action.""" + return await self._repository.create_confirmation( + user_id=user_id, action=action, data=data + ) + + async def delete_confirmation_and_user( + self, user_id: UserID, confirmation: Confirmation + ) -> None: + """Atomically delete confirmation and user.""" + await self._repository.delete_confirmation_and_user( + user_id=user_id, confirmation=confirmation + ) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py index 1fe1e28662bc..b093f654e02c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py @@ -15,9 +15,10 @@ from ....utils import HOUR from ....utils_rate_limiting import global_rate_limit_route from ....web_utils import flash_response -from ... import _auth_service, _confirmation_service, _confirmation_web +from ... import _auth_service, _confirmation_web +from ..._confirmation_repository import ConfirmationRepository +from ..._confirmation_service import ConfirmationService from ..._emails_service import get_template_path, send_email_from_template -from ..._login_repository_legacy import AsyncpgStorage, get_plugin_storage from ..._login_service import ( ACTIVE, CHANGE_EMAIL, @@ -33,12 +34,20 @@ ) from ...decorators import login_required from ...errors import WrongPasswordError -from ...settings import LoginOptions, get_plugin_options +from ...settings import get_plugin_options from .change_schemas import ChangeEmailBody, ChangePasswordBody, ResetPasswordBody _logger = logging.getLogger(__name__) +def _get_confirmation_service(app: web.Application) -> ConfirmationService: + """Get confirmation service instance from app.""" + engine = app["postgres_db_engine"] + repository = ConfirmationRepository(engine) + options = get_plugin_options(app) + return ConfirmationService(repository, options) + + routes = RouteTableDef() @@ -82,8 +91,6 @@ async def initiate_reset_password(request: web.Request): - 4. Who requested the reset? """ - db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) product: Product = products_web.get_current_product(request) request_body = await parse_request_body_as(ResetPasswordBody, request) @@ -177,16 +184,15 @@ def _get_error_context( try: # Confirmation token that includes code to `complete_reset_password`. # Recreated if non-existent or expired (Guideline #2) + confirmation_service = _get_confirmation_service(request.app) confirmation = ( - await _confirmation_service.get_or_create_confirmation_without_data( - cfg, db, user_id=user["id"], action="RESET_PASSWORD" + await confirmation_service.get_or_create_confirmation_without_data( + user_id=user["id"], action="RESET_PASSWORD" ) ) # Produce a link so that the front-end can hit `complete_reset_password` - link = _confirmation_web.make_confirmation_link( - request, confirmation["code"] - ) + link = _confirmation_web.make_confirmation_link(request, confirmation.code) # primary reset email with a URL and the normal instructions. await send_email_from_template( @@ -220,8 +226,8 @@ def _get_error_context( async def initiate_change_email(request: web.Request): # NOTE: This code have been intentially disabled in https://github.com/ITISFoundation/osparc-simcore/pull/5472 - db: AsyncpgStorage = get_plugin_storage(request.app) product: Product = products_web.get_current_product(request) + confirmation_service = _get_confirmation_service(request.app) request_body = await parse_request_body_as(ChangeEmailBody, request) @@ -238,15 +244,17 @@ async def initiate_change_email(request: web.Request): raise web.HTTPUnprocessableEntity(text="This email cannot be used") # Reset if previously requested - confirmation = await db.get_confirmation({"user": user, "action": CHANGE_EMAIL}) - if confirmation: - await db.delete_confirmation(confirmation) + existing_confirmation = await confirmation_service.get_confirmation( + filter_dict={"user_id": user["id"], "action": CHANGE_EMAIL} + ) + if existing_confirmation: + await confirmation_service.delete_confirmation(existing_confirmation) # create new confirmation to ensure email is actually valid - confirmation = await db.create_confirmation( + confirmation = await confirmation_service.create_confirmation( user_id=user["id"], action="CHANGE_EMAIL", data=request_body.email ) - link = _confirmation_web.make_confirmation_link(request, confirmation["code"]) + link = _confirmation_web.make_confirmation_link(request, confirmation.code) try: await send_email_from_template( request, @@ -261,7 +269,7 @@ async def initiate_change_email(request: web.Request): ) except Exception as err: # pylint: disable=broad-except _logger.exception("Can not send change_email_email") - await db.delete_confirmation(confirmation) + await confirmation_service.delete_confirmation(confirmation) raise web.HTTPServiceUnavailable(text=MSG_CANT_SEND_MAIL) from err return flash_response(MSG_CHANGE_EMAIL_REQUESTED) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py index 7003eef09a0d..833a09dfdb71 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py @@ -26,15 +26,14 @@ from ....web_utils import flash_response from ... import ( _auth_service, - _confirmation_service, _registration_service, _security_service, _twofa_service, ) +from ..._confirmation_repository import ConfirmationRepository +from ..._confirmation_service import ConfirmationService from ..._login_repository_legacy import ( - AsyncpgStorage, ConfirmationTokenDict, - get_plugin_storage, ) from ..._login_service import ( ACTIVE, @@ -43,6 +42,7 @@ RESET_PASSWORD, notify_user_confirmation, ) +from ..._models import Confirmation from ...constants import ( MSG_PASSWORD_CHANGE_NOT_ALLOWED, MSG_PASSWORD_CHANGED, @@ -64,47 +64,66 @@ _logger = logging.getLogger(__name__) +def _get_confirmation_service(app: web.Application) -> ConfirmationService: + """Get confirmation service instance from app.""" + engine = app["postgres_db_engine"] + repository = ConfirmationRepository(engine) + options = get_plugin_options(app) + return ConfirmationService(repository, options) + + +def _confirmation_to_legacy_dict(confirmation: Confirmation) -> ConfirmationTokenDict: + """Convert new Confirmation model to legacy ConfirmationTokenDict format.""" + return { + "code": confirmation.code, + "user_id": confirmation.user_id, + "action": confirmation.action, + "data": confirmation.data, + "created_at": confirmation.created_at, + } + + routes = RouteTableDef() async def _handle_confirm_registration( app: web.Application, product_name: ProductName, - confirmation: ConfirmationTokenDict, + confirmation: Confirmation, ): - db: AsyncpgStorage = get_plugin_storage(app) - user_id = confirmation["user_id"] + confirmation_service = _get_confirmation_service(app) + user_id = confirmation.user_id # activate user and consume confirmation token - await db.delete_confirmation_and_update_user( + await confirmation_service.delete_confirmation_and_update_user( + confirmation=confirmation, user_id=user_id, updates={"status": ACTIVE}, - confirmation=confirmation, ) await notify_user_confirmation( app, user_id=user_id, product_name=product_name, - extra_credits_in_usd=parse_extra_credits_in_usd_or_none(confirmation), + extra_credits_in_usd=parse_extra_credits_in_usd_or_none( + _confirmation_to_legacy_dict(confirmation) + ), ) async def _handle_confirm_change_email( - app: web.Application, confirmation: ConfirmationTokenDict + app: web.Application, confirmation: Confirmation ): - db: AsyncpgStorage = get_plugin_storage(app) - user_id = confirmation["user_id"] + confirmation_service = _get_confirmation_service(app) + user_id = confirmation.user_id # update and consume confirmation token - await db.delete_confirmation_and_update_user( + await confirmation_service.delete_confirmation_and_update_user( + confirmation=confirmation, user_id=user_id, updates={ - "email": TypeAdapter(LowerCaseEmailStr).validate_python( - confirmation["data"] - ) + "email": TypeAdapter(LowerCaseEmailStr).validate_python(confirmation.data) }, - confirmation=confirmation, ) @@ -125,22 +144,20 @@ async def validate_confirmation_and_redirect(request: web.Request): - show the reset-password page - use the token to submit a POST /v0/auth/confirmation/{code} and finalize reset action """ - db: AsyncpgStorage = get_plugin_storage(request.app) cfg: LoginOptions = get_plugin_options(request.app) product: Product = products_web.get_current_product(request) + confirmation_service = _get_confirmation_service(request.app) path_params = parse_request_path_parameters_as(CodePathParam, request) - confirmation: ConfirmationTokenDict | None = ( - await _confirmation_service.validate_confirmation_code( - path_params.code.get_secret_value(), - db=db, - cfg=cfg, + confirmation: Confirmation | None = ( + await confirmation_service.validate_confirmation_code( + path_params.code.get_secret_value() ) ) redirect_to_login_url = URL(cfg.LOGIN_REDIRECT) - if confirmation and (action := confirmation["action"]): + if confirmation and (action := confirmation.action): try: if action == REGISTRATION: await _handle_confirm_registration( @@ -249,20 +266,19 @@ async def complete_reset_password(request: web.Request): - Changes password using a token code without login - Code is provided via email by calling first initiate_reset_password """ - db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) product: Product = products_web.get_current_product(request) + confirmation_service = _get_confirmation_service(request.app) path_params = parse_request_path_parameters_as(CodePathParam, request) request_body = await parse_request_body_as(ResetPasswordConfirmation, request) - confirmation = await _confirmation_service.validate_confirmation_code( - code=path_params.code.get_secret_value(), db=db, cfg=cfg + confirmation = await confirmation_service.validate_confirmation_code( + code=path_params.code.get_secret_value() ) if confirmation: user = await _auth_service.get_user_or_none( - request.app, user_id=confirmation["user_id"] + request.app, user_id=confirmation.user_id ) assert user # nosec @@ -274,7 +290,7 @@ async def complete_reset_password(request: web.Request): verify_current_password=False, # confirmed by code ) - await db.delete_confirmation(confirmation) + await confirmation_service.delete_confirmation(confirmation) return flash_response(MSG_PASSWORD_CHANGED) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py index 081bbe650416..b92c7e013a34 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py @@ -30,6 +30,8 @@ _security_service, _twofa_service, ) +from ..._confirmation_repository import ConfirmationRepository +from ..._confirmation_service import ConfirmationService from ..._emails_service import get_template_path, send_email_from_template from ..._invitations_service import ( ConfirmedInvitationData, @@ -39,12 +41,12 @@ ) from ..._login_repository_legacy import ( AsyncpgStorage, - ConfirmationTokenDict, get_plugin_storage, ) from ..._login_service import ( notify_user_confirmation, ) +from ..._models import Confirmation from ...constants import ( CODE_2FA_SMS_CODE_REQUIRED, MAX_2FA_CODE_RESEND, @@ -70,6 +72,14 @@ _logger = logging.getLogger(__name__) +def _get_confirmation_service(app: web.Application) -> ConfirmationService: + """Get confirmation service instance from app.""" + engine = app["postgres_db_engine"] + repository = ConfirmationRepository(engine) + options = get_plugin_options(app) + return ConfirmationService(repository, options) + + routes = RouteTableDef() @@ -224,7 +234,8 @@ async def register(request: web.Request): if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: # Confirmation required: send confirmation email - _confirmation: ConfirmationTokenDict = await db.create_confirmation( + confirmation_service = _get_confirmation_service(request.app) + _confirmation: Confirmation = await confirmation_service.create_confirmation( user_id=user["id"], action="REGISTRATION", data=invitation.model_dump_json() if invitation else None, @@ -232,7 +243,7 @@ async def register(request: web.Request): try: email_confirmation_url = _confirmation_web.make_confirmation_link( - request, _confirmation["code"] + request, _confirmation.code ) email_template_path = await get_template_path( request, "registration_email.jinja2" @@ -270,7 +281,9 @@ async def register(request: web.Request): ) ) - await db.delete_confirmation_and_user(user["id"], _confirmation) + await confirmation_service.delete_confirmation_and_user( + user_id=user["id"], confirmation=_confirmation + ) raise web.HTTPServiceUnavailable(text=user_error_msg) from err From db895984430f31f220758d2f3000843fae57896c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 23 Jul 2025 23:31:39 +0200 Subject: [PATCH 04/19] =?UTF-8?q?=E2=9C=A8=20Refactor=20invitation=20servi?= =?UTF-8?q?ce:=20integrate=20confirmation=20service=20and=20remove=20legac?= =?UTF-8?q?y=20database=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/_controller/rest/registration.py | 11 +--- .../login/_invitations_service.py | 60 +++++++++++++------ 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py index b92c7e013a34..b904c30b9860 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py @@ -39,10 +39,6 @@ check_other_registrations, extract_email_from_invitation, ) -from ..._login_repository_legacy import ( - AsyncpgStorage, - get_plugin_storage, -) from ..._login_service import ( notify_user_confirmation, ) @@ -57,7 +53,6 @@ MSG_WEAK_PASSWORD, ) from ...settings import ( - LoginOptions, LoginSettingsForProduct, get_plugin_options, get_plugin_settings, @@ -129,13 +124,11 @@ async def register(request: web.Request): settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) - db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) registration = await parse_request_body_as(RegisterBody, request) await check_other_registrations( - request.app, email=registration.email, current_product=product, db=db, cfg=cfg + request.app, email=registration.email, current_product=product ) # Check for weak passwords @@ -184,8 +177,6 @@ async def register(request: web.Request): invitation_code, product=product, guest_email=registration.email, - db=db, - cfg=cfg, app=request.app, ) if invitation.trial_account_days: diff --git a/services/web/server/src/simcore_service_webserver/login/_invitations_service.py b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py index 7d753fb26713..52803f400635 100644 --- a/services/web/server/src/simcore_service_webserver/login/_invitations_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py @@ -41,9 +41,10 @@ ) from ..products.models import Product from ..users import users_service -from . import _auth_service, _confirmation_service +from . import _auth_service +from ._confirmation_repository import ConfirmationRepository +from ._confirmation_service import ConfirmationService from ._login_repository_legacy import ( - AsyncpgStorage, BaseConfirmationTokenDict, ConfirmationTokenDict, ) @@ -52,11 +53,19 @@ MSG_INVITATIONS_CONTACT_SUFFIX, MSG_USER_DISABLED, ) -from .settings import LoginOptions +from .settings import get_plugin_options _logger = logging.getLogger(__name__) +def _get_confirmation_service(app: web.Application) -> ConfirmationService: + """Get confirmation service instance from app.""" + engine = app["postgres_db_engine"] + repository = ConfirmationRepository(engine) + options = get_plugin_options(app) + return ConfirmationService(repository, options) + + class ConfirmationTokenInfoDict(ConfirmationTokenDict): expires: datetime url: str @@ -112,8 +121,6 @@ async def check_other_registrations( app: web.Application, email: str, current_product: Product, - db: AsyncpgStorage, - cfg: LoginOptions, ) -> None: # An account is already registered with this email if user := await _auth_service.get_user_or_none(app, email=email): @@ -131,15 +138,16 @@ async def check_other_registrations( # w/ an expired confirmation will get deleted and its account (i.e. email) # can be overtaken by this new registration # - _confirmation = await db.get_confirmation( + confirmation_service = _get_confirmation_service(app) + _confirmation = await confirmation_service.get_confirmation( filter_dict={ - "user": user, + "user_id": user["id"], "action": ConfirmationAction.REGISTRATION.value, } ) drop_previous_registration = ( not _confirmation - or _confirmation_service.is_confirmation_expired(cfg, _confirmation) + or confirmation_service.is_confirmation_expired(_confirmation) ) if drop_previous_registration: if not _confirmation: @@ -147,7 +155,7 @@ async def check_other_registrations( app, user_id=user["id"], clean_cache=False ) else: - await db.delete_confirmation_and_user( + await confirmation_service.delete_confirmation_and_user( user_id=user["id"], confirmation=_confirmation ) @@ -173,7 +181,7 @@ async def check_other_registrations( async def create_invitation_token( - db: AsyncpgStorage, + app: web.Application, *, user_id: IdInt, user_email: LowerCaseEmailStr | None = None, @@ -196,11 +204,19 @@ async def create_invitation_token( trial_account_days=trial_days, extra_credits_in_usd=extra_credits_in_usd, ) - return await db.create_confirmation( + confirmation_service = _get_confirmation_service(app) + confirmation = await confirmation_service.create_confirmation( user_id=user_id, action=ConfirmationAction.INVITATION.name, data=data_model.model_dump_json(), ) + return { + "code": confirmation.code, + "user_id": confirmation.user_id, + "action": confirmation.action, + "data": confirmation.data, + "created_at": confirmation.created_at, + } @contextmanager @@ -268,8 +284,6 @@ async def check_and_consume_invitation( invitation_code: str, guest_email: str, product: Product, - db: AsyncpgStorage, - cfg: LoginOptions, app: web.Application, ) -> ConfirmedInvitationData: """Consumes invitation: the code is validated, the invitation retrieives and then deleted @@ -303,12 +317,20 @@ async def check_and_consume_invitation( ) # database-type invitations - if confirmation_token := await _confirmation_service.validate_confirmation_code( - invitation_code, db, cfg + confirmation_service = _get_confirmation_service(app) + if confirmation := await confirmation_service.validate_confirmation_code( + invitation_code ): try: + confirmation_token_dict = { + "code": confirmation.code, + "user_id": confirmation.user_id, + "action": confirmation.action, + "data": confirmation.data, + "created_at": confirmation.created_at, + } invitation_data: ConfirmedInvitationData = ( - _InvitationValidator.model_validate(confirmation_token).data + _InvitationValidator.model_validate(confirmation_token_dict).data ) return invitation_data @@ -316,13 +338,13 @@ async def check_and_consume_invitation( _logger.warning( "%s is associated with an invalid %s.\nDetails: %s", f"{invitation_code=}", - f"{confirmation_token=}", + f"{confirmation=}", f"{err=}", ) finally: - await db.delete_confirmation(confirmation_token) - _logger.info("Invitation with %s was consumed", f"{confirmation_token=}") + await confirmation_service.delete_confirmation(confirmation) + _logger.info("Invitation with %s was consumed", f"{confirmation=}") raise web.HTTPForbidden( text=( From 601fe0b1e0769baee0c71e05e509602e772eed00 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 23 Jul 2025 23:46:11 +0200 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=A8=20Refactor=20confirmation=20ser?= =?UTF-8?q?vice=20integration:=20centralize=20service=20retrieval=20in=20?= =?UTF-8?q?=5Frest=5Fdependencies.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_controller/rest/_rest_dependencies.py | 24 +++++++++++++++++++ .../login/_controller/rest/change.py | 16 +++---------- .../login/_controller/rest/confirmation.py | 19 ++++----------- .../login/_controller/rest/registration.py | 14 ++--------- .../login/_invitations_service.py | 18 ++++---------- 5 files changed, 38 insertions(+), 53 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_dependencies.py diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_dependencies.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_dependencies.py new file mode 100644 index 000000000000..94f17c5bfe74 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_dependencies.py @@ -0,0 +1,24 @@ +"""Common dependency injection helpers for REST controllers. + +This module provides factory functions for creating service instances +that are commonly used across multiple REST controllers in the login module. +""" + +from aiohttp import web + +from ....db.plugin import get_asyncpg_engine +from ..._confirmation_repository import ConfirmationRepository +from ..._confirmation_service import ConfirmationService +from ...settings import get_plugin_options + + +def get_confirmation_service(app: web.Application) -> ConfirmationService: + """Get confirmation service instance from app. + + Creates a ConfirmationService with proper repository and options injection. + Used across multiple REST controllers for confirmation operations. + """ + engine = get_asyncpg_engine(app) + repository = ConfirmationRepository(engine) + options = get_plugin_options(app) + return ConfirmationService(repository, options) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py index b093f654e02c..215c86a72a73 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py @@ -16,8 +16,6 @@ from ....utils_rate_limiting import global_rate_limit_route from ....web_utils import flash_response from ... import _auth_service, _confirmation_web -from ..._confirmation_repository import ConfirmationRepository -from ..._confirmation_service import ConfirmationService from ..._emails_service import get_template_path, send_email_from_template from ..._login_service import ( ACTIVE, @@ -34,20 +32,12 @@ ) from ...decorators import login_required from ...errors import WrongPasswordError -from ...settings import get_plugin_options +from ._rest_dependencies import get_confirmation_service from .change_schemas import ChangeEmailBody, ChangePasswordBody, ResetPasswordBody _logger = logging.getLogger(__name__) -def _get_confirmation_service(app: web.Application) -> ConfirmationService: - """Get confirmation service instance from app.""" - engine = app["postgres_db_engine"] - repository = ConfirmationRepository(engine) - options = get_plugin_options(app) - return ConfirmationService(repository, options) - - routes = RouteTableDef() @@ -184,7 +174,7 @@ def _get_error_context( try: # Confirmation token that includes code to `complete_reset_password`. # Recreated if non-existent or expired (Guideline #2) - confirmation_service = _get_confirmation_service(request.app) + confirmation_service = get_confirmation_service(request.app) confirmation = ( await confirmation_service.get_or_create_confirmation_without_data( user_id=user["id"], action="RESET_PASSWORD" @@ -227,7 +217,7 @@ def _get_error_context( async def initiate_change_email(request: web.Request): # NOTE: This code have been intentially disabled in https://github.com/ITISFoundation/osparc-simcore/pull/5472 product: Product = products_web.get_current_product(request) - confirmation_service = _get_confirmation_service(request.app) + confirmation_service = get_confirmation_service(request.app) request_body = await parse_request_body_as(ChangeEmailBody, request) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py index 833a09dfdb71..7f62f7b6862d 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py @@ -30,8 +30,6 @@ _security_service, _twofa_service, ) -from ..._confirmation_repository import ConfirmationRepository -from ..._confirmation_service import ConfirmationService from ..._login_repository_legacy import ( ConfirmationTokenDict, ) @@ -54,6 +52,7 @@ get_plugin_options, get_plugin_settings, ) +from ._rest_dependencies import get_confirmation_service from .confirmation_schemas import ( CodePathParam, PhoneConfirmationBody, @@ -64,14 +63,6 @@ _logger = logging.getLogger(__name__) -def _get_confirmation_service(app: web.Application) -> ConfirmationService: - """Get confirmation service instance from app.""" - engine = app["postgres_db_engine"] - repository = ConfirmationRepository(engine) - options = get_plugin_options(app) - return ConfirmationService(repository, options) - - def _confirmation_to_legacy_dict(confirmation: Confirmation) -> ConfirmationTokenDict: """Convert new Confirmation model to legacy ConfirmationTokenDict format.""" return { @@ -91,7 +82,7 @@ async def _handle_confirm_registration( product_name: ProductName, confirmation: Confirmation, ): - confirmation_service = _get_confirmation_service(app) + confirmation_service = get_confirmation_service(app) user_id = confirmation.user_id # activate user and consume confirmation token @@ -114,7 +105,7 @@ async def _handle_confirm_registration( async def _handle_confirm_change_email( app: web.Application, confirmation: Confirmation ): - confirmation_service = _get_confirmation_service(app) + confirmation_service = get_confirmation_service(app) user_id = confirmation.user_id # update and consume confirmation token @@ -146,7 +137,7 @@ async def validate_confirmation_and_redirect(request: web.Request): """ cfg: LoginOptions = get_plugin_options(request.app) product: Product = products_web.get_current_product(request) - confirmation_service = _get_confirmation_service(request.app) + confirmation_service = get_confirmation_service(request.app) path_params = parse_request_path_parameters_as(CodePathParam, request) @@ -267,7 +258,7 @@ async def complete_reset_password(request: web.Request): - Code is provided via email by calling first initiate_reset_password """ product: Product = products_web.get_current_product(request) - confirmation_service = _get_confirmation_service(request.app) + confirmation_service = get_confirmation_service(request.app) path_params = parse_request_path_parameters_as(CodePathParam, request) request_body = await parse_request_body_as(ResetPasswordConfirmation, request) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py index b904c30b9860..c0471af55b93 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py @@ -30,8 +30,6 @@ _security_service, _twofa_service, ) -from ..._confirmation_repository import ConfirmationRepository -from ..._confirmation_service import ConfirmationService from ..._emails_service import get_template_path, send_email_from_template from ..._invitations_service import ( ConfirmedInvitationData, @@ -54,9 +52,9 @@ ) from ...settings import ( LoginSettingsForProduct, - get_plugin_options, get_plugin_settings, ) +from ._rest_dependencies import get_confirmation_service from .registration_schemas import ( InvitationCheck, InvitationInfo, @@ -67,14 +65,6 @@ _logger = logging.getLogger(__name__) -def _get_confirmation_service(app: web.Application) -> ConfirmationService: - """Get confirmation service instance from app.""" - engine = app["postgres_db_engine"] - repository = ConfirmationRepository(engine) - options = get_plugin_options(app) - return ConfirmationService(repository, options) - - routes = RouteTableDef() @@ -225,7 +215,7 @@ async def register(request: web.Request): if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: # Confirmation required: send confirmation email - confirmation_service = _get_confirmation_service(request.app) + confirmation_service = get_confirmation_service(request.app) _confirmation: Confirmation = await confirmation_service.create_confirmation( user_id=user["id"], action="REGISTRATION", diff --git a/services/web/server/src/simcore_service_webserver/login/_invitations_service.py b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py index 52803f400635..90ec21249560 100644 --- a/services/web/server/src/simcore_service_webserver/login/_invitations_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py @@ -42,8 +42,7 @@ from ..products.models import Product from ..users import users_service from . import _auth_service -from ._confirmation_repository import ConfirmationRepository -from ._confirmation_service import ConfirmationService +from ._controller.rest._rest_dependencies import get_confirmation_service from ._login_repository_legacy import ( BaseConfirmationTokenDict, ConfirmationTokenDict, @@ -53,19 +52,10 @@ MSG_INVITATIONS_CONTACT_SUFFIX, MSG_USER_DISABLED, ) -from .settings import get_plugin_options _logger = logging.getLogger(__name__) -def _get_confirmation_service(app: web.Application) -> ConfirmationService: - """Get confirmation service instance from app.""" - engine = app["postgres_db_engine"] - repository = ConfirmationRepository(engine) - options = get_plugin_options(app) - return ConfirmationService(repository, options) - - class ConfirmationTokenInfoDict(ConfirmationTokenDict): expires: datetime url: str @@ -138,7 +128,7 @@ async def check_other_registrations( # w/ an expired confirmation will get deleted and its account (i.e. email) # can be overtaken by this new registration # - confirmation_service = _get_confirmation_service(app) + confirmation_service = get_confirmation_service(app) _confirmation = await confirmation_service.get_confirmation( filter_dict={ "user_id": user["id"], @@ -204,7 +194,7 @@ async def create_invitation_token( trial_account_days=trial_days, extra_credits_in_usd=extra_credits_in_usd, ) - confirmation_service = _get_confirmation_service(app) + confirmation_service = get_confirmation_service(app) confirmation = await confirmation_service.create_confirmation( user_id=user_id, action=ConfirmationAction.INVITATION.name, @@ -317,7 +307,7 @@ async def check_and_consume_invitation( ) # database-type invitations - confirmation_service = _get_confirmation_service(app) + confirmation_service = get_confirmation_service(app) if confirmation := await confirmation_service.validate_confirmation_code( invitation_code ): From c62bed5c5170f69d64ec9fd4df959b6cb8b4f81e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 24 Jul 2025 00:00:52 +0200 Subject: [PATCH 06/19] =?UTF-8?q?=E2=9C=A8=20Refactor=20login=20module:=20?= =?UTF-8?q?replace=20legacy=20repository=20with=20confirmation=20service?= =?UTF-8?q?=20and=20clean=20up=20related=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pytest_simcore/helpers/webserver_login.py | 20 +++++++++----- .../garbage_collector/plugin.py | 4 --- .../login/login_repository_legacy.py | 8 ------ .../simcore_service_webserver/login/plugin.py | 27 ------------------- .../server/tests/unit/with_dbs/01/test_db.py | 16 ----------- .../tests/unit/with_dbs/03/login/conftest.py | 13 --------- .../03/login/test_login_registration.py | 2 -- .../03/login/test_login_reset_password.py | 22 ++------------- 8 files changed, 15 insertions(+), 97 deletions(-) delete mode 100644 services/web/server/src/simcore_service_webserver/login/login_repository_legacy.py diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py index 6d843ceacdf3..d61fe972f813 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py @@ -5,10 +5,10 @@ from aiohttp.test_utils import TestClient from servicelib.aiohttp import status -from simcore_service_webserver.login._invitations_service import create_invitation_token -from simcore_service_webserver.login._login_repository_legacy import ( - get_plugin_storage, +from simcore_service_webserver.login._controller.rest._rest_dependencies import ( + get_confirmation_service, ) +from simcore_service_webserver.login._invitations_service import create_invitation_token from simcore_service_webserver.login.constants import MSG_LOGGED_IN from simcore_service_webserver.security import security_service from yarl import URL @@ -132,7 +132,7 @@ def __init__( self.confirmation = None self.trial_days = trial_days self.extra_credits_in_usd = extra_credits_in_usd - self.db = get_plugin_storage(self.app) + self.confirmation_service = get_confirmation_service(client.app) async def __aenter__(self) -> "NewInvitation": # creates host user @@ -140,7 +140,7 @@ async def __aenter__(self) -> "NewInvitation": self.user = await super().__aenter__() self.confirmation = await create_invitation_token( - self.db, + self.client.app, user_id=self.user["id"], user_email=self.user["email"], tag=self.tag, @@ -150,5 +150,11 @@ async def __aenter__(self) -> "NewInvitation": return self async def __aexit__(self, *args): - if await self.db.get_confirmation(self.confirmation): - await self.db.delete_confirmation(self.confirmation) + if self.confirmation: + # Try to get confirmation by filter and delete if it exists + confirmation = await self.confirmation_service.get_confirmation( + filter_dict={"code": self.confirmation["code"]} + ) + if confirmation: + await self.confirmation_service.delete_confirmation(confirmation) + return await super().__aexit__(*args) diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py b/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py index a43834d14af7..21140f6b63d9 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py @@ -4,8 +4,6 @@ from servicelib.logging_utils import set_parent_module_log_level from ..application_settings import get_application_settings -from ..application_setup import ModuleCategory, app_setup_func -from ..login.plugin import setup_login_storage from ..products.plugin import setup_products from ..projects._projects_repository_legacy import setup_projects_db from ..redis import setup_redis @@ -34,8 +32,6 @@ def setup_garbage_collector(app: web.Application) -> None: # - project needs access to socketio via notify_project_state_update setup_socketio(app) - # - project needs access to user-api that is connected to login plugin - setup_login_storage(app) settings = get_plugin_settings(app) diff --git a/services/web/server/src/simcore_service_webserver/login/login_repository_legacy.py b/services/web/server/src/simcore_service_webserver/login/login_repository_legacy.py deleted file mode 100644 index 778a99c2175f..000000000000 --- a/services/web/server/src/simcore_service_webserver/login/login_repository_legacy.py +++ /dev/null @@ -1,8 +0,0 @@ -from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage - -__all__: tuple[str, ...] = ( - "AsyncpgStorage", - "get_plugin_storage", -) - -# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/login/plugin.py b/services/web/server/src/simcore_service_webserver/login/plugin.py index 46845dce7d8b..614949220bd0 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -1,12 +1,9 @@ -import asyncio import logging from typing import Final -import asyncpg from aiohttp import web from pydantic import ValidationError from settings_library.email import SMTPSettings -from settings_library.postgres import PostgresSettings from .._meta import APP_NAME from ..application_setup import ( @@ -20,7 +17,6 @@ INDEX_RESOURCE_NAME, ) from ..db.plugin import setup_db -from ..db.settings import get_plugin_settings as get_db_plugin_settings from ..email.plugin import setup_email from ..email.settings import get_plugin_settings as get_email_plugin_settings from ..invitations.plugin import setup_invitations @@ -38,7 +34,6 @@ registration, twofa, ) -from ._login_repository_legacy import APP_LOGIN_STORAGE_KEY, AsyncpgStorage from .constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY from .settings import ( APP_LOGIN_OPTIONS_KEY, @@ -54,27 +49,6 @@ MAX_TIME_TO_CLOSE_POOL_SECS = 5 -async def _setup_login_storage_ctx(app: web.Application): - assert APP_LOGIN_STORAGE_KEY not in app # nosec - settings: PostgresSettings = get_db_plugin_settings(app) - - async with asyncpg.create_pool( - dsn=settings.dsn_with_query(f"{APP_NAME}-login", suffix="asyncpg"), - min_size=settings.POSTGRES_MINSIZE, - max_size=settings.POSTGRES_MAXSIZE, - loop=asyncio.get_event_loop(), - ) as pool: - app[APP_LOGIN_STORAGE_KEY] = AsyncpgStorage(pool) - - yield # ---------------- - - -@ensure_single_setup(f"{__name__}.storage", logger=log) -def setup_login_storage(app: web.Application): - if _setup_login_storage_ctx not in app.cleanup_ctx: - app.cleanup_ctx.append(_setup_login_storage_ctx) - - @ensure_single_setup(f"{__name__}.login_options", logger=log) def _setup_login_options(app: web.Application): settings: SMTPSettings = get_email_plugin_settings(app) @@ -158,7 +132,6 @@ def setup_login(app: web.Application): app.router.add_routes(twofa.routes) _setup_login_options(app) - setup_login_storage(app) app.on_startup.append(_resolve_login_settings_per_product) diff --git a/services/web/server/tests/unit/with_dbs/01/test_db.py b/services/web/server/tests/unit/with_dbs/01/test_db.py index 715f9426d2b7..d13f13054535 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_db.py +++ b/services/web/server/tests/unit/with_dbs/01/test_db.py @@ -6,7 +6,6 @@ from pathlib import Path import aiopg.sa -import asyncpg import sqlalchemy as sa import yaml from aiohttp.test_utils import TestServer @@ -19,10 +18,6 @@ is_service_enabled, is_service_responsive, ) -from simcore_service_webserver.login._login_repository_legacy import ( - AsyncpgStorage, - get_plugin_storage, -) from sqlalchemy.ext.asyncio import AsyncEngine @@ -44,19 +39,11 @@ async def test_all_pg_engines_in_app(web_server: TestServer): assert asyncpg_engine assert isinstance(asyncpg_engine, AsyncEngine) - # (3) low-level asyncpg Pool (deprecated) - # Will be replaced by (2) - login_storage: AsyncpgStorage = get_plugin_storage(app) - assert login_storage.pool - assert isinstance(login_storage.pool, asyncpg.Pool) - # they ALL point to the SAME database assert aiopg_engine.dsn assert asyncpg_engine.url query = sa.text('SELECT "version_num" FROM "alembic_version"') - async with login_storage.pool.acquire() as conn: - result_pool = await conn.fetchval(str(query)) async with asyncpg_engine.connect() as conn: result_asyncpg = (await conn.execute(query)).scalar_one_or_none() @@ -64,9 +51,6 @@ async def test_all_pg_engines_in_app(web_server: TestServer): async with aiopg_engine.acquire() as conn: result_aiopg = await (await conn.execute(query)).scalar() - assert result_pool == result_asyncpg - assert result_pool == result_aiopg - def test_uses_same_postgres_version( docker_compose_file: Path, osparc_simcore_root_dir: Path diff --git a/services/web/server/tests/unit/with_dbs/03/login/conftest.py b/services/web/server/tests/unit/with_dbs/03/login/conftest.py index 92631213d9d3..afe86c41dba8 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/login/conftest.py @@ -21,10 +21,6 @@ ConfirmationRepository, ) from simcore_service_webserver.login._confirmation_service import ConfirmationService -from simcore_service_webserver.login._login_repository_legacy import ( - AsyncpgStorage, - get_plugin_storage, -) from simcore_service_webserver.login.settings import LoginOptions, get_plugin_options @@ -87,15 +83,6 @@ def fake_weak_password(faker: Faker) -> str: ) -@pytest.fixture -def db_storage_deprecated(client: TestClient) -> AsyncpgStorage: - """login database repository instance""" - assert client.app - db: AsyncpgStorage = get_plugin_storage(client.app) - assert db - return db - - @pytest.fixture def confirmation_repository(client: TestClient) -> ConfirmationRepository: """Modern confirmation repository instance""" diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py index e469c1ce7579..41d86e5d985f 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -24,7 +24,6 @@ ConfirmationRepository, ) from simcore_service_webserver.login._confirmation_web import _url_for_confirmation -from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage from simcore_service_webserver.login.constants import ( MSG_EMAIL_ALREADY_REGISTERED, MSG_LOGGED_IN, @@ -243,7 +242,6 @@ async def test_registration_with_weak_password_fails( async def test_registration_with_invalid_confirmation_code( client: TestClient, login_options: LoginOptions, - db_storage_deprecated: AsyncpgStorage, mocker: MockerFixture, cleanup_db_tables: None, ): diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py index 000694d6fffb..578324fb944a 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py @@ -4,8 +4,7 @@ # pylint: disable=too-many-arguments -import contextlib -from collections.abc import AsyncIterator, Callable +from collections.abc import Callable import pytest from aiohttp.test_utils import TestClient, TestServer @@ -16,12 +15,8 @@ from servicelib.aiohttp import status from servicelib.rest_constants import X_PRODUCT_NAME_HEADER from servicelib.utils_secrets import generate_password -from simcore_service_webserver.db.models import ConfirmationAction, UserStatus +from simcore_service_webserver.db.models import UserStatus from simcore_service_webserver.groups import api as groups_service -from simcore_service_webserver.login._login_repository_legacy import ( - AsyncpgStorage, - ConfirmationTokenDict, -) from simcore_service_webserver.login.constants import ( MSG_ACTIVATION_REQUIRED, MSG_EMAIL_SENT, @@ -295,16 +290,3 @@ async def test_unregistered_product( message.startswith("Password reset initiated") for message in logged_warnings ), f"Missing warning in {logged_warnings}" - - -@contextlib.asynccontextmanager -async def confirmation_ctx( - db: AsyncpgStorage, user -) -> AsyncIterator[ConfirmationTokenDict]: - confirmation = await db.create_confirmation( - user["id"], ConfirmationAction.RESET_PASSWORD.name - ) - - yield confirmation - - await db.delete_confirmation(confirmation) From 35759896b8e09fca4a13bbc228f830a9d11f229e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 24 Jul 2025 00:02:55 +0200 Subject: [PATCH 07/19] rm legacy --- .../login/_login_repository_legacy.py | 112 +-------------- .../login/_login_repository_legacy_sql.py | 135 ------------------ 2 files changed, 1 insertion(+), 246 deletions(-) delete mode 100644 services/web/server/src/simcore_service_webserver/login/_login_repository_legacy_sql.py diff --git a/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py index e7f904e1544d..243dead380cf 100644 --- a/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py +++ b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py @@ -1,17 +1,9 @@ from datetime import datetime from logging import getLogger -from typing import Any, Literal, TypedDict, cast - -import asyncpg -from aiohttp import web -from servicelib.utils_secrets import generate_passcode - -from . import _login_repository_legacy_sql +from typing import Literal, TypedDict _logger = getLogger(__name__) -APP_LOGIN_STORAGE_KEY = f"{__name__}.APP_LOGIN_STORAGE_KEY" - ## MODELS @@ -31,105 +23,3 @@ class ConfirmationTokenDict(BaseConfirmationTokenDict): created_at: datetime # SEE handlers_confirmation.py::email_confirmation to determine what type is associated to each action data: str | None - - -## REPOSITORY - - -class AsyncpgStorage: - def __init__( - self, - pool: asyncpg.Pool, - *, - user_table_name: str = "users", - confirmation_table_name: str = "confirmations", - ): - self.pool = pool - self.user_tbl = user_table_name - self.confirm_tbl = confirmation_table_name - - # - # CRUD confirmation - # - async def create_confirmation( - self, user_id: int, action: ActionLiteralStr, data: str | None = None - ) -> ConfirmationTokenDict: - async with self.pool.acquire() as conn: - # generate different code - while True: - # NOTE: use only numbers (i.e. avoid generate_password) since front-end does not handle well url encoding - numeric_code: str = generate_passcode(20) - if not await _login_repository_legacy_sql.find_one( - conn, self.confirm_tbl, {"code": numeric_code} - ): - break - # insert confirmation - # NOTE: returns timestamp generated at the server-side - confirmation = ConfirmationTokenDict( - code=numeric_code, - action=action, - user_id=user_id, - data=data, - created_at=datetime.utcnow(), - ) - c = await _login_repository_legacy_sql.insert( - conn, self.confirm_tbl, dict(confirmation), returning="code" - ) - assert numeric_code == c # nosec - return confirmation - - async def get_confirmation( - self, filter_dict: dict[str, Any] - ) -> ConfirmationTokenDict | None: - if "user" in filter_dict: - filter_dict["user_id"] = filter_dict.pop("user")["id"] - async with self.pool.acquire() as conn: - confirmation = await _login_repository_legacy_sql.find_one( - conn, self.confirm_tbl, filter_dict - ) - confirmation_token: ConfirmationTokenDict | None = ( - ConfirmationTokenDict(**confirmation) if confirmation else None # type: ignore[typeddict-item] - ) - return confirmation_token - - async def delete_confirmation(self, confirmation: ConfirmationTokenDict): - async with self.pool.acquire() as conn: - await _login_repository_legacy_sql.delete( - conn, self.confirm_tbl, {"code": confirmation["code"]} - ) - - # - # Transactions that guarantee atomicity. This avoids - # inconsistent states of confirmation and users rows - # - - async def delete_confirmation_and_user( - self, user_id: int, confirmation: ConfirmationTokenDict - ): - async with self.pool.acquire() as conn, conn.transaction(): - await _login_repository_legacy_sql.delete( - conn, self.confirm_tbl, {"code": confirmation["code"]} - ) - await _login_repository_legacy_sql.delete( - conn, self.user_tbl, {"id": user_id} - ) - - async def delete_confirmation_and_update_user( - self, user_id: int, updates: dict[str, Any], confirmation: ConfirmationTokenDict - ): - async with self.pool.acquire() as conn, conn.transaction(): - await _login_repository_legacy_sql.delete( - conn, self.confirm_tbl, {"code": confirmation["code"]} - ) - await _login_repository_legacy_sql.update( - conn, self.user_tbl, {"id": user_id}, updates - ) - - # NOTE: This class is deprecated. Use ConfirmationRepository instead. - # Keeping for backwards compatibility during migration. - - -def get_plugin_storage(app: web.Application) -> AsyncpgStorage: - storage = cast(AsyncpgStorage, app.get(APP_LOGIN_STORAGE_KEY)) - assert storage, "login plugin was not initialized" # nosec - return storage diff --git a/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy_sql.py b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy_sql.py deleted file mode 100644 index 48aa8fc6a906..000000000000 --- a/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy_sql.py +++ /dev/null @@ -1,135 +0,0 @@ -from collections.abc import Coroutine, Iterable -from typing import Any - -import asyncpg - - -def find_one( - conn: asyncpg.pool.PoolConnectionProxy, - table: str, - filter_: dict[str, Any], - fields=None, -) -> Coroutine[Any, Any, asyncpg.Record | None]: - sql, values = find_one_sql(table, filter_, fields) - return conn.fetchrow(sql, *values) - - -def find_one_sql( - table: str, filter_: dict[str, Any], fields: Iterable[str] | None = None -) -> tuple[str, list[Any]]: - """ - >>> find_one_sql('tbl', {'foo': 10, 'bar': 'baz'}) - ('SELECT * FROM tbl WHERE bar=$1 AND foo=$2', ['baz', 10]) - >>> find_one_sql('tbl', {'id': 10}, fields=['foo', 'bar']) - ('SELECT foo, bar FROM tbl WHERE id=$1', [10]) - """ - keys, values = _split_dict(filter_) - fields = ", ".join(fields) if fields else "*" - where = _pairs(keys) - sql = f"SELECT {fields} FROM {table} WHERE {where}" # noqa: S608 # @pcrespov SQL injection issue here - return sql, values - - -def insert( - conn: asyncpg.pool.PoolConnectionProxy, - table: str, - data: dict[str, Any], - returning: str = "id", -) -> Coroutine[Any, Any, Any]: - sql, values = insert_sql(table, data, returning) - return conn.fetchval(sql, *values) - - -def insert_sql( - table: str, data: dict[str, Any], returning: str = "id" -) -> tuple[str, list[Any]]: - """ - >>> insert_sql('tbl', {'foo': 'bar', 'id': 1}) - ('INSERT INTO tbl (foo, id) VALUES ($1, $2) RETURNING id', ['bar', 1]) - - >>> insert_sql('tbl', {'foo': 'bar', 'id': 1}, returning=None) - ('INSERT INTO tbl (foo, id) VALUES ($1, $2)', ['bar', 1]) - - >>> insert_sql('tbl', {'foo': 'bar', 'id': 1}, returning='pk') - ('INSERT INTO tbl (foo, id) VALUES ($1, $2) RETURNING pk', ['bar', 1]) - """ - keys, values = _split_dict(data) - sql = "INSERT INTO {} ({}) VALUES ({}){}".format( # noqa: S608 # @pcrespov SQL injection issue here - table, - ", ".join(keys), - ", ".join(_placeholders(data)), - f" RETURNING {returning}" if returning else "", - ) - return sql, values - - -def update( - conn: asyncpg.pool.PoolConnectionProxy, - table: str, - filter_: dict[str, Any], - updates: dict[str, Any], -) -> Coroutine[Any, Any, str]: - sql, values = update_sql(table, filter_, updates) - return conn.execute(sql, *values) - - -def update_sql( - table: str, filter_: dict[str, Any], updates: dict[str, Any] -) -> tuple[str, list[Any]]: - """ - >>> update_sql('tbl', {'foo': 'a', 'bar': 1}, {'bar': 2, 'baz': 'b'}) - ('UPDATE tbl SET bar=$1, baz=$2 WHERE bar=$3 AND foo=$4', [2, 'b', 1, 'a']) - """ - where_keys, where_vals = _split_dict(filter_) - up_keys, up_vals = _split_dict(updates) - changes = _pairs(up_keys, sep=", ") - where = _pairs(where_keys, start=len(up_keys) + 1) - sql = f"UPDATE {table} SET {changes} WHERE {where}" # noqa: S608 # @pcrespov SQL injection issue here - return sql, up_vals + where_vals - - -def delete( - conn: asyncpg.pool.PoolConnectionProxy, table: str, filter_: dict[str, Any] -) -> Coroutine[Any, Any, str]: - sql, values = delete_sql(table, filter_) - return conn.execute(sql, *values) - - -def delete_sql(table: str, filter_: dict[str, Any]) -> tuple[str, list[Any]]: - """ - >>> delete_sql('tbl', {'foo': 10, 'bar': 'baz'}) - ('DELETE FROM tbl WHERE bar=$1 AND foo=$2', ['baz', 10]) - """ - keys, values = _split_dict(filter_) - where = _pairs(keys) - sql = f"DELETE FROM {table} WHERE {where}" # noqa: S608 # @pcrespov SQL injection issue here - return sql, values - - -def _pairs(keys: Iterable[str], *, start: int = 1, sep: str = " AND ") -> str: - """ - >>> _pairs(['foo', 'bar', 'baz'], sep=', ') - 'foo=$1, bar=$2, baz=$3' - >>> _pairs(['foo', 'bar', 'baz'], start=2) - 'foo=$2 AND bar=$3 AND baz=$4' - """ - return sep.join(f"{k}=${i}" for i, k in enumerate(keys, start)) - - -def _placeholders(variables: Iterable[Any]) -> list[Any]: - """Returns placeholders by number of variables - - >>> _placeholders(['foo', 'bar', 1]) - ['$1', '$2', '$3'] - """ - return [f"${i}" for i, _ in enumerate(variables, 1)] - - -def _split_dict(dic: dict[str, Any]) -> tuple[list[str], list[Any]]: - """Split dict into sorted keys and values - - >>> _split_dict({'b': 2, 'a': 1}) - (['a', 'b'], [1, 2]) - """ - keys = sorted(dic.keys()) - return keys, [dic[k] for k in keys] From d4bdb5807c3a5e4970ccae8af48c2a85bd88edb6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 24 Jul 2025 00:05:06 +0200 Subject: [PATCH 08/19] rm legacy and move models --- .../login/_controller/rest/confirmation.py | 8 +++--- .../_controller/rest/confirmation_schemas.py | 5 ++-- .../login/_invitations_service.py | 2 +- .../login/_login_repository_legacy.py | 25 ------------------- .../login/_models.py | 15 ++++++++++- 5 files changed, 22 insertions(+), 33 deletions(-) delete mode 100644 services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py index 7f62f7b6862d..67d5d0c7b250 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py @@ -30,9 +30,6 @@ _security_service, _twofa_service, ) -from ..._login_repository_legacy import ( - ConfirmationTokenDict, -) from ..._login_service import ( ACTIVE, CHANGE_EMAIL, @@ -40,7 +37,10 @@ RESET_PASSWORD, notify_user_confirmation, ) -from ..._models import Confirmation +from ..._models import ( + Confirmation, + ConfirmationTokenDict, +) from ...constants import ( MSG_PASSWORD_CHANGE_NOT_ALLOWED, MSG_PASSWORD_CHANGED, diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation_schemas.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation_schemas.py index f1d732d4c322..91cf4c8533f7 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation_schemas.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation_schemas.py @@ -12,10 +12,11 @@ ) from ..._invitations_service import ConfirmedInvitationData -from ..._login_repository_legacy import ( +from ..._models import ( ConfirmationTokenDict, + InputSchema, + check_confirm_password_match, ) -from ..._models import InputSchema, check_confirm_password_match class CodePathParam(BaseModel): diff --git a/services/web/server/src/simcore_service_webserver/login/_invitations_service.py b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py index 90ec21249560..7f3b9c86eacd 100644 --- a/services/web/server/src/simcore_service_webserver/login/_invitations_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py @@ -43,7 +43,7 @@ from ..users import users_service from . import _auth_service from ._controller.rest._rest_dependencies import get_confirmation_service -from ._login_repository_legacy import ( +from ._models import ( BaseConfirmationTokenDict, ConfirmationTokenDict, ) diff --git a/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py deleted file mode 100644 index 243dead380cf..000000000000 --- a/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py +++ /dev/null @@ -1,25 +0,0 @@ -from datetime import datetime -from logging import getLogger -from typing import Literal, TypedDict - -_logger = getLogger(__name__) - - -## MODELS - -ActionLiteralStr = Literal[ - "REGISTRATION", "INVITATION", "RESET_PASSWORD", "CHANGE_EMAIL" -] - - -class BaseConfirmationTokenDict(TypedDict): - code: str - action: ActionLiteralStr - - -class ConfirmationTokenDict(BaseConfirmationTokenDict): - # SEE packages/postgres-database/src/simcore_postgres_database/models/confirmations.py - user_id: int - created_at: datetime - # SEE handlers_confirmation.py::email_confirmation to determine what type is associated to each action - data: str | None diff --git a/services/web/server/src/simcore_service_webserver/login/_models.py b/services/web/server/src/simcore_service_webserver/login/_models.py index e411fa133205..0e18290c2e3a 100644 --- a/services/web/server/src/simcore_service_webserver/login/_models.py +++ b/services/web/server/src/simcore_service_webserver/login/_models.py @@ -1,6 +1,6 @@ from collections.abc import Callable from datetime import datetime -from typing import Literal +from typing import Literal, TypedDict from models_library.users import UserID from pydantic import BaseModel, ConfigDict, SecretStr, ValidationInfo @@ -12,6 +12,19 @@ ] +class BaseConfirmationTokenDict(TypedDict): + code: str + action: ActionLiteralStr + + +class ConfirmationTokenDict(BaseConfirmationTokenDict): + # SEE packages/postgres-database/src/simcore_postgres_database/models/confirmations.py + user_id: int + created_at: datetime + # SEE handlers_confirmation.py::email_confirmation to determine what type is associated to each action + data: str | None + + class Confirmation(BaseModel): code: str user_id: UserID From b19eb75e96515dd18c84593c3c0d2528818b8298 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 28 Jul 2025 18:48:27 +0200 Subject: [PATCH 09/19] imports --- .../web/server/tests/unit/with_dbs/03/login/conftest.py | 4 ---- .../with_dbs/03/login/test_login_confirmation_service.py | 9 +++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/login/conftest.py b/services/web/server/tests/unit/with_dbs/03/login/conftest.py index afe86c41dba8..f5157d56508e 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/login/conftest.py @@ -97,10 +97,6 @@ def confirmation_service( confirmation_repository: ConfirmationRepository, login_options: LoginOptions ) -> ConfirmationService: """Confirmation service instance""" - from simcore_service_webserver.login._confirmation_service import ( - ConfirmationService, - ) - return ConfirmationService(confirmation_repository, login_options) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py index d1d09bce7f5b..009784fa662b 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py @@ -1,7 +1,7 @@ from datetime import timedelta +from aiohttp import web from aiohttp.test_utils import make_mocked_request -from aiohttp.web import Application, Response from pytest_simcore.helpers.webserver_users import UserInfoDict from simcore_service_webserver.login import _confirmation_web from simcore_service_webserver.login._confirmation_service import ConfirmationService @@ -35,10 +35,11 @@ async def test_confirmation_token_workflow( assert validated_confirmation.action == action # Step 4: Create confirmation link - app = Application() + app = web.Application() - async def mock_handler(request): - return Response() + async def mock_handler(request: web.Request): + assert request.match_info["code"] == confirmation.code + return web.Response() app.router.add_get( "/auth/confirmation/{code}", mock_handler, name="auth_confirmation" From 8d69500a83b3ac3eadf8970afba7a377d02f4f43 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 28 Jul 2025 19:24:29 +0200 Subject: [PATCH 10/19] new column --- .../models/confirmations.py | 19 ++++++++----------- .../login/_confirmation_repository.py | 12 +++++++++--- .../tests/unit/with_dbs/03/login/conftest.py | 3 ++- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/confirmations.py b/packages/postgres-database/src/simcore_postgres_database/models/confirmations.py index 6fd56e8c8e01..2b05174e47de 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/confirmations.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/confirmations.py @@ -1,15 +1,16 @@ -""" User's confirmations table +"""User's confirmations table - - Keeps a list of tokens to identify an action (registration, invitation, reset, etc) authorized - by link to a a user in the framework - - These tokens have an expiration date defined by configuration +- Keeps a list of tokens to identify an action (registration, invitation, reset, etc) authorized +by link to a a user in the framework +- These tokens have an expiration date defined by configuration """ + import enum import sqlalchemy as sa -from ._common import RefActions +from ._common import RefActions, column_created_datetime from .base import metadata from .users import users @@ -47,12 +48,8 @@ class ConfirmationAction(enum.Enum): sa.Text, doc="Extra data associated to the action. SEE handlers_confirmation.py::email_confirmation", ), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - # NOTE: that here it would be convenient to have a server_default=now()! - doc="Creation date of this code." + column_created_datetime( + doc="Creation date of this code. " "Can be used as reference to determine the expiration date. SEE ${ACTION}_CONFIRMATION_LIFETIME", ), # constraints ---------------- diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py index 7da8bd08c0b5..99814be561dd 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py @@ -1,5 +1,4 @@ import logging -from datetime import datetime from typing import Any import sqlalchemy as sa @@ -21,7 +20,15 @@ def _to_domain(confirmation_row: Row) -> Confirmation: - return Confirmation.model_validate(confirmation_row) + return Confirmation.model_validate( + { + "code": confirmation_row.code, + "user_id": confirmation_row.user_id, + "action": confirmation_row.action.value, # conversion to literal string + "data": confirmation_row.data, + "created_at": confirmation_row.created, # renames + } + ) class ConfirmationRepository(BaseRepository): @@ -57,7 +64,6 @@ async def create_confirmation( user_id=user_id, action=action, data=data, - created_at=datetime.utcnow(), ) .returning(*confirmations.c) ) diff --git a/services/web/server/tests/unit/with_dbs/03/login/conftest.py b/services/web/server/tests/unit/with_dbs/03/login/conftest.py index f5157d56508e..9da9a42c9887 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/login/conftest.py @@ -17,6 +17,7 @@ from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict from simcore_postgres_database.models.users import users from simcore_postgres_database.models.wallets import wallets +from simcore_service_webserver.db.plugin import get_asyncpg_engine from simcore_service_webserver.login._confirmation_repository import ( ConfirmationRepository, ) @@ -88,7 +89,7 @@ def confirmation_repository(client: TestClient) -> ConfirmationRepository: """Modern confirmation repository instance""" assert client.app # Get the async engine from the application - engine = client.app["postgres_db_engine"] + engine = get_asyncpg_engine(client.app) return ConfirmationRepository(engine) From adff46c1636dbdf6979183bf7945f27cb040aa93 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 28 Jul 2025 19:28:34 +0200 Subject: [PATCH 11/19] migration --- ...14a4_update_confirmation_created_column.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py new file mode 100644 index 000000000000..31c69098f9c6 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py @@ -0,0 +1,74 @@ +"""update confirmation created column + +Revision ID: 9dddb16914a4 +Revises: 5679165336c8 +Create Date: 2025-07-28 17:25:06.534720+00:00 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "9dddb16914a4" +down_revision = "5679165336c8" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Step 1: Add new column as nullable first + op.add_column( + "confirmations", + sa.Column( + "created", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + + # Step 2: Copy data from created_at to created, assuming UTC timezone for existing data + op.execute( + "UPDATE confirmations SET created = created_at AT TIME ZONE 'UTC' WHERE created_at IS NOT NULL" + ) + + # Step 3: Make the column non-nullable with default + op.alter_column( + "confirmations", + "created", + nullable=False, + server_default=sa.text("now()"), + ) + + # Step 4: Drop old column + op.drop_column("confirmations", "created_at") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Step 1: Add back the old column + op.add_column( + "confirmations", + sa.Column( + "created_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + ) + + # Step 2: Copy data back, converting timezone-aware to naive timestamp + op.execute( + "UPDATE confirmations SET created_at = created AT TIME ZONE 'UTC' WHERE created IS NOT NULL" + ) + + # Step 3: Make the column non-nullable + op.alter_column( + "confirmations", + "created_at", + nullable=False, + ) + + # Step 4: Drop new column + op.drop_column("confirmations", "created") + # ### end Alembic commands ### From e7153247cd61a3f100d03dc7c136fff5015128e3 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 11 Sep 2025 23:00:14 +0200 Subject: [PATCH 12/19] repositions migration --- .../9dddb16914a4_update_confirmation_created_column.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py index 31c69098f9c6..74c2ab23a519 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py @@ -1,7 +1,7 @@ """update confirmation created column Revision ID: 9dddb16914a4 -Revises: 5679165336c8 +Revises: 06eafd25d004 Create Date: 2025-07-28 17:25:06.534720+00:00 """ @@ -12,13 +12,12 @@ # revision identifiers, used by Alembic. revision = "9dddb16914a4" -down_revision = "5679165336c8" +down_revision = "06eafd25d004" branch_labels = None depends_on = None def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### # Step 1: Add new column as nullable first op.add_column( "confirmations", @@ -44,11 +43,9 @@ def upgrade(): # Step 4: Drop old column op.drop_column("confirmations", "created_at") - # ### end Alembic commands ### def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### # Step 1: Add back the old column op.add_column( "confirmations", @@ -71,4 +68,3 @@ def downgrade(): # Step 4: Drop new column op.drop_column("confirmations", "created") - # ### end Alembic commands ### From 0f14b2b59a482032325bc53f01d966f95036dcbb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:57:11 +0200 Subject: [PATCH 13/19] fix: update down_revision in migration and import app_setup_func in garbage collector plugin --- .../versions/9dddb16914a4_update_confirmation_created_column.py | 2 +- .../src/simcore_service_webserver/garbage_collector/plugin.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py index 74c2ab23a519..871a0238506a 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/9dddb16914a4_update_confirmation_created_column.py @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = "9dddb16914a4" -down_revision = "06eafd25d004" +down_revision = "7e92447558e0" branch_labels = None depends_on = None diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py b/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py index 21140f6b63d9..efbe105a95b6 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py @@ -4,6 +4,7 @@ from servicelib.logging_utils import set_parent_module_log_level from ..application_settings import get_application_settings +from ..application_setup import ModuleCategory, app_setup_func from ..products.plugin import setup_products from ..projects._projects_repository_legacy import setup_projects_db from ..redis import setup_redis From 838783eb2beed7cf0dad971a21d5be446947dd7c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:00:12 +0200 Subject: [PATCH 14/19] write operations --- .../login/_confirmation_repository.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py index 99814be561dd..8e194bc5a545 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py @@ -42,7 +42,7 @@ async def create_confirmation( data: str | None = None, ) -> Confirmation: """Create a new confirmation token for a user action.""" - async with pass_or_acquire_connection(self.engine, connection) as conn: + async with transaction_context(self.engine, connection) as conn: # Generate unique code while True: # NOTE: use only numbers since front-end does not handle well url encoding @@ -108,7 +108,7 @@ async def delete_confirmation( confirmations.c.code == confirmation.code ) - async with pass_or_acquire_connection(self.engine, connection) as conn: + async with transaction_context(self.engine, connection) as conn: await conn.execute(query) async def delete_confirmation_and_user( From 4ba3d205ce664acf67a000421390c7a662720fcb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:54:19 +0200 Subject: [PATCH 15/19] fixes test --- .../login/_confirmation_repository.py | 6 ++++-- .../simcore_service_webserver/products/_models.py | 15 +++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py index 8e194bc5a545..5184b85c4545 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_repository.py @@ -42,9 +42,11 @@ async def create_confirmation( data: str | None = None, ) -> Confirmation: """Create a new confirmation token for a user action.""" + async with transaction_context(self.engine, connection) as conn: - # Generate unique code - while True: + # We want the same connection checking uniqueness and inserting + while True: # Generate unique code + # NOTE: use only numbers since front-end does not handle well url encoding numeric_code: str = generate_passcode(20) diff --git a/services/web/server/src/simcore_service_webserver/products/_models.py b/services/web/server/src/simcore_service_webserver/products/_models.py index e28c20eb2b68..127533d16c7a 100644 --- a/services/web/server/src/simcore_service_webserver/products/_models.py +++ b/services/web/server/src/simcore_service_webserver/products/_models.py @@ -70,20 +70,21 @@ class Product(BaseModel): Field(pattern=PUBLIC_VARIABLE_NAME_RE, validate_default=True), ] - display_name: Annotated[str, Field(..., description="Long display name")] + display_name: Annotated[str, Field(description="Long display name")] short_name: Annotated[ str | None, Field( - None, pattern=re.compile(TWILIO_ALPHANUMERIC_SENDER_ID_RE), min_length=2, max_length=11, description="Short display name for SMS", ), - ] + ] = None host_regex: Annotated[ - re.Pattern, BeforeValidator(str.strip), Field(..., description="Host regex") + re.Pattern, + BeforeValidator(lambda s: s.strip() if isinstance(s, str) else s), + Field(description="Host regex"), ] support_email: Annotated[ @@ -115,7 +116,7 @@ class Product(BaseModel): manuals: list[Manual] | None = None - support: list[Forum | EmailFeedback | WebFeedback] | None = Field(None) + support: list[Forum | EmailFeedback | WebFeedback] | None = None login_settings: Annotated[ ProductLoginSettingsDict, @@ -153,9 +154,7 @@ class Product(BaseModel): is_payment_enabled: Annotated[ bool, - Field( - description="True if this product offers credits", - ), + Field(description="True if this product offers credits"), ] = False credits_per_usd: Annotated[ From fe4fc577782d142c917560b442bd5af257275733 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:01:31 +0200 Subject: [PATCH 16/19] fixe pylint --- .../src/simcore_service_webserver/login/plugin.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/plugin.py b/services/web/server/src/simcore_service_webserver/login/plugin.py index 614949220bd0..c0f619c8f120 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -1,11 +1,9 @@ import logging -from typing import Final from aiohttp import web from pydantic import ValidationError from settings_library.email import SMTPSettings -from .._meta import APP_NAME from ..application_setup import ( ModuleCategory, app_setup_func, @@ -42,14 +40,10 @@ LoginSettingsForProduct, ) -log = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) -APP_LOGIN_CLIENT_KEY: Final = web.AppKey("APP_LOGIN_CLIENT_KEY", object) -MAX_TIME_TO_CLOSE_POOL_SECS = 5 - - -@ensure_single_setup(f"{__name__}.login_options", logger=log) +@ensure_single_setup(f"{__name__}.login_options", logger=_logger) def _setup_login_options(app: web.Application): settings: SMTPSettings = get_email_plugin_settings(app) @@ -108,7 +102,7 @@ async def _resolve_login_settings_per_product(app: web.Application): "simcore_service_webserver.login", ModuleCategory.ADDON, settings_name="WEBSERVER_LOGIN", - logger=log, + logger=_logger, ) def setup_login(app: web.Application): """Setting up login subsystem in application""" From 85d7da75a59141bf2194e52fdf95b07f37827182 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:16:47 +0200 Subject: [PATCH 17/19] fix: enhance error logging in change email process and correct variable assignment in API base URL --- .../login/_controller/rest/change.py | 15 ++++++++++++++- .../simcore_service_webserver/utils_aiohttp.py | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py index 215c86a72a73..15cc99a90da3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py @@ -9,6 +9,7 @@ from ...._meta import API_VTAG from ....db.plugin import get_asyncpg_engine +from ....exception_handling import create_error_context_from_request from ....products import products_web from ....products.models import Product from ....users import users_service @@ -258,7 +259,19 @@ async def initiate_change_email(request: web.Request): }, ) except Exception as err: # pylint: disable=broad-except - _logger.exception("Can not send change_email_email") + _logger.exception( + **create_troubleshooting_log_kwargs( + "Can not send change_email_email", + error=err, + error_context={ + "user_id": user["id"], + "user_email": user["email"], + "new_email": request_body.email, + "product_name": product.name, + **create_error_context_from_request(request), + }, + ) + ) await confirmation_service.delete_confirmation(confirmation) raise web.HTTPServiceUnavailable(text=MSG_CANT_SEND_MAIL) from err diff --git a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py index e0b753e8aee9..9b12bf06e5f8 100644 --- a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py +++ b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py @@ -171,5 +171,5 @@ def iter_origins(request: web.Request) -> Iterator[tuple[str, str]]: def get_api_base_url(request: web.Request) -> str: scheme, host = next(iter_origins(request)) - api_host = api_host = host if is_ip_address(host) else f"api.{host}" + api_host = host if is_ip_address(host) else f"api.{host}" return f"{scheme}://{api_host}" From 661099c4268e81a72076dc107fc21ed5f7de7b95 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:48:32 +0200 Subject: [PATCH 18/19] minor fix --- .../tests/unit/with_dbs/03/login/test_login_registration.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py index 41d86e5d985f..0b34ae169788 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -3,7 +3,7 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable -from datetime import timedelta +from datetime import UTC, datetime, timedelta from http import HTTPStatus import pytest @@ -521,8 +521,6 @@ async def test_registraton_with_invitation_for_trial_account( data, _ = await assert_status(response, status.HTTP_200_OK) profile = MyProfileRestGet.model_validate(data) - from datetime import UTC, datetime - created_at = invitation.user.get("created_at") or datetime.now(UTC) expected = created_at + timedelta(days=TRIAL_DAYS) assert profile.expiration_date From e1bff2b2111e1fa3e4dee606c9d41fe2c8ead503 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:40:02 +0200 Subject: [PATCH 19/19] feat: implement confirmation service setup and integrate with invitations service --- .../login/_application_keys.py | 5 ++++ .../login/_confirmation_web.py | 26 +++++++++++++++++++ .../_controller/rest/_rest_dependencies.py | 11 ++------ .../login/_invitations_service.py | 8 +++--- .../simcore_service_webserver/login/plugin.py | 2 ++ 5 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/login/_application_keys.py diff --git a/services/web/server/src/simcore_service_webserver/login/_application_keys.py b/services/web/server/src/simcore_service_webserver/login/_application_keys.py new file mode 100644 index 000000000000..4abd2e1e52b3 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_application_keys.py @@ -0,0 +1,5 @@ +from aiohttp import web + +from ._confirmation_service import ConfirmationService + +CONFIRMATION_SERVICE_APPKEY = web.AppKey("CONFIRMATION_SERVICE", ConfirmationService) diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_web.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_web.py index 6a1b6124efc7..291c188b80aa 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_web.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_web.py @@ -1,8 +1,20 @@ +import logging from urllib.parse import quote from aiohttp import web +from simcore_service_webserver.login._application_keys import ( + CONFIRMATION_SERVICE_APPKEY, +) +from simcore_service_webserver.login.settings import get_plugin_options from yarl import URL +from ..application_setup import ensure_single_setup +from ..db.plugin import get_asyncpg_engine +from ._confirmation_repository import ConfirmationRepository +from ._confirmation_service import ConfirmationService + +_logger = logging.getLogger(__name__) + def _url_for_confirmation(app: web.Application, code: str) -> URL: # NOTE: this is in a query parameter, and can contain ? for example. @@ -14,3 +26,17 @@ def make_confirmation_link(request: web.Request, code: str) -> str: assert code # nosec link = _url_for_confirmation(request.app, code=code) return f"{request.scheme}://{request.host}{link}" + + +@ensure_single_setup(__name__, logger=_logger) +def setup_confirmation(app: web.Application) -> None: + """Sets up the confirmation service in the application.""" + + async def _on_cleanup_ctx(app: web.Application): + repository = ConfirmationRepository(get_asyncpg_engine(app)) + options = get_plugin_options(app) + app[CONFIRMATION_SERVICE_APPKEY] = ConfirmationService(repository, options) + + yield + + app.cleanup_ctx.append(_on_cleanup_ctx) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_dependencies.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_dependencies.py index 94f17c5bfe74..77ee8e3f0de1 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_dependencies.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_dependencies.py @@ -6,19 +6,12 @@ from aiohttp import web -from ....db.plugin import get_asyncpg_engine -from ..._confirmation_repository import ConfirmationRepository +from ..._application_keys import CONFIRMATION_SERVICE_APPKEY from ..._confirmation_service import ConfirmationService -from ...settings import get_plugin_options def get_confirmation_service(app: web.Application) -> ConfirmationService: """Get confirmation service instance from app. - - Creates a ConfirmationService with proper repository and options injection. Used across multiple REST controllers for confirmation operations. """ - engine = get_asyncpg_engine(app) - repository = ConfirmationRepository(engine) - options = get_plugin_options(app) - return ConfirmationService(repository, options) + return app[CONFIRMATION_SERVICE_APPKEY] diff --git a/services/web/server/src/simcore_service_webserver/login/_invitations_service.py b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py index 7f3b9c86eacd..393d5f9c91a3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_invitations_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py @@ -42,7 +42,7 @@ from ..products.models import Product from ..users import users_service from . import _auth_service -from ._controller.rest._rest_dependencies import get_confirmation_service +from ._application_keys import CONFIRMATION_SERVICE_APPKEY from ._models import ( BaseConfirmationTokenDict, ConfirmationTokenDict, @@ -128,7 +128,7 @@ async def check_other_registrations( # w/ an expired confirmation will get deleted and its account (i.e. email) # can be overtaken by this new registration # - confirmation_service = get_confirmation_service(app) + confirmation_service = app[CONFIRMATION_SERVICE_APPKEY] _confirmation = await confirmation_service.get_confirmation( filter_dict={ "user_id": user["id"], @@ -194,7 +194,7 @@ async def create_invitation_token( trial_account_days=trial_days, extra_credits_in_usd=extra_credits_in_usd, ) - confirmation_service = get_confirmation_service(app) + confirmation_service = app[CONFIRMATION_SERVICE_APPKEY] confirmation = await confirmation_service.create_confirmation( user_id=user_id, action=ConfirmationAction.INVITATION.name, @@ -307,7 +307,7 @@ async def check_and_consume_invitation( ) # database-type invitations - confirmation_service = get_confirmation_service(app) + confirmation_service = app[CONFIRMATION_SERVICE_APPKEY] if confirmation := await confirmation_service.validate_confirmation_code( invitation_code ): diff --git a/services/web/server/src/simcore_service_webserver/login/plugin.py b/services/web/server/src/simcore_service_webserver/login/plugin.py index c0f619c8f120..2af0b57ea24f 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -25,6 +25,7 @@ from ..products.plugin import setup_products from ..redis import setup_redis from ..rest.plugin import setup_rest +from ._confirmation_web import setup_confirmation from ._controller.rest import ( auth, change, @@ -113,6 +114,7 @@ def setup_login(app: web.Application): setup_rest(app) setup_email(app) setup_invitations(app) + setup_confirmation(app) # routes