From 703b855ca40e45d102ee4f9696b5935d0eaef0da Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:08:52 +0100 Subject: [PATCH 01/39] confirmation service and dependencies --- .../{_confirmation.py => _confirmation_service.py} | 0 .../login/_registration.py | 9 ++++++--- .../login/handlers_change.py | 2 +- .../login/handlers_confirmation.py | 14 ++++++++------ .../login/handlers_registration.py | 7 ++++--- .../with_dbs/03/login/test_login_registration.py | 8 ++++---- 6 files changed, 23 insertions(+), 17 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_confirmation.py => _confirmation_service.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_confirmation.py rename to services/web/server/src/simcore_service_webserver/login/_confirmation_service.py diff --git a/services/web/server/src/simcore_service_webserver/login/_registration.py b/services/web/server/src/simcore_service_webserver/login/_registration.py index 0e924def7b1..122df14bc56 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration.py @@ -40,7 +40,7 @@ InvitationsServiceUnavailableError, ) from ..products.models import Product -from ._confirmation import is_confirmation_expired, validate_confirmation_code +from . import _confirmation_service from ._constants import ( MSG_EMAIL_ALREADY_REGISTERED, MSG_INVITATIONS_CONTACT_SUFFIX, @@ -134,7 +134,8 @@ async def check_other_registrations( } ) drop_previous_registration = ( - not _confirmation or is_confirmation_expired(cfg, _confirmation) + not _confirmation + or _confirmation_service.is_confirmation_expired(cfg, _confirmation) ) if drop_previous_registration: if not _confirmation: @@ -296,7 +297,9 @@ async def check_and_consume_invitation( ) # database-type invitations - if confirmation_token := await validate_confirmation_code(invitation_code, db, cfg): + if confirmation_token := await _confirmation_service.validate_confirmation_code( + invitation_code, db, cfg + ): try: invitation_data: InvitationData = _InvitationValidator.model_validate( confirmation_token diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_change.py b/services/web/server/src/simcore_service_webserver/login/handlers_change.py index 6710022bf74..6503110823b 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_change.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_change.py @@ -18,7 +18,7 @@ from ..users import api as users_service from ..utils import HOUR from ..utils_rate_limiting import global_rate_limit_route -from ._confirmation import get_or_create_confirmation, make_confirmation_link +from ._confirmation_service import get_or_create_confirmation, make_confirmation_link from ._constants import ( MSG_CANT_SEND_MAIL, MSG_CHANGE_EMAIL_REQUESTED, diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py index f4b9bb755a8..88625e3a9fd 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py @@ -33,8 +33,8 @@ from ..utils import HOUR, MINUTE from ..utils_aiohttp import create_redirect_to_page_response from ..utils_rate_limiting import global_rate_limit_route +from . import _confirmation_service from ._2fa_api import delete_2fa_code, get_2fa_code -from ._confirmation import validate_confirmation_code from ._constants import ( MSG_PASSWORD_CHANGE_NOT_ALLOWED, MSG_PASSWORD_CHANGED, @@ -143,10 +143,12 @@ async def validate_confirmation_and_redirect(request: web.Request): path_params = parse_request_path_parameters_as(_PathParam, request) - confirmation: ConfirmationTokenDict | None = await validate_confirmation_code( - path_params.code.get_secret_value(), - db=db, - cfg=cfg, + confirmation: ConfirmationTokenDict | None = ( + await _confirmation_service.validate_confirmation_code( + path_params.code.get_secret_value(), + db=db, + cfg=cfg, + ) ) redirect_to_login_url = URL(cfg.LOGIN_REDIRECT) @@ -287,7 +289,7 @@ async def complete_reset_password(request: web.Request): path_params = parse_request_path_parameters_as(_PathParam, request) request_body = await parse_request_body_as(ResetPasswordConfirmation, request) - confirmation = await validate_confirmation_code( + confirmation = await _confirmation_service.validate_confirmation_code( code=path_params.code.get_secret_value(), db=db, cfg=cfg ) diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index e91556f4424..72130547f18 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -32,9 +32,8 @@ from ..utils import MINUTE from ..utils_aiohttp import NextPage, envelope_json_response from ..utils_rate_limiting import global_rate_limit_route -from . import _auth_api +from . import _auth_api, _confirmation_service from ._2fa_api import create_2fa_code, mask_phone_number, send_sms_code -from ._confirmation import make_confirmation_link from ._constants import ( CODE_2FA_SMS_CODE_REQUIRED, MAX_2FA_CODE_RESEND, @@ -255,7 +254,9 @@ async def register(request: web.Request): ) try: - email_confirmation_url = make_confirmation_link(request, _confirmation) + email_confirmation_url = _confirmation_service.make_confirmation_link( + request, _confirmation + ) email_template_path = await get_template_path( request, "registration_email.jinja2" ) 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 1abc63ac9f5..cfc88456ffc 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 @@ -19,7 +19,7 @@ from servicelib.rest_responses import unwrap_envelope 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._confirmation import _url_for_confirmation +from simcore_service_webserver.login._confirmation_service import _url_for_confirmation from simcore_service_webserver.login._constants import ( MSG_EMAIL_ALREADY_REGISTERED, MSG_LOGGED_IN, @@ -403,9 +403,9 @@ async def test_registration_with_invitation( "email": fake_user_email, "password": fake_user_password, "confirm": fake_user_password, - "invitation": confirmation["code"] - if has_valid_invitation - else "WRONG_CODE", + "invitation": ( + confirmation["code"] if has_valid_invitation else "WRONG_CODE" + ), }, ) await assert_status(response, expected_response) From 661b36c102f68dae27658cf7d867c25678848a8d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:18:47 +0100 Subject: [PATCH 02/39] service --- .../login/_2fa_handlers.py | 23 +++++++----------- .../login/{_2fa_api.py => _2fa_service.py} | 2 +- .../login/_auth_handlers.py | 24 +++++++------------ .../login/handlers_confirmation.py | 7 +++--- .../login/handlers_registration.py | 7 +++--- .../unit/with_dbs/03/login/test_login_2fa.py | 2 +- 6 files changed, 25 insertions(+), 40 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_2fa_api.py => _2fa_service.py} (98%) diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py b/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py index 83c2119dab3..efdb3d42581 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py @@ -12,14 +12,7 @@ from ..products import products_web from ..products.models import Product from ..session.access_policies import session_access_required -from ._2fa_api import ( - create_2fa_code, - delete_2fa_code, - get_2fa_code, - mask_phone_number, - send_email_code, - send_sms_code, -) +from . import _2fa_service from ._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, @@ -72,9 +65,11 @@ async def resend_2fa_code(request: web.Request): ) # Already a code? - previous_code = await get_2fa_code(request.app, user_email=resend_2fa_.email) + previous_code = await _2fa_service.get_2fa_code( + request.app, user_email=resend_2fa_.email + ) if previous_code is not None: - await delete_2fa_code(request.app, user_email=resend_2fa_.email) + await _2fa_service.delete_2fa_code(request.app, user_email=resend_2fa_.email) # guaranteed by LoginSettingsForProduct assert settings.LOGIN_2FA_REQUIRED # nosec @@ -82,7 +77,7 @@ async def resend_2fa_code(request: web.Request): assert product.twilio_messaging_sid # nosec # creates and stores code - code = await create_2fa_code( + code = await _2fa_service.create_2fa_code( request.app, user_email=user["email"], expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, @@ -90,7 +85,7 @@ async def resend_2fa_code(request: web.Request): # sends via SMS if resend_2fa_.via == "SMS": - await send_sms_code( + await _2fa_service.send_sms_code( phone_number=user["phone"], code=code, twilio_auth=settings.LOGIN_TWILIO, @@ -105,7 +100,7 @@ async def resend_2fa_code(request: web.Request): "name": CODE_2FA_SMS_CODE_REQUIRED, "parameters": { "message": MSG_2FA_CODE_SENT.format( - phone_number=mask_phone_number(user["phone"]) + phone_number=_2fa_service.mask_phone_number(user["phone"]) ), "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, }, @@ -116,7 +111,7 @@ async def resend_2fa_code(request: web.Request): # sends via Email else: assert resend_2fa_.via == "Email" # nosec - await send_email_code( + await _2fa_service.send_email_code( request, user_email=user["email"], support_email=product.support_email, diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_api.py b/services/web/server/src/simcore_service_webserver/login/_2fa_service.py similarity index 98% rename from services/web/server/src/simcore_service_webserver/login/_2fa_api.py rename to services/web/server/src/simcore_service_webserver/login/_2fa_service.py index cda2bc1721d..e432d4b4a10 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_api.py +++ b/services/web/server/src/simcore_service_webserver/login/_2fa_service.py @@ -19,9 +19,9 @@ from settings_library.twilio import TwilioSettings from twilio.base.exceptions import TwilioException # type: ignore[import-untyped] -from ..login.errors import SendingVerificationEmailError, SendingVerificationSmsError from ..products.models import Product from ..redis import get_redis_validation_code_client +from .errors import SendingVerificationEmailError, SendingVerificationSmsError from .utils_email import get_template_path, send_email_from_template log = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py b/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py index fe1794363d8..fe7f0bd4680 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py @@ -22,14 +22,7 @@ ) from ..users import preferences_api as user_preferences_api from ..utils_aiohttp import NextPage -from ._2fa_api import ( - create_2fa_code, - delete_2fa_code, - get_2fa_code, - mask_phone_number, - send_email_code, - send_sms_code, -) +from . import _2fa_service from ._auth_api import ( check_authorized_user_credentials_or_raise, check_authorized_user_in_product_or_raise, @@ -74,8 +67,7 @@ class CodePageParams(BaseModel): next_url: str | None = None -class LoginNextPage(NextPage[CodePageParams]): - ... +class LoginNextPage(NextPage[CodePageParams]): ... @routes.post(f"/{API_VTAG}/auth/login", name="auth_login") @@ -162,7 +154,7 @@ async def login(request: web.Request): status=status.HTTP_202_ACCEPTED, ) - code = await create_2fa_code( + code = await _2fa_service.create_2fa_code( app=request.app, user_email=user["email"], expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, @@ -175,7 +167,7 @@ async def login(request: web.Request): assert settings.LOGIN_TWILIO # nosec assert product.twilio_messaging_sid # nosec - await send_sms_code( + await _2fa_service.send_sms_code( phone_number=user["phone"], code=code, twilio_auth=settings.LOGIN_TWILIO, @@ -191,7 +183,7 @@ async def login(request: web.Request): "name": CODE_2FA_SMS_CODE_REQUIRED, "parameters": { "message": MSG_2FA_CODE_SENT.format( - phone_number=mask_phone_number(user["phone"]) + phone_number=_2fa_service.mask_phone_number(user["phone"]) ), "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, }, @@ -203,7 +195,7 @@ async def login(request: web.Request): assert ( user_2fa_authentification_method == TwoFactorAuthentificationMethod.EMAIL ) # nosec - await send_email_code( + await _2fa_service.send_email_code( request, user_email=user["email"], support_email=product.support_email, @@ -252,7 +244,7 @@ async def login_2fa(request: web.Request): login_2fa_ = await parse_request_body_as(LoginTwoFactorAuthBody, request) # validates code - _expected_2fa_code = await get_2fa_code(request.app, login_2fa_.email) + _expected_2fa_code = await _2fa_service.get_2fa_code(request.app, login_2fa_.email) if not _expected_2fa_code: raise web.HTTPUnauthorized( reason=MSG_WRONG_2FA_CODE__EXPIRED, content_type=MIMETYPE_APPLICATION_JSON @@ -269,7 +261,7 @@ async def login_2fa(request: web.Request): assert UserRole(user["role"]) <= UserRole.USER # nosec # dispose since code was used - await delete_2fa_code(request.app, login_2fa_.email) + await _2fa_service.delete_2fa_code(request.app, login_2fa_.email) return await login_granted_response(request, user=dict(user)) diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py index 88625e3a9fd..b06ded88914 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py @@ -33,8 +33,7 @@ from ..utils import HOUR, MINUTE from ..utils_aiohttp import create_redirect_to_page_response from ..utils_rate_limiting import global_rate_limit_route -from . import _confirmation_service -from ._2fa_api import delete_2fa_code, get_2fa_code +from . import _2fa_service, _confirmation_service from ._constants import ( MSG_PASSWORD_CHANGE_NOT_ALLOWED, MSG_PASSWORD_CHANGED, @@ -242,10 +241,10 @@ async def phone_confirmation(request: web.Request): request_body = await parse_request_body_as(PhoneConfirmationBody, request) if ( - expected := await get_2fa_code(request.app, request_body.email) + expected := await _2fa_service.get_2fa_code(request.app, request_body.email) ) and request_body.code.get_secret_value() == expected: # consumes code - await delete_2fa_code(request.app, request_body.email) + await _2fa_service.delete_2fa_code(request.app, request_body.email) # updates confirmed phone number try: diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index 72130547f18..0ef64c57e10 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -32,8 +32,7 @@ from ..utils import MINUTE from ..utils_aiohttp import NextPage, envelope_json_response from ..utils_rate_limiting import global_rate_limit_route -from . import _auth_api, _confirmation_service -from ._2fa_api import create_2fa_code, mask_phone_number, send_sms_code +from . import _2fa_service, _auth_api, _confirmation_service from ._constants import ( CODE_2FA_SMS_CODE_REQUIRED, MAX_2FA_CODE_RESEND, @@ -381,12 +380,12 @@ async def register_phone(request: web.Request): msg = f"Messaging SID is not configured in {product}. Update product's twilio_messaging_sid in database." raise ValueError(msg) - code = await create_2fa_code( + code = await _2fa_service.create_2fa_code( app=request.app, user_email=registration.email, expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, ) - await send_sms_code( + await _2fa_service.send_sms_code( phone_number=registration.phone, code=code, twilio_auth=settings.LOGIN_TWILIO, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 588e95182b6..802ce69ef92 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -22,7 +22,7 @@ from simcore_postgres_database.models.products import ProductLoginSettingsDict, products from simcore_service_webserver.application_settings import ApplicationSettings from simcore_service_webserver.db.models import UserStatus -from simcore_service_webserver.login._2fa_api import ( +from simcore_service_webserver.login._2fa_service import ( _do_create_2fa_code, create_2fa_code, delete_2fa_code, From 15680bd58e6155100aa9f04252e58ca2ee9a8758 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:24:21 +0100 Subject: [PATCH 03/39] adapts mocks --- .../login/_auth_handlers.py | 15 +++++---------- .../login/{_auth_api.py => _auth_service.py} | 0 .../login/handlers_registration.py | 8 ++++---- .../unit/with_dbs/03/login/test_login_2fa.py | 6 +++--- .../with_dbs/03/login/test_login_2fa_resend.py | 11 +++++++---- .../server/tests/unit/with_dbs/03/test_users.py | 4 ++-- 6 files changed, 21 insertions(+), 23 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_auth_api.py => _auth_service.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py b/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py index fe7f0bd4680..65ffc929d83 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py @@ -22,12 +22,7 @@ ) from ..users import preferences_api as user_preferences_api from ..utils_aiohttp import NextPage -from . import _2fa_service -from ._auth_api import ( - check_authorized_user_credentials_or_raise, - check_authorized_user_in_product_or_raise, - get_user_by_email, -) +from . import _2fa_service, _auth_service from ._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, @@ -96,12 +91,12 @@ async def login(request: web.Request): login_data = await parse_request_body_as(LoginBody, request) # Authenticate user and verify access to the product - user = await check_authorized_user_credentials_or_raise( - user=await get_user_by_email(request.app, email=login_data.email), + user = await _auth_service.check_authorized_user_credentials_or_raise( + user=await _auth_service.get_user_by_email(request.app, email=login_data.email), password=login_data.password.get_secret_value(), product=product, ) - await check_authorized_user_in_product_or_raise( + await _auth_service.check_authorized_user_in_product_or_raise( request.app, user=user, product=product ) @@ -254,7 +249,7 @@ async def login_2fa(request: web.Request): reason=MSG_WRONG_2FA_CODE__INVALID, content_type=MIMETYPE_APPLICATION_JSON ) - user = await db.get_user({"email": login_2fa_.email}) + user = await _auth_service.get_user_by_email(request.app, email=login_2fa_.email) assert user is not None # nosec # NOTE: a priviledge user should not have called this entrypoint diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_api.py b/services/web/server/src/simcore_service_webserver/login/_auth_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_auth_api.py rename to services/web/server/src/simcore_service_webserver/login/_auth_service.py diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index 0ef64c57e10..1a8a644e82b 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -32,7 +32,7 @@ from ..utils import MINUTE from ..utils_aiohttp import NextPage, envelope_json_response from ..utils_rate_limiting import global_rate_limit_route -from . import _2fa_service, _auth_api, _confirmation_service +from . import _2fa_service, _auth_service, _confirmation_service from ._constants import ( CODE_2FA_SMS_CODE_REQUIRED, MAX_2FA_CODE_RESEND, @@ -210,15 +210,15 @@ async def register(request: web.Request): expires_at = datetime.now(UTC) + timedelta(invitation.trial_account_days) # get authorized user or create new - user = await _auth_api.get_user_by_email(request.app, email=registration.email) + user = await _auth_service.get_user_by_email(request.app, email=registration.email) if user: - await _auth_api.check_authorized_user_credentials_or_raise( + await _auth_service.check_authorized_user_credentials_or_raise( user, password=registration.password.get_secret_value(), product=product, ) else: - user = await _auth_api.create_user( + user = await _auth_service.create_user( request.app, email=registration.email, password=registration.password.get_secret_value(), diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 802ce69ef92..37b6a49aed5 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -79,11 +79,11 @@ def postgres_db(postgres_db: sa.engine.Engine): def mocked_twilio_service(mocker: MockerFixture) -> dict[str, Mock]: return { "send_sms_code_for_registration": mocker.patch( - "simcore_service_webserver.login.handlers_registration.send_sms_code", + "simcore_service_webserver.login.handlers_registration._2fa_service.send_sms_code", autospec=True, ), "send_sms_code_for_login": mocker.patch( - "simcore_service_webserver.login._auth_handlers.send_sms_code", + "simcore_service_webserver.login._auth_rest._2fa_service.send_sms_code", autospec=True, ), } @@ -421,7 +421,7 @@ async def test_2fa_sms_failure_during_login( mocker.patch( # MD: Emulates error in graylog https://monitoring.osparc.io/graylog/search/649e7619ce6e0838a96e9bf1?q=%222FA%22&rangetype=relative&from=172800 - "simcore_service_webserver.login._2fa_api.twilio.rest.Client", + "simcore_service_webserver.login._2fa_service.twilio.rest.Client", autospec=True, side_effect=TwilioRestException( status=400, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py index 1f3c76c2ea8..ae2d4e90ba5 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py @@ -79,18 +79,21 @@ async def test_resend_2fa_workflow( # spy send functions mocker.patch( - "simcore_service_webserver.login._2fa_handlers.send_sms_code", autospec=True + "simcore_service_webserver.login._2fa_rest._2fa_service.send_sms_code", + autospec=True, ) mock_send_sms_code2 = mocker.patch( - "simcore_service_webserver.login._auth_handlers.send_sms_code", autospec=True + "simcore_service_webserver.login._auth_rest._2fa_service.send_sms_code", + autospec=True, ) mock_send_email_code = mocker.patch( - "simcore_service_webserver.login._2fa_handlers.send_email_code", autospec=True + "simcore_service_webserver.login._2fa_rest._2fa_service.send_email_code", + autospec=True, ) mock_get_2fa_code = mocker.patch( - "simcore_service_webserver.login._2fa_handlers.get_2fa_code", + "simcore_service_webserver.login._2fa_rest._2fa_service.get_2fa_code", autospec=True, return_value=None, # <-- Emulates code expired ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users.py b/services/web/server/tests/unit/with_dbs/03/test_users.py index c4008f75235..79d2b82b054 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users.py @@ -15,7 +15,7 @@ from unittest.mock import MagicMock, Mock import pytest -import simcore_service_webserver.login._auth_api +import simcore_service_webserver.login._auth_service from aiohttp.test_utils import TestClient from aiopg.sa.connection import SAConnection from common_library.users_enums import UserRole, UserStatus @@ -729,7 +729,7 @@ async def test_search_and_pre_registration( # Emulating registration of pre-register user new_user = ( - await simcore_service_webserver.login._auth_api.create_user( # noqa: SLF001 + await simcore_service_webserver.login._auth_service.create_user( # noqa: SLF001 client.app, email=account_request_form["email"], password=DEFAULT_TEST_PASSWORD, From 5a588a46d3b0aca11ccb6672cdac4a4af209c0b0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:26:16 +0100 Subject: [PATCH 04/39] rest --- api/specs/web-server/_auth.py | 4 ++-- .../login/{_2fa_handlers.py => _2fa_rest.py} | 0 .../login/{_auth_handlers.py => _auth_rest.py} | 0 .../simcore_service_webserver/login/plugin.py | 18 +++++++++--------- .../with_dbs/03/login/test_login_2fa_resend.py | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_2fa_handlers.py => _2fa_rest.py} (100%) rename services/web/server/src/simcore_service_webserver/login/{_auth_handlers.py => _auth_rest.py} (100%) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index 978dcef3d63..d168d15a8c7 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -15,8 +15,8 @@ from models_library.rest_error import EnvelopedError, Log from pydantic import BaseModel, Field, confloat from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.login._2fa_handlers import Resend2faBody -from simcore_service_webserver.login._auth_handlers import ( +from simcore_service_webserver.login._2fa_rest import Resend2faBody +from simcore_service_webserver.login._auth_rest import ( LoginBody, LoginNextPage, LoginTwoFactorAuthBody, diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py b/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py rename to services/web/server/src/simcore_service_webserver/login/_2fa_rest.py diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_auth_handlers.py rename to services/web/server/src/simcore_service_webserver/login/_auth_rest.py 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 149780b668e..2824adc1c6a 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -24,8 +24,8 @@ from ..redis import setup_redis from ..rest.plugin import setup_rest from . import ( - _2fa_handlers, - _auth_handlers, + _2fa_rest, + _auth_rest, _registration_handlers, handlers_change, handlers_confirmation, @@ -93,11 +93,11 @@ async def _resolve_login_settings_per_product(app: web.Application): errors = {} for product in products_service.list_products(app): try: - login_settings_per_product[ - product.name - ] = LoginSettingsForProduct.create_from_composition( - app_login_settings=app_login_settings, - product_login_settings=product.login_settings, + login_settings_per_product[product.name] = ( + LoginSettingsForProduct.create_from_composition( + app_login_settings=app_login_settings, + product_login_settings=product.login_settings, + ) ) except ValidationError as err: # noqa: PERF203 errors[product.name] = err @@ -138,12 +138,12 @@ def setup_login(app: web.Application): # routes - app.router.add_routes(_auth_handlers.routes) + app.router.add_routes(_auth_rest.routes) app.router.add_routes(handlers_confirmation.routes) app.router.add_routes(handlers_registration.routes) app.router.add_routes(_registration_handlers.routes) app.router.add_routes(handlers_change.routes) - app.router.add_routes(_2fa_handlers.routes) + app.router.add_routes(_2fa_rest.routes) _setup_login_options(app) setup_login_storage(app) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py index ae2d4e90ba5..6a1e500f9ea 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py @@ -13,7 +13,7 @@ from servicelib.aiohttp import status from simcore_postgres_database.models.products import ProductLoginSettingsDict, products from simcore_service_webserver.application_settings import ApplicationSettings -from simcore_service_webserver.login._auth_handlers import CodePageParams, NextPage +from simcore_service_webserver.login._auth_rest import CodePageParams, NextPage from simcore_service_webserver.login._constants import CODE_2FA_SMS_CODE_REQUIRED From dbc723d1fea735cbc81178f20f50eefc94d25229 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:27:40 +0100 Subject: [PATCH 05/39] registration component --- ...egistration_handlers.py => _registration_rest.py} | 12 ++++-------- ..._registration_api.py => _registration_service.py} | 0 .../src/simcore_service_webserver/login/plugin.py | 4 ++-- .../web/server/tests/unit/with_dbs/03/test_email.py | 6 ++++-- 4 files changed, 10 insertions(+), 12 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_registration_handlers.py => _registration_rest.py} (95%) rename services/web/server/src/simcore_service_webserver/login/{_registration_api.py => _registration_service.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py similarity index 95% rename from services/web/server/src/simcore_service_webserver/login/_registration_handlers.py rename to services/web/server/src/simcore_service_webserver/login/_registration_rest.py index 2cbb69db5ee..2c87f4bf229 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py @@ -26,12 +26,8 @@ from ..users.api import get_user_credentials, set_user_as_deleted from ..utils import MINUTE from ..utils_rate_limiting import global_rate_limit_route +from . import _registration_service from ._constants import CAPTCHA_SESSION_KEY, MSG_LOGGED_OUT, MSG_WRONG_CAPTCHA__INVALID -from ._registration_api import ( - generate_captcha, - send_account_request_email_to_support, - send_close_account_email, -) from .decorators import login_required from .settings import LoginSettingsForProduct, get_plugin_settings from .utils import flash_response, notify_user_logout @@ -78,7 +74,7 @@ async def request_product_account(request: web.Request): # send email to fogbugz or user itself fire_and_forget_task( - send_account_request_email_to_support( + _registration_service.send_account_request_email_to_support( request, product=product, request_form=body.form, @@ -135,7 +131,7 @@ async def unregister_account(request: web.Request): # send email in the background fire_and_forget_task( - send_close_account_email( + _registration_service.send_close_account_email( request, user_email=credentials.email, user_first_name=credentials.display_name, @@ -156,7 +152,7 @@ async def unregister_account(request: web.Request): async def request_captcha(request: web.Request): session = await get_session(request) - captcha_text, image_data = await generate_captcha() + captcha_text, image_data = await _registration_service.generate_captcha() # Store captcha text in session session[CAPTCHA_SESSION_KEY] = captcha_text diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_api.py b/services/web/server/src/simcore_service_webserver/login/_registration_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_registration_api.py rename to services/web/server/src/simcore_service_webserver/login/_registration_service.py 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 2824adc1c6a..297e4f187ca 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -26,7 +26,7 @@ from . import ( _2fa_rest, _auth_rest, - _registration_handlers, + _registration_rest, handlers_change, handlers_confirmation, handlers_registration, @@ -141,7 +141,7 @@ def setup_login(app: web.Application): app.router.add_routes(_auth_rest.routes) app.router.add_routes(handlers_confirmation.routes) app.router.add_routes(handlers_registration.routes) - app.router.add_routes(_registration_handlers.routes) + app.router.add_routes(_registration_rest.routes) app.router.add_routes(handlers_change.routes) app.router.add_routes(_2fa_rest.routes) diff --git a/services/web/server/tests/unit/with_dbs/03/test_email.py b/services/web/server/tests/unit/with_dbs/03/test_email.py index 244f090ab40..735c8fd8050 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_email.py +++ b/services/web/server/tests/unit/with_dbs/03/test_email.py @@ -29,8 +29,10 @@ from simcore_service_webserver.email._core import _remove_comments, _render_template from simcore_service_webserver.email._handlers import EmailTestFailed, EmailTestPassed from simcore_service_webserver.email.plugin import setup_email -from simcore_service_webserver.login._registration_api import _json_encoder_and_dumps -from simcore_service_webserver.login._registration_handlers import _get_ipinfo +from simcore_service_webserver.login._registration_rest import _get_ipinfo +from simcore_service_webserver.login._registration_service import ( + _json_encoder_and_dumps, +) @pytest.fixture From 4d216aa80becb50fcf5b662c7487e1485a2dd762 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:32:11 +0100 Subject: [PATCH 06/39] security --- .../src/simcore_service_webserver/login/_auth_rest.py | 9 ++++----- .../login/{_security.py => _security_service.py} | 7 +++---- .../login/handlers_confirmation.py | 5 ++--- .../login/handlers_registration.py | 5 ++--- 4 files changed, 11 insertions(+), 15 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_security.py => _security_service.py} (86%) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py index 65ffc929d83..e55e97b671d 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py @@ -22,7 +22,7 @@ ) from ..users import preferences_api as user_preferences_api from ..utils_aiohttp import NextPage -from . import _2fa_service, _auth_service +from . import _2fa_service, _auth_service, _security_service from ._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, @@ -38,7 +38,6 @@ MSG_WRONG_2FA_CODE__INVALID, ) from ._models import InputSchema -from ._security import login_granted_response from .decorators import login_required from .errors import handle_login_exceptions from .settings import LoginSettingsForProduct, get_plugin_settings @@ -103,7 +102,7 @@ async def login(request: web.Request): # Check if user role allows skipping 2FA or if 2FA is not required skip_2fa = UserRole(user["role"]) == UserRole.TESTER if skip_2fa or not settings.LOGIN_2FA_REQUIRED: - return await login_granted_response(request, user=user) + return await _security_service.login_granted_response(request, user=user) # 2FA login process continuation user_2fa_preference = await user_preferences_api.get_frontend_user_preference( @@ -130,7 +129,7 @@ async def login(request: web.Request): ).validate_python(user_2fa_preference.value) if user_2fa_authentification_method == TwoFactorAuthentificationMethod.DISABLED: - return await login_granted_response(request, user=user) + return await _security_service.login_granted_response(request, user=user) # Check phone for SMS authentication if ( @@ -258,7 +257,7 @@ async def login_2fa(request: web.Request): # dispose since code was used await _2fa_service.delete_2fa_code(request.app, login_2fa_.email) - return await login_granted_response(request, user=dict(user)) + return await _security_service.login_granted_response(request, user=dict(user)) class LogoutBody(InputSchema): diff --git a/services/web/server/src/simcore_service_webserver/login/_security.py b/services/web/server/src/simcore_service_webserver/login/_security_service.py similarity index 86% rename from services/web/server/src/simcore_service_webserver/login/_security.py rename to services/web/server/src/simcore_service_webserver/login/_security_service.py index 4764f323481..7588f68d63d 100644 --- a/services/web/server/src/simcore_service_webserver/login/_security.py +++ b/services/web/server/src/simcore_service_webserver/login/_security_service.py @@ -1,13 +1,12 @@ -""" Utils that extends on security_api plugin +"""Utils that extends on security_api plugin""" -""" import logging from typing import Any from aiohttp import web from servicelib.logging_utils import get_log_record_extra, log_context -from ..security.api import remember_identity +from ..security import api as security_service from ._constants import MSG_LOGGED_IN from .utils import flash_response @@ -37,7 +36,7 @@ async def login_granted_response( extra=get_log_record_extra(user_id=user_id), ): response = flash_response(MSG_LOGGED_IN, "INFO") - return await remember_identity( + return await security_service.remember_identity( request=request, response=response, user_email=email, diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py index b06ded88914..2dd53299425 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py @@ -33,7 +33,7 @@ from ..utils import HOUR, MINUTE from ..utils_aiohttp import create_redirect_to_page_response from ..utils_rate_limiting import global_rate_limit_route -from . import _2fa_service, _confirmation_service +from . import _2fa_service, _confirmation_service, _security_service from ._constants import ( MSG_PASSWORD_CHANGE_NOT_ALLOWED, MSG_PASSWORD_CHANGED, @@ -41,7 +41,6 @@ ) from ._models import InputSchema, check_confirm_password_match from ._registration import InvitationData -from ._security import login_granted_response from .settings import ( LoginOptions, LoginSettingsForProduct, @@ -258,7 +257,7 @@ async def phone_confirmation(request: web.Request): content_type=MIMETYPE_APPLICATION_JSON, ) from err - return await login_granted_response(request, user=dict(user)) + return await _security_service.login_granted_response(request, user=dict(user)) # fails because of invalid or no code raise web.HTTPUnauthorized( diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index 1a8a644e82b..faac63034a6 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -32,7 +32,7 @@ from ..utils import MINUTE from ..utils_aiohttp import NextPage, envelope_json_response from ..utils_rate_limiting import global_rate_limit_route -from . import _2fa_service, _auth_service, _confirmation_service +from . import _2fa_service, _auth_service, _confirmation_service, _security_service from ._constants import ( CODE_2FA_SMS_CODE_REQUIRED, MAX_2FA_CODE_RESEND, @@ -48,7 +48,6 @@ check_other_registrations, extract_email_from_invitation, ) -from ._security import login_granted_response from .settings import ( LoginOptions, LoginSettingsForProduct, @@ -321,7 +320,7 @@ async def register(request: web.Request): assert not settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED # nosec assert not settings.LOGIN_2FA_REQUIRED # nosec - return await login_granted_response(request=request, user=user) + return await _security_service.login_granted_response(request=request, user=user) class RegisterPhoneBody(InputSchema): From ca46abb4977d808dd5235a3240923b7c4eff149c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:37:19 +0100 Subject: [PATCH 07/39] security --- .../src/simcore_service_webserver/login/_auth_rest.py | 4 ++-- .../server/src/simcore_service_webserver/login/_models.py | 2 +- .../simcore_service_webserver/login/_registration_rest.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py index e55e97b671d..52bed89e011 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py @@ -15,7 +15,7 @@ from .._meta import API_VTAG from ..products import products_web from ..products.models import Product -from ..security.api import forget_identity +from ..security import api as security_service from ..session.access_policies import ( on_success_grant_session_access_to, session_access_required, @@ -284,7 +284,7 @@ async def logout(request: web.Request) -> web.Response: ): response = flash_response(MSG_LOGGED_OUT, "INFO") await notify_user_logout(request.app, user_id, logout_.client_session_id) - await forget_identity(request, response) + await security_service.forget_identity(request, response) return response 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 c0aef7a6015..c63430b59e6 100644 --- a/services/web/server/src/simcore_service_webserver/login/_models.py +++ b/services/web/server/src/simcore_service_webserver/login/_models.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable from pydantic import BaseModel, ConfigDict, SecretStr, ValidationInfo diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py index 2c87f4bf229..76efc32c6e9 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py @@ -20,7 +20,7 @@ from ..constants import RQ_PRODUCT_KEY from ..products import products_web from ..products.models import Product -from ..security.api import check_password, forget_identity +from ..security import api as security_service from ..security.decorators import permission_required from ..session.api import get_session from ..users.api import get_user_credentials, set_user_as_deleted @@ -105,7 +105,7 @@ async def unregister_account(request: web.Request): # checks before deleting credentials = await get_user_credentials(request.app, user_id=req_ctx.user_id) - if body.email != credentials.email.lower() or not check_password( + if body.email != credentials.email.lower() or not security_service.check_password( body.password.get_secret_value(), credentials.password_hash ): raise web.HTTPConflict( @@ -127,7 +127,7 @@ async def unregister_account(request: web.Request): request.app, user_id=req_ctx.user_id, client_session_id=None ) response = flash_response(MSG_LOGGED_OUT, "INFO") - await forget_identity(request, response) + await security_service.forget_identity(request, response) # send email in the background fire_and_forget_task( From babfdf7bbd3204abb86499a5ba8beb9966a2d0bc Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:39:06 +0100 Subject: [PATCH 08/39] rename invitations service --- .../src/pytest_simcore/helpers/webserver_login.py | 2 +- .../login/{_registration.py => _invitations_service.py} | 0 .../web/server/src/simcore_service_webserver/login/cli.py | 2 +- .../simcore_service_webserver/login/handlers_confirmation.py | 2 +- .../simcore_service_webserver/login/handlers_registration.py | 4 ++-- 5 files changed, 5 insertions(+), 5 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_registration.py => _invitations_service.py} (100%) 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 a9d7b3fcdd7..ac9cdac0a4c 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py @@ -11,7 +11,7 @@ from simcore_service_webserver.db.models import UserRole, UserStatus from simcore_service_webserver.groups.api import auto_add_user_to_product_group from simcore_service_webserver.login._constants import MSG_LOGGED_IN -from simcore_service_webserver.login._registration import create_invitation_token +from simcore_service_webserver.login._invitations_service import create_invitation_token from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage from simcore_service_webserver.products.products_service import list_products from simcore_service_webserver.security.api import clean_auth_policy_cache diff --git a/services/web/server/src/simcore_service_webserver/login/_registration.py b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_registration.py rename to services/web/server/src/simcore_service_webserver/login/_invitations_service.py diff --git a/services/web/server/src/simcore_service_webserver/login/cli.py b/services/web/server/src/simcore_service_webserver/login/cli.py index 561ec8c1e9f..86a70f8e77f 100644 --- a/services/web/server/src/simcore_service_webserver/login/cli.py +++ b/services/web/server/src/simcore_service_webserver/login/cli.py @@ -6,7 +6,7 @@ from simcore_postgres_database.models.confirmations import ConfirmationAction from yarl import URL -from ._registration import InvitationData, get_invitation_url +from ._invitations_service import InvitationData, get_invitation_url def invitations( diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py index 2dd53299425..7bc83fe2148 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py @@ -39,8 +39,8 @@ MSG_PASSWORD_CHANGED, MSG_UNAUTHORIZED_PHONE_CONFIRMATION, ) +from ._invitations_service import InvitationData from ._models import InputSchema, check_confirm_password_match -from ._registration import InvitationData from .settings import ( LoginOptions, LoginSettingsForProduct, diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index faac63034a6..bdeb3eca5a8 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -42,12 +42,12 @@ MSG_UNAUTHORIZED_REGISTER_PHONE, MSG_WEAK_PASSWORD, ) -from ._models import InputSchema, check_confirm_password_match -from ._registration import ( +from ._invitations_service import ( check_and_consume_invitation, check_other_registrations, extract_email_from_invitation, ) +from ._models import InputSchema, check_confirm_password_match from .settings import ( LoginOptions, LoginSettingsForProduct, From 84b8cbdf6f66526a7c8f194203636a5403b48b2e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:43:06 +0100 Subject: [PATCH 09/39] imports --- .../simcore_service_webserver/login/handlers_change.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_change.py b/services/web/server/src/simcore_service_webserver/login/handlers_change.py index 6503110823b..968a5500e1e 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_change.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_change.py @@ -18,7 +18,7 @@ from ..users import api as users_service from ..utils import HOUR from ..utils_rate_limiting import global_rate_limit_route -from ._confirmation_service import get_or_create_confirmation, make_confirmation_link +from . import _confirmation_service from ._constants import ( MSG_CANT_SEND_MAIL, MSG_CHANGE_EMAIL_REQUESTED, @@ -180,12 +180,12 @@ def _get_error_context( try: # Confirmation token that includes code to `complete_reset_password`. # Recreated if non-existent or expired (Guideline #2) - confirmation = await get_or_create_confirmation( + confirmation = await _confirmation_service.get_or_create_confirmation( cfg, db, user_id=user["id"], action="RESET_PASSWORD" ) # Produce a link so that the front-end can hit `complete_reset_password` - link = make_confirmation_link(request, confirmation) + link = _confirmation_service.make_confirmation_link(request, confirmation) # primary reset email with a URL and the normal instructions. await send_email_from_template( @@ -247,7 +247,7 @@ async def submit_request_to_change_email(request: web.Request): confirmation = await db.create_confirmation( user_id=user["id"], action="CHANGE_EMAIL", data=request_body.email ) - link = make_confirmation_link(request, confirmation) + link = _confirmation_service.make_confirmation_link(request, confirmation) try: await send_email_from_template( request, From f808f13c76aa217db59fd66fe5162b5857392b1e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:49:38 +0100 Subject: [PATCH 10/39] rest --- api/specs/web-server/_auth.py | 14 +++++++------- ...tion_service.py => _preregistration_service.py} | 0 .../login/_registration_rest.py | 8 ++++---- ...ers_registration.py => _registration_rest_2.py} | 0 .../src/simcore_service_webserver/login/plugin.py | 4 ++-- ...test_login_handlers_registration_invitations.py | 2 +- .../server/tests/unit/with_dbs/03/test_email.py | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_registration_service.py => _preregistration_service.py} (100%) rename services/web/server/src/simcore_service_webserver/login/{handlers_registration.py => _registration_rest_2.py} (100%) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index d168d15a8c7..92c2229e108 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -22,6 +22,13 @@ LoginTwoFactorAuthBody, LogoutBody, ) +from simcore_service_webserver.login._registration_rest_2 import ( + InvitationCheck, + InvitationInfo, + RegisterBody, + RegisterPhoneBody, + RegisterPhoneNextPage, +) from simcore_service_webserver.login.handlers_change import ( ChangeEmailBody, ChangePasswordBody, @@ -31,13 +38,6 @@ PhoneConfirmationBody, ResetPasswordConfirmation, ) -from simcore_service_webserver.login.handlers_registration import ( - InvitationCheck, - InvitationInfo, - RegisterBody, - RegisterPhoneBody, - RegisterPhoneNextPage, -) router = APIRouter(prefix=f"/{API_VTAG}", tags=["auth"]) diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_service.py b/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_registration_service.py rename to services/web/server/src/simcore_service_webserver/login/_preregistration_service.py diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py index 76efc32c6e9..b2d9197804e 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py @@ -26,7 +26,7 @@ from ..users.api import get_user_credentials, set_user_as_deleted from ..utils import MINUTE from ..utils_rate_limiting import global_rate_limit_route -from . import _registration_service +from . import _preregistration_service from ._constants import CAPTCHA_SESSION_KEY, MSG_LOGGED_OUT, MSG_WRONG_CAPTCHA__INVALID from .decorators import login_required from .settings import LoginSettingsForProduct, get_plugin_settings @@ -74,7 +74,7 @@ async def request_product_account(request: web.Request): # send email to fogbugz or user itself fire_and_forget_task( - _registration_service.send_account_request_email_to_support( + _preregistration_service.send_account_request_email_to_support( request, product=product, request_form=body.form, @@ -131,7 +131,7 @@ async def unregister_account(request: web.Request): # send email in the background fire_and_forget_task( - _registration_service.send_close_account_email( + _preregistration_service.send_close_account_email( request, user_email=credentials.email, user_first_name=credentials.display_name, @@ -152,7 +152,7 @@ async def unregister_account(request: web.Request): async def request_captcha(request: web.Request): session = await get_session(request) - captcha_text, image_data = await _registration_service.generate_captcha() + captcha_text, image_data = await _preregistration_service.generate_captcha() # Store captcha text in session session[CAPTCHA_SESSION_KEY] = captcha_text diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/handlers_registration.py rename to services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py 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 297e4f187ca..790713da2cf 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -27,9 +27,9 @@ _2fa_rest, _auth_rest, _registration_rest, + _registration_rest_2, handlers_change, handlers_confirmation, - handlers_registration, ) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY from .settings import ( @@ -140,7 +140,7 @@ def setup_login(app: web.Application): app.router.add_routes(_auth_rest.routes) app.router.add_routes(handlers_confirmation.routes) - app.router.add_routes(handlers_registration.routes) + app.router.add_routes(_registration_rest_2.routes) app.router.add_routes(_registration_rest.routes) app.router.add_routes(handlers_change.routes) app.router.add_routes(_2fa_rest.routes) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py index 7a081e39cb6..ed62affc6ef 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py @@ -18,7 +18,7 @@ from servicelib.aiohttp import status from servicelib.rest_constants import X_PRODUCT_NAME_HEADER from simcore_service_webserver.invitations.api import generate_invitation -from simcore_service_webserver.login.handlers_registration import ( +from simcore_service_webserver.login._registration_rest_2 import ( InvitationCheck, InvitationInfo, ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_email.py b/services/web/server/tests/unit/with_dbs/03/test_email.py index 735c8fd8050..ce3db9c9022 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_email.py +++ b/services/web/server/tests/unit/with_dbs/03/test_email.py @@ -29,10 +29,10 @@ from simcore_service_webserver.email._core import _remove_comments, _render_template from simcore_service_webserver.email._handlers import EmailTestFailed, EmailTestPassed from simcore_service_webserver.email.plugin import setup_email -from simcore_service_webserver.login._registration_rest import _get_ipinfo -from simcore_service_webserver.login._registration_service import ( +from simcore_service_webserver.login._preregistration_service import ( _json_encoder_and_dumps, ) +from simcore_service_webserver.login._registration_rest import _get_ipinfo @pytest.fixture From d1879507bdc0297a78478877e20f0fc3e308397d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:50:26 +0100 Subject: [PATCH 11/39] confirmation rest --- api/specs/web-server/_auth.py | 8 ++++---- .../{handlers_confirmation.py => _confirmation_rest.py} | 0 .../server/src/simcore_service_webserver/login/plugin.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{handlers_confirmation.py => _confirmation_rest.py} (100%) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index 92c2229e108..3db9bfa70ac 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -22,6 +22,10 @@ LoginTwoFactorAuthBody, LogoutBody, ) +from simcore_service_webserver.login._confirmation_rest import ( + PhoneConfirmationBody, + ResetPasswordConfirmation, +) from simcore_service_webserver.login._registration_rest_2 import ( InvitationCheck, InvitationInfo, @@ -34,10 +38,6 @@ ChangePasswordBody, ResetPasswordBody, ) -from simcore_service_webserver.login.handlers_confirmation import ( - PhoneConfirmationBody, - ResetPasswordConfirmation, -) router = APIRouter(prefix=f"/{API_VTAG}", tags=["auth"]) diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py rename to services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py 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 790713da2cf..51b0f440830 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -26,10 +26,10 @@ from . import ( _2fa_rest, _auth_rest, + _confirmation_rest, _registration_rest, _registration_rest_2, handlers_change, - handlers_confirmation, ) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY from .settings import ( @@ -139,7 +139,7 @@ def setup_login(app: web.Application): # routes app.router.add_routes(_auth_rest.routes) - app.router.add_routes(handlers_confirmation.routes) + app.router.add_routes(_confirmation_rest.routes) app.router.add_routes(_registration_rest_2.routes) app.router.add_routes(_registration_rest.routes) app.router.add_routes(handlers_change.routes) From 97590dfa1d91af171200aa57c8e1dca5dbf14d41 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:51:46 +0100 Subject: [PATCH 12/39] change rest --- api/specs/web-server/_auth.py | 10 +++++----- .../login/{handlers_change.py => _change_service.py} | 0 .../src/simcore_service_webserver/login/plugin.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{handlers_change.py => _change_service.py} (100%) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index 3db9bfa70ac..a35db14eeba 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -22,6 +22,11 @@ LoginTwoFactorAuthBody, LogoutBody, ) +from simcore_service_webserver.login._change_service import ( + ChangeEmailBody, + ChangePasswordBody, + ResetPasswordBody, +) from simcore_service_webserver.login._confirmation_rest import ( PhoneConfirmationBody, ResetPasswordConfirmation, @@ -33,11 +38,6 @@ RegisterPhoneBody, RegisterPhoneNextPage, ) -from simcore_service_webserver.login.handlers_change import ( - ChangeEmailBody, - ChangePasswordBody, - ResetPasswordBody, -) router = APIRouter(prefix=f"/{API_VTAG}", tags=["auth"]) diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_change.py b/services/web/server/src/simcore_service_webserver/login/_change_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/handlers_change.py rename to services/web/server/src/simcore_service_webserver/login/_change_service.py 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 51b0f440830..c117c092a97 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -26,10 +26,10 @@ from . import ( _2fa_rest, _auth_rest, + _change_service, _confirmation_rest, _registration_rest, _registration_rest_2, - handlers_change, ) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY from .settings import ( @@ -142,7 +142,7 @@ def setup_login(app: web.Application): app.router.add_routes(_confirmation_rest.routes) app.router.add_routes(_registration_rest_2.routes) app.router.add_routes(_registration_rest.routes) - app.router.add_routes(handlers_change.routes) + app.router.add_routes(_change_service.routes) app.router.add_routes(_2fa_rest.routes) _setup_login_options(app) From 5bba413ef0617505bc300f8ff24df6ff99b313ee Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:56:29 +0100 Subject: [PATCH 13/39] login repo legacy --- .../pytest_simcore/helpers/webserver_login.py | 5 +- .../login/_2fa_rest.py | 2 +- .../login/_auth_rest.py | 2 +- .../login/_auth_service.py | 2 +- .../login/_change_service.py | 2 +- .../login/_confirmation_rest.py | 6 +- .../login/_confirmation_service.py | 6 +- .../login/_invitations_service.py | 6 +- ...storage.py => _login_repository_legacy.py} | 0 .../login/_registration_rest_2.py | 6 +- .../login/handlers_confirmation.py | 319 +++++++++++++ .../login/handlers_registration.py | 437 ++++++++++++++++++ .../login/login_repository_legacy.py | 8 + .../simcore_service_webserver/login/plugin.py | 2 +- .../publications/_rest.py | 2 +- .../studies_dispatcher/_users.py | 14 +- .../server/tests/unit/with_dbs/01/test_db.py | 5 +- .../tests/unit/with_dbs/03/login/conftest.py | 5 +- .../unit/with_dbs/03/login/test_login_2fa.py | 2 +- .../with_dbs/03/login/test_login_logout.py | 2 +- .../03/login/test_login_registration.py | 2 +- .../03/login/test_login_reset_password.py | 4 +- 22 files changed, 814 insertions(+), 25 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{storage.py => _login_repository_legacy.py} (100%) create mode 100644 services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py create mode 100644 services/web/server/src/simcore_service_webserver/login/handlers_registration.py create 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 ac9cdac0a4c..d055e3a110c 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py @@ -12,7 +12,10 @@ from simcore_service_webserver.groups.api import auto_add_user_to_product_group from simcore_service_webserver.login._constants import MSG_LOGGED_IN from simcore_service_webserver.login._invitations_service import create_invitation_token -from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage +from simcore_service_webserver.login._login_repository_legacy import ( + AsyncpgStorage, + get_plugin_storage, +) from simcore_service_webserver.products.products_service import list_products from simcore_service_webserver.security.api import clean_auth_policy_cache from yarl import URL diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py b/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py index efdb3d42581..a363a64e55c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py @@ -20,10 +20,10 @@ MSG_EMAIL_SENT, MSG_UNKNOWN_EMAIL, ) +from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage from ._models import InputSchema from .errors import handle_login_exceptions from .settings import LoginSettingsForProduct, get_plugin_settings -from .storage import AsyncpgStorage, get_plugin_storage from .utils import envelope_response _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py index 52bed89e011..157968a371a 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py @@ -37,11 +37,11 @@ MSG_WRONG_2FA_CODE__EXPIRED, MSG_WRONG_2FA_CODE__INVALID, ) +from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage from ._models import InputSchema from .decorators import login_required from .errors import handle_login_exceptions from .settings import LoginSettingsForProduct, get_plugin_settings -from .storage import AsyncpgStorage, get_plugin_storage from .utils import envelope_response, flash_response, notify_user_logout log = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_service.py b/services/web/server/src/simcore_service_webserver/login/_auth_service.py index 5e00ae0b9e6..4dd7d724e18 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_service.py @@ -11,7 +11,7 @@ from ..products.models import Product from ..security.api import check_password, encrypt_password from ._constants import MSG_UNKNOWN_EMAIL, MSG_WRONG_PASSWORD -from .storage import AsyncpgStorage, get_plugin_storage +from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage from .utils import validate_user_status diff --git a/services/web/server/src/simcore_service_webserver/login/_change_service.py b/services/web/server/src/simcore_service_webserver/login/_change_service.py index 968a5500e1e..4e05098fd51 100644 --- a/services/web/server/src/simcore_service_webserver/login/_change_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_change_service.py @@ -27,10 +27,10 @@ MSG_PASSWORD_CHANGED, MSG_WRONG_PASSWORD, ) +from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage from ._models import InputSchema, create_password_match_validator from .decorators import login_required from .settings import LoginOptions, get_plugin_options -from .storage import AsyncpgStorage, get_plugin_storage from .utils import ( ACTIVE, CHANGE_EMAIL, diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py index 7bc83fe2148..5ca7351dccb 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py @@ -40,6 +40,11 @@ MSG_UNAUTHORIZED_PHONE_CONFIRMATION, ) from ._invitations_service import InvitationData +from ._login_repository_legacy import ( + AsyncpgStorage, + ConfirmationTokenDict, + get_plugin_storage, +) from ._models import InputSchema, check_confirm_password_match from .settings import ( LoginOptions, @@ -47,7 +52,6 @@ get_plugin_options, get_plugin_settings, ) -from .storage import AsyncpgStorage, ConfirmationTokenDict, get_plugin_storage from .utils import ( ACTIVE, CHANGE_EMAIL, 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 386629a7482..e4c40b16ef5 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 @@ -14,8 +14,12 @@ from models_library.users import UserID from yarl import URL +from ._login_repository_legacy import ( + ActionLiteralStr, + AsyncpgStorage, + ConfirmationTokenDict, +) from .settings import LoginOptions -from .storage import ActionLiteralStr, AsyncpgStorage, ConfirmationTokenDict log = logging.getLogger(__name__) 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 122df14bc56..eefd949d2e8 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 @@ -46,8 +46,12 @@ MSG_INVITATIONS_CONTACT_SUFFIX, MSG_USER_DISABLED, ) +from ._login_repository_legacy import ( + AsyncpgStorage, + BaseConfirmationTokenDict, + ConfirmationTokenDict, +) from .settings import LoginOptions -from .storage import AsyncpgStorage, BaseConfirmationTokenDict, ConfirmationTokenDict _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/storage.py b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/storage.py rename to services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py index bdeb3eca5a8..e2671488a9c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py @@ -47,6 +47,11 @@ check_other_registrations, extract_email_from_invitation, ) +from ._login_repository_legacy import ( + AsyncpgStorage, + ConfirmationTokenDict, + get_plugin_storage, +) from ._models import InputSchema, check_confirm_password_match from .settings import ( LoginOptions, @@ -54,7 +59,6 @@ get_plugin_options, get_plugin_settings, ) -from .storage import AsyncpgStorage, ConfirmationTokenDict, get_plugin_storage from .utils import ( envelope_response, flash_response, diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py new file mode 100644 index 00000000000..5ca7351dccb --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py @@ -0,0 +1,319 @@ +import logging +from contextlib import suppress +from json import JSONDecodeError + +from aiohttp import web +from aiohttp.web import RouteTableDef +from common_library.error_codes import create_error_code +from models_library.emails import LowerCaseEmailStr +from models_library.products import ProductName +from pydantic import ( + BaseModel, + Field, + PositiveInt, + SecretStr, + TypeAdapter, + ValidationError, + field_validator, +) +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) +from servicelib.logging_errors import create_troubleshotting_log_kwargs +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from simcore_postgres_database.aiopg_errors import UniqueViolation +from yarl import URL + +from ..products import products_web +from ..products.models import Product +from ..security.api import encrypt_password +from ..session.access_policies import session_access_required +from ..utils import HOUR, MINUTE +from ..utils_aiohttp import create_redirect_to_page_response +from ..utils_rate_limiting import global_rate_limit_route +from . import _2fa_service, _confirmation_service, _security_service +from ._constants import ( + MSG_PASSWORD_CHANGE_NOT_ALLOWED, + MSG_PASSWORD_CHANGED, + MSG_UNAUTHORIZED_PHONE_CONFIRMATION, +) +from ._invitations_service import InvitationData +from ._login_repository_legacy import ( + AsyncpgStorage, + ConfirmationTokenDict, + get_plugin_storage, +) +from ._models import InputSchema, check_confirm_password_match +from .settings import ( + LoginOptions, + LoginSettingsForProduct, + get_plugin_options, + get_plugin_settings, +) +from .utils import ( + ACTIVE, + CHANGE_EMAIL, + REGISTRATION, + RESET_PASSWORD, + flash_response, + notify_user_confirmation, +) + +_logger = logging.getLogger(__name__) + + +routes = RouteTableDef() + + +class _PathParam(BaseModel): + code: SecretStr + + +def _parse_extra_credits_in_usd_or_none( + confirmation: ConfirmationTokenDict, +) -> PositiveInt | None: + with suppress(ValidationError, JSONDecodeError): + confirmation_data = confirmation.get("data", "EMPTY") or "EMPTY" + invitation = InvitationData.model_validate_json(confirmation_data) + return invitation.extra_credits_in_usd + return None + + +async def _handle_confirm_registration( + app: web.Application, + product_name: ProductName, + confirmation: ConfirmationTokenDict, +): + db: AsyncpgStorage = get_plugin_storage(app) + user_id = confirmation["user_id"] + + # activate user and consume confirmation token + await db.delete_confirmation_and_update_user( + 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), + ) + + +async def _handle_confirm_change_email( + app: web.Application, confirmation: ConfirmationTokenDict +): + db: AsyncpgStorage = get_plugin_storage(app) + user_id = confirmation["user_id"] + + # update and consume confirmation token + await db.delete_confirmation_and_update_user( + user_id=user_id, + updates={ + "email": TypeAdapter(LowerCaseEmailStr).validate_python( + confirmation["data"] + ) + }, + confirmation=confirmation, + ) + + +@routes.get("/v0/auth/confirmation/{code}", name="auth_confirmation") +async def validate_confirmation_and_redirect(request: web.Request): + """Handles email confirmation by checking a code passed as query parameter + + Retrieves confirmation key and redirects back to some location front-end + + * registration, change-email: + - sets user as active + - redirects to login + * reset-password: + - redirects to login + - attaches page and token info onto the url + - info appended as fragment, e.g. https://osparc.io#reset-password?code=131234 + - front-end should interpret that info as: + - 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) + + path_params = parse_request_path_parameters_as(_PathParam, request) + + confirmation: ConfirmationTokenDict | None = ( + await _confirmation_service.validate_confirmation_code( + path_params.code.get_secret_value(), + db=db, + cfg=cfg, + ) + ) + + redirect_to_login_url = URL(cfg.LOGIN_REDIRECT) + if confirmation and (action := confirmation["action"]): + try: + if action == REGISTRATION: + await _handle_confirm_registration( + app=request.app, + product_name=product.name, + confirmation=confirmation, + ) + redirect_to_login_url = redirect_to_login_url.with_fragment( + "?registered=true" + ) + + elif action == CHANGE_EMAIL: + await _handle_confirm_change_email( + app=request.app, confirmation=confirmation + ) + + elif action == RESET_PASSWORD: + # + # NOTE: By using fragments (instead of queries or path parameters), + # the browser does NOT reloads page + # + redirect_to_login_url = redirect_to_login_url.with_fragment( + f"reset-password?code={path_params.code.get_secret_value()}" + ) + + _logger.info( + "Confirms %s %s -> %s", + action.upper(), + f"{confirmation=}", + f"{redirect_to_login_url=}", + ) + + except Exception as err: # pylint: disable=broad-except + error_code = create_error_code(err) + user_error_msg = ( + f"Sorry, we cannot confirm your {action}." + "Please try again in a few moments." + ) + + _logger.exception( + **create_troubleshotting_log_kwargs( + user_error_msg, + error=err, + error_code=error_code, + tip="Failed during email_confirmation", + ) + ) + + raise create_redirect_to_page_response( + request.app, + page="error", + message=user_error_msg, + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) from err + + raise web.HTTPFound(location=redirect_to_login_url) + + +class PhoneConfirmationBody(InputSchema): + email: LowerCaseEmailStr + phone: str = Field( + ..., description="Phone number E.164, needed on the deployments with 2FA" + ) + code: SecretStr + + +@routes.post("/v0/auth/validate-code-register", name="auth_phone_confirmation") +@global_rate_limit_route(number_of_requests=5, interval_seconds=MINUTE) +@session_access_required( + name="auth_phone_confirmation", + unauthorized_reason=MSG_UNAUTHORIZED_PHONE_CONFIRMATION, +) +async def phone_confirmation(request: web.Request): + product: Product = products_web.get_current_product(request) + settings: LoginSettingsForProduct = get_plugin_settings( + request.app, product_name=product.name + ) + + db: AsyncpgStorage = get_plugin_storage(request.app) + + if not settings.LOGIN_2FA_REQUIRED: + raise web.HTTPServiceUnavailable( + reason="Phone registration is not available", + content_type=MIMETYPE_APPLICATION_JSON, + ) + + request_body = await parse_request_body_as(PhoneConfirmationBody, request) + + if ( + expected := await _2fa_service.get_2fa_code(request.app, request_body.email) + ) and request_body.code.get_secret_value() == expected: + # consumes code + await _2fa_service.delete_2fa_code(request.app, request_body.email) + + # updates confirmed phone number + try: + user = await db.get_user({"email": request_body.email}) + assert user is not None # nosec + await db.update_user(dict(user), {"phone": request_body.phone}) + + except UniqueViolation as err: + raise web.HTTPUnauthorized( + reason="Invalid phone number", + content_type=MIMETYPE_APPLICATION_JSON, + ) from err + + return await _security_service.login_granted_response(request, user=dict(user)) + + # fails because of invalid or no code + raise web.HTTPUnauthorized( + reason="Invalid 2FA code", content_type=MIMETYPE_APPLICATION_JSON + ) + + +class ResetPasswordConfirmation(InputSchema): + password: SecretStr + confirm: SecretStr + + _password_confirm_match = field_validator("confirm")(check_confirm_password_match) + + +@routes.post("/v0/auth/reset-password/{code}", name="complete_reset_password") +@global_rate_limit_route(number_of_requests=10, interval_seconds=HOUR) +async def complete_reset_password(request: web.Request): + """Last of the "Two-Step Action Confirmation pattern": initiate_reset_password + complete_reset_password(code) + + - 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) + + path_params = parse_request_path_parameters_as(_PathParam, 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 + ) + + if confirmation: + user = await db.get_user({"id": confirmation["user_id"]}) + assert user # nosec + + await db.update_user( + user={"id": user["id"]}, + updates={ + "password_hash": encrypt_password( + request_body.password.get_secret_value() + ) + }, + ) + await db.delete_confirmation(confirmation) + + return flash_response(MSG_PASSWORD_CHANGED) + + raise web.HTTPUnauthorized( + reason=MSG_PASSWORD_CHANGE_NOT_ALLOWED.format( + support_email=product.support_email + ), + content_type=MIMETYPE_APPLICATION_JSON, + ) # 401 diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py new file mode 100644 index 00000000000..e2671488a9c --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -0,0 +1,437 @@ +import logging +from datetime import UTC, datetime, timedelta +from typing import Literal + +from aiohttp import web +from aiohttp.web import RouteTableDef +from common_library.error_codes import create_error_code +from models_library.emails import LowerCaseEmailStr +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PositiveInt, + SecretStr, + field_validator, +) +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import parse_request_body_as +from servicelib.logging_errors import create_troubleshotting_log_kwargs +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from simcore_postgres_database.models.users import UserStatus + +from .._meta import API_VTAG +from ..groups.api import auto_add_user_to_groups, auto_add_user_to_product_group +from ..invitations.api import is_service_invitation_code +from ..products import products_web +from ..products.models import Product +from ..session.access_policies import ( + on_success_grant_session_access_to, + session_access_required, +) +from ..utils import MINUTE +from ..utils_aiohttp import NextPage, envelope_json_response +from ..utils_rate_limiting import global_rate_limit_route +from . import _2fa_service, _auth_service, _confirmation_service, _security_service +from ._constants import ( + CODE_2FA_SMS_CODE_REQUIRED, + MAX_2FA_CODE_RESEND, + MAX_2FA_CODE_TRIALS, + MSG_2FA_CODE_SENT, + MSG_CANT_SEND_MAIL, + MSG_UNAUTHORIZED_REGISTER_PHONE, + MSG_WEAK_PASSWORD, +) +from ._invitations_service import ( + check_and_consume_invitation, + check_other_registrations, + extract_email_from_invitation, +) +from ._login_repository_legacy import ( + AsyncpgStorage, + ConfirmationTokenDict, + get_plugin_storage, +) +from ._models import InputSchema, check_confirm_password_match +from .settings import ( + LoginOptions, + LoginSettingsForProduct, + get_plugin_options, + get_plugin_settings, +) +from .utils import ( + envelope_response, + flash_response, + get_user_name_from_email, + notify_user_confirmation, +) +from .utils_email import get_template_path, send_email_from_template + +_logger = logging.getLogger(__name__) + + +routes = RouteTableDef() + + +class InvitationCheck(InputSchema): + invitation: str = Field(..., description="Invitation code") + + +class InvitationInfo(InputSchema): + email: LowerCaseEmailStr | None = Field( + None, description="Email associated to invitation or None" + ) + + +@routes.post( + f"/{API_VTAG}/auth/register/invitations:check", + name="auth_check_registration_invitation", +) +@global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) +async def check_registration_invitation(request: web.Request): + """ + Decrypts invitation and extracts associated email or + returns None if is not an encrypted invitation (might be a database invitation). + + raises HTTPForbidden, HTTPServiceUnavailable + """ + product: Product = products_web.get_current_product(request) + settings: LoginSettingsForProduct = get_plugin_settings( + request.app, product_name=product.name + ) + + # disabled -> None + if not settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: + return envelope_json_response(InvitationInfo(email=None)) + + # non-encrypted -> None + # NOTE: that None is given if the code is the old type (and does not fail) + check = await parse_request_body_as(InvitationCheck, request) + if not is_service_invitation_code(code=check.invitation): + return envelope_json_response(InvitationInfo(email=None)) + + # extracted -> email + email = await extract_email_from_invitation( + request.app, invitation_code=check.invitation + ) + return envelope_json_response(InvitationInfo(email=email)) + + +class RegisterBody(InputSchema): + email: LowerCaseEmailStr + password: SecretStr + confirm: SecretStr | None = Field(None, description="Password confirmation") + invitation: str | None = Field(None, description="Invitation code") + + _password_confirm_match = field_validator("confirm")(check_confirm_password_match) + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "email": "foo@mymail.com", + "password": "my secret", # NOSONAR + "confirm": "my secret", # optional + "invitation": "33c451d4-17b7-4e65-9880-694559b8ffc2", # optional only active + } + ] + } + ) + + +@routes.post(f"/{API_VTAG}/auth/register", name="auth_register") +async def register(request: web.Request): + """ + Starts user's registration by providing an email, password and + invitation code (required by configuration). + + An email with a link to 'email_confirmation' is sent to complete registration + """ + product: Product = products_web.get_current_product(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 + ) + + # Check for weak passwords + # This should strictly happen before invitation links are checked and consumed + # So the invitation can be re-used with a stronger password. + if ( + len(registration.password.get_secret_value()) + < settings.LOGIN_PASSWORD_MIN_LENGTH + ): + raise web.HTTPUnauthorized( + reason=MSG_WEAK_PASSWORD.format( + LOGIN_PASSWORD_MIN_LENGTH=settings.LOGIN_PASSWORD_MIN_LENGTH + ), + content_type=MIMETYPE_APPLICATION_JSON, + ) + + # INVITATIONS + expires_at: datetime | None = None # = does not expire + invitation = None + # There are 3 possible states for an invitation: + # 1. Invitation is not required (i.e. the app has disabled invitations) + # 2. Invitation is invalid + # 3. Invitation is valid + # + # For those states the `invitation` variable get the following values + # 1. `None + # 2. no value, it raises and exception + # 3. gets `InvitationData` + # ` + # In addition, for 3. there are two types of invitations: + # 1. the invitation generated by the `invitation` service (new). + # 2. the invitation created by hand in the db confirmation table (deprecated). This + # one does not understand products. + # + if settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: + # Only requests with INVITATION can register user + # to either a permanent or to a trial account + invitation_code = registration.invitation + if invitation_code is None: + raise web.HTTPBadRequest( + reason="invitation field is required", + content_type=MIMETYPE_APPLICATION_JSON, + ) + + invitation = await check_and_consume_invitation( + invitation_code, + product=product, + guest_email=registration.email, + db=db, + cfg=cfg, + app=request.app, + ) + if invitation.trial_account_days: + expires_at = datetime.now(UTC) + timedelta(invitation.trial_account_days) + + # get authorized user or create new + user = await _auth_service.get_user_by_email(request.app, email=registration.email) + if user: + await _auth_service.check_authorized_user_credentials_or_raise( + user, + password=registration.password.get_secret_value(), + product=product, + ) + else: + user = await _auth_service.create_user( + request.app, + email=registration.email, + password=registration.password.get_secret_value(), + status_upon_creation=( + UserStatus.CONFIRMATION_PENDING + if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED + else UserStatus.ACTIVE + ), + expires_at=expires_at, + ) + + # setup user groups + assert ( # nosec + product.name == invitation.product + if invitation and invitation.product + else True + ) + + await auto_add_user_to_groups(app=request.app, user_id=user["id"]) + await auto_add_user_to_product_group( + app=request.app, + user_id=user["id"], + product_name=product.name, + ) + + if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: + # Confirmation required: send confirmation email + _confirmation: ConfirmationTokenDict = await db.create_confirmation( + user_id=user["id"], + action="REGISTRATION", + data=invitation.model_dump_json() if invitation else None, + ) + + try: + email_confirmation_url = _confirmation_service.make_confirmation_link( + request, _confirmation + ) + email_template_path = await get_template_path( + request, "registration_email.jinja2" + ) + await send_email_from_template( + request, + from_=product.support_email, + to=registration.email, + template=email_template_path, + context={ + "host": request.host, + "link": email_confirmation_url, # SEE email_confirmation handler (action=REGISTRATION) + "name": user.get("first_name") or user["name"], + "support_email": product.support_email, + "product": product, + }, + ) + except Exception as err: # pylint: disable=broad-except + error_code = create_error_code(err) + user_error_msg = MSG_CANT_SEND_MAIL + + _logger.exception( + **create_troubleshotting_log_kwargs( + user_error_msg, + error=err, + error_code=error_code, + error_context={ + "request": request, + "registration": registration, + "user_id": user.get("id"), + "user": user, + "confirmation": _confirmation, + }, + tip="Failed while sending confirmation email", + ) + ) + + await db.delete_confirmation_and_user(user, _confirmation) + + raise web.HTTPServiceUnavailable(reason=user_error_msg) from err + + return flash_response( + "You are registered successfully! To activate your account, please, " + f"click on the verification link in the email we sent you to {registration.email}.", + "INFO", + ) + + # NOTE: Here confirmation is disabled + assert settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED is False # nosec + assert ( # nosec + product.name == invitation.product + if invitation and invitation.product + else True + ) + + await notify_user_confirmation( + request.app, + user_id=user["id"], + product_name=product.name, + extra_credits_in_usd=invitation.extra_credits_in_usd if invitation else None, + ) + + # No confirmation required: authorize login + assert not settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED # nosec + assert not settings.LOGIN_2FA_REQUIRED # nosec + + return await _security_service.login_granted_response(request=request, user=user) + + +class RegisterPhoneBody(InputSchema): + email: LowerCaseEmailStr + phone: str = Field( + ..., description="Phone number E.164, needed on the deployments with 2FA" + ) + + +class _PageParams(BaseModel): + expiration_2fa: PositiveInt | None = None + + +class RegisterPhoneNextPage(NextPage[_PageParams]): + logger: str = Field("user", deprecated=True) + level: Literal["INFO", "WARNING", "ERROR"] = "INFO" + message: str + + +@routes.post(f"/{API_VTAG}/auth/verify-phone-number", name="auth_register_phone") +@session_access_required( + name="auth_register_phone", + unauthorized_reason=MSG_UNAUTHORIZED_REGISTER_PHONE, +) +@on_success_grant_session_access_to( + name="auth_phone_confirmation", + max_access_count=MAX_2FA_CODE_TRIALS, +) +@on_success_grant_session_access_to( + name="auth_resend_2fa_code", + max_access_count=MAX_2FA_CODE_RESEND, +) +async def register_phone(request: web.Request): + """ + Submits phone registration + - sends a code + - registration is completed requesting to 'phone_confirmation' route with the code received + """ + product: Product = products_web.get_current_product(request) + settings: LoginSettingsForProduct = get_plugin_settings( + request.app, product_name=product.name + ) + + if not settings.LOGIN_2FA_REQUIRED: + raise web.HTTPServiceUnavailable( + reason="Phone registration is not available", + content_type=MIMETYPE_APPLICATION_JSON, + ) + + registration = await parse_request_body_as(RegisterPhoneBody, request) + + try: + assert settings.LOGIN_2FA_REQUIRED + assert settings.LOGIN_TWILIO + if not product.twilio_messaging_sid: + msg = f"Messaging SID is not configured in {product}. Update product's twilio_messaging_sid in database." + raise ValueError(msg) + + code = await _2fa_service.create_2fa_code( + app=request.app, + user_email=registration.email, + expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, + ) + await _2fa_service.send_sms_code( + phone_number=registration.phone, + code=code, + twilio_auth=settings.LOGIN_TWILIO, + twilio_messaging_sid=product.twilio_messaging_sid, + twilio_alpha_numeric_sender=product.twilio_alpha_numeric_sender_id, + first_name=get_user_name_from_email(registration.email), + ) + + return envelope_response( + # RegisterPhoneNextPage + data={ + "name": CODE_2FA_SMS_CODE_REQUIRED, + "parameters": { + "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, + }, + "message": MSG_2FA_CODE_SENT.format( + phone_number=mask_phone_number(registration.phone) + ), + "level": "INFO", + "logger": "user", + }, + status=status.HTTP_202_ACCEPTED, + ) + + except web.HTTPException: + raise + + except Exception as err: # pylint: disable=broad-except + # Unhandled errors -> 503 + error_code = create_error_code(err) + user_error_msg = "Currently we cannot register phone numbers" + + _logger.exception( + **create_troubleshotting_log_kwargs( + user_error_msg, + error=err, + error_code=error_code, + error_context={"request": request, "registration": registration}, + tip="Phone registration failed", + ) + ) + + raise web.HTTPServiceUnavailable( + reason=user_error_msg, + content_type=MIMETYPE_APPLICATION_JSON, + ) from err 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 new file mode 100644 index 00000000000..778a99c2175 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/login_repository_legacy.py @@ -0,0 +1,8 @@ +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 c117c092a97..cc5a35d3b28 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -32,13 +32,13 @@ _registration_rest_2, ) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY +from ._login_repository_legacy import APP_LOGIN_STORAGE_KEY, AsyncpgStorage from .settings import ( APP_LOGIN_OPTIONS_KEY, LoginOptions, LoginSettings, LoginSettingsForProduct, ) -from .storage import APP_LOGIN_STORAGE_KEY, AsyncpgStorage log = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/publications/_rest.py b/services/web/server/src/simcore_service_webserver/publications/_rest.py index 35ccbac61a5..330905f94b3 100644 --- a/services/web/server/src/simcore_service_webserver/publications/_rest.py +++ b/services/web/server/src/simcore_service_webserver/publications/_rest.py @@ -11,7 +11,7 @@ from .._meta import API_VTAG as VTAG from ..login.decorators import login_required -from ..login.storage import AsyncpgStorage, get_plugin_storage +from ..login.login_repository_legacy import AsyncpgStorage, get_plugin_storage from ..login.utils_email import AttachmentTuple, send_email_from_template, themed from ..products import products_web from ._utils import json2html diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py index ea7b8fecf6c..926d614079f 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py @@ -1,10 +1,10 @@ -""" Users management +"""Users management - Keeps functionality that couples with the following app modules - - users, - - login - - security - - resource_manager +Keeps functionality that couples with the following app modules + - users, + - login + - security + - resource_manager """ @@ -27,7 +27,7 @@ from ..garbage_collector.settings import GUEST_USER_RC_LOCK_FORMAT from ..groups.api import auto_add_user_to_product_group -from ..login.storage import AsyncpgStorage, get_plugin_storage +from ..login.login_repository_legacy import AsyncpgStorage, get_plugin_storage from ..login.utils import ACTIVE, GUEST from ..products import products_web from ..redis import get_redis_lock_manager_client 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 a9204b460bf..715f9426d2b 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 @@ -19,7 +19,10 @@ is_service_enabled, is_service_responsive, ) -from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage +from simcore_service_webserver.login._login_repository_legacy import ( + AsyncpgStorage, + get_plugin_storage, +) from sqlalchemy.ext.asyncio import AsyncEngine 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 c0eaf628d2e..165ef39c0a4 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 @@ -15,8 +15,11 @@ from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from simcore_postgres_database.models.users import users from simcore_postgres_database.models.wallets import wallets +from simcore_service_webserver.login._login_repository_legacy import ( + AsyncpgStorage, + get_plugin_storage, +) from simcore_service_webserver.login.settings import LoginOptions, get_plugin_options -from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage @pytest.fixture diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 37b6a49aed5..6144f489ea0 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -34,7 +34,7 @@ CODE_2FA_SMS_CODE_REQUIRED, MSG_2FA_UNAVAILABLE, ) -from simcore_service_webserver.login.storage import AsyncpgStorage +from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage from simcore_service_webserver.products import products_web from simcore_service_webserver.products.errors import UnknownProductError from simcore_service_webserver.products.models import Product 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 910a7cb0b1b..13aa95c32e4 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,7 +6,7 @@ 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.storage import AsyncpgStorage +from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage async def test_logout(client: TestClient, db: AsyncpgStorage): 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 cfc88456ffc..cb370a87b80 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 @@ -26,12 +26,12 @@ MSG_PASSWORD_MISMATCH, MSG_WEAK_PASSWORD, ) +from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage from simcore_service_webserver.login.settings import ( LoginOptions, LoginSettingsForProduct, get_plugin_settings, ) -from simcore_service_webserver.login.storage import AsyncpgStorage @pytest.fixture 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 ea121adb288..cf9b95f78ee 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 @@ -27,11 +27,11 @@ MSG_USER_BANNED, MSG_USER_EXPIRED, ) -from simcore_service_webserver.login.settings import LoginOptions -from simcore_service_webserver.login.storage import ( +from simcore_service_webserver.login._login_repository_legacy import ( AsyncpgStorage, ConfirmationTokenDict, ) +from simcore_service_webserver.login.settings import LoginOptions from simcore_service_webserver.users import api as users_service from yarl import URL From cd8957358df0a69a334a7e9b21cb1331ae6fa332 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:57:21 +0100 Subject: [PATCH 14/39] missing --- .../login/handlers_confirmation.py | 319 ------------- .../login/handlers_registration.py | 437 ------------------ 2 files changed, 756 deletions(-) delete mode 100644 services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py delete mode 100644 services/web/server/src/simcore_service_webserver/login/handlers_registration.py diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py deleted file mode 100644 index 5ca7351dccb..00000000000 --- a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py +++ /dev/null @@ -1,319 +0,0 @@ -import logging -from contextlib import suppress -from json import JSONDecodeError - -from aiohttp import web -from aiohttp.web import RouteTableDef -from common_library.error_codes import create_error_code -from models_library.emails import LowerCaseEmailStr -from models_library.products import ProductName -from pydantic import ( - BaseModel, - Field, - PositiveInt, - SecretStr, - TypeAdapter, - ValidationError, - field_validator, -) -from servicelib.aiohttp import status -from servicelib.aiohttp.requests_validation import ( - parse_request_body_as, - parse_request_path_parameters_as, -) -from servicelib.logging_errors import create_troubleshotting_log_kwargs -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from simcore_postgres_database.aiopg_errors import UniqueViolation -from yarl import URL - -from ..products import products_web -from ..products.models import Product -from ..security.api import encrypt_password -from ..session.access_policies import session_access_required -from ..utils import HOUR, MINUTE -from ..utils_aiohttp import create_redirect_to_page_response -from ..utils_rate_limiting import global_rate_limit_route -from . import _2fa_service, _confirmation_service, _security_service -from ._constants import ( - MSG_PASSWORD_CHANGE_NOT_ALLOWED, - MSG_PASSWORD_CHANGED, - MSG_UNAUTHORIZED_PHONE_CONFIRMATION, -) -from ._invitations_service import InvitationData -from ._login_repository_legacy import ( - AsyncpgStorage, - ConfirmationTokenDict, - get_plugin_storage, -) -from ._models import InputSchema, check_confirm_password_match -from .settings import ( - LoginOptions, - LoginSettingsForProduct, - get_plugin_options, - get_plugin_settings, -) -from .utils import ( - ACTIVE, - CHANGE_EMAIL, - REGISTRATION, - RESET_PASSWORD, - flash_response, - notify_user_confirmation, -) - -_logger = logging.getLogger(__name__) - - -routes = RouteTableDef() - - -class _PathParam(BaseModel): - code: SecretStr - - -def _parse_extra_credits_in_usd_or_none( - confirmation: ConfirmationTokenDict, -) -> PositiveInt | None: - with suppress(ValidationError, JSONDecodeError): - confirmation_data = confirmation.get("data", "EMPTY") or "EMPTY" - invitation = InvitationData.model_validate_json(confirmation_data) - return invitation.extra_credits_in_usd - return None - - -async def _handle_confirm_registration( - app: web.Application, - product_name: ProductName, - confirmation: ConfirmationTokenDict, -): - db: AsyncpgStorage = get_plugin_storage(app) - user_id = confirmation["user_id"] - - # activate user and consume confirmation token - await db.delete_confirmation_and_update_user( - 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), - ) - - -async def _handle_confirm_change_email( - app: web.Application, confirmation: ConfirmationTokenDict -): - db: AsyncpgStorage = get_plugin_storage(app) - user_id = confirmation["user_id"] - - # update and consume confirmation token - await db.delete_confirmation_and_update_user( - user_id=user_id, - updates={ - "email": TypeAdapter(LowerCaseEmailStr).validate_python( - confirmation["data"] - ) - }, - confirmation=confirmation, - ) - - -@routes.get("/v0/auth/confirmation/{code}", name="auth_confirmation") -async def validate_confirmation_and_redirect(request: web.Request): - """Handles email confirmation by checking a code passed as query parameter - - Retrieves confirmation key and redirects back to some location front-end - - * registration, change-email: - - sets user as active - - redirects to login - * reset-password: - - redirects to login - - attaches page and token info onto the url - - info appended as fragment, e.g. https://osparc.io#reset-password?code=131234 - - front-end should interpret that info as: - - 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) - - path_params = parse_request_path_parameters_as(_PathParam, request) - - confirmation: ConfirmationTokenDict | None = ( - await _confirmation_service.validate_confirmation_code( - path_params.code.get_secret_value(), - db=db, - cfg=cfg, - ) - ) - - redirect_to_login_url = URL(cfg.LOGIN_REDIRECT) - if confirmation and (action := confirmation["action"]): - try: - if action == REGISTRATION: - await _handle_confirm_registration( - app=request.app, - product_name=product.name, - confirmation=confirmation, - ) - redirect_to_login_url = redirect_to_login_url.with_fragment( - "?registered=true" - ) - - elif action == CHANGE_EMAIL: - await _handle_confirm_change_email( - app=request.app, confirmation=confirmation - ) - - elif action == RESET_PASSWORD: - # - # NOTE: By using fragments (instead of queries or path parameters), - # the browser does NOT reloads page - # - redirect_to_login_url = redirect_to_login_url.with_fragment( - f"reset-password?code={path_params.code.get_secret_value()}" - ) - - _logger.info( - "Confirms %s %s -> %s", - action.upper(), - f"{confirmation=}", - f"{redirect_to_login_url=}", - ) - - except Exception as err: # pylint: disable=broad-except - error_code = create_error_code(err) - user_error_msg = ( - f"Sorry, we cannot confirm your {action}." - "Please try again in a few moments." - ) - - _logger.exception( - **create_troubleshotting_log_kwargs( - user_error_msg, - error=err, - error_code=error_code, - tip="Failed during email_confirmation", - ) - ) - - raise create_redirect_to_page_response( - request.app, - page="error", - message=user_error_msg, - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - ) from err - - raise web.HTTPFound(location=redirect_to_login_url) - - -class PhoneConfirmationBody(InputSchema): - email: LowerCaseEmailStr - phone: str = Field( - ..., description="Phone number E.164, needed on the deployments with 2FA" - ) - code: SecretStr - - -@routes.post("/v0/auth/validate-code-register", name="auth_phone_confirmation") -@global_rate_limit_route(number_of_requests=5, interval_seconds=MINUTE) -@session_access_required( - name="auth_phone_confirmation", - unauthorized_reason=MSG_UNAUTHORIZED_PHONE_CONFIRMATION, -) -async def phone_confirmation(request: web.Request): - product: Product = products_web.get_current_product(request) - settings: LoginSettingsForProduct = get_plugin_settings( - request.app, product_name=product.name - ) - - db: AsyncpgStorage = get_plugin_storage(request.app) - - if not settings.LOGIN_2FA_REQUIRED: - raise web.HTTPServiceUnavailable( - reason="Phone registration is not available", - content_type=MIMETYPE_APPLICATION_JSON, - ) - - request_body = await parse_request_body_as(PhoneConfirmationBody, request) - - if ( - expected := await _2fa_service.get_2fa_code(request.app, request_body.email) - ) and request_body.code.get_secret_value() == expected: - # consumes code - await _2fa_service.delete_2fa_code(request.app, request_body.email) - - # updates confirmed phone number - try: - user = await db.get_user({"email": request_body.email}) - assert user is not None # nosec - await db.update_user(dict(user), {"phone": request_body.phone}) - - except UniqueViolation as err: - raise web.HTTPUnauthorized( - reason="Invalid phone number", - content_type=MIMETYPE_APPLICATION_JSON, - ) from err - - return await _security_service.login_granted_response(request, user=dict(user)) - - # fails because of invalid or no code - raise web.HTTPUnauthorized( - reason="Invalid 2FA code", content_type=MIMETYPE_APPLICATION_JSON - ) - - -class ResetPasswordConfirmation(InputSchema): - password: SecretStr - confirm: SecretStr - - _password_confirm_match = field_validator("confirm")(check_confirm_password_match) - - -@routes.post("/v0/auth/reset-password/{code}", name="complete_reset_password") -@global_rate_limit_route(number_of_requests=10, interval_seconds=HOUR) -async def complete_reset_password(request: web.Request): - """Last of the "Two-Step Action Confirmation pattern": initiate_reset_password + complete_reset_password(code) - - - 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) - - path_params = parse_request_path_parameters_as(_PathParam, 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 - ) - - if confirmation: - user = await db.get_user({"id": confirmation["user_id"]}) - assert user # nosec - - await db.update_user( - user={"id": user["id"]}, - updates={ - "password_hash": encrypt_password( - request_body.password.get_secret_value() - ) - }, - ) - await db.delete_confirmation(confirmation) - - return flash_response(MSG_PASSWORD_CHANGED) - - raise web.HTTPUnauthorized( - reason=MSG_PASSWORD_CHANGE_NOT_ALLOWED.format( - support_email=product.support_email - ), - content_type=MIMETYPE_APPLICATION_JSON, - ) # 401 diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py deleted file mode 100644 index e2671488a9c..00000000000 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ /dev/null @@ -1,437 +0,0 @@ -import logging -from datetime import UTC, datetime, timedelta -from typing import Literal - -from aiohttp import web -from aiohttp.web import RouteTableDef -from common_library.error_codes import create_error_code -from models_library.emails import LowerCaseEmailStr -from pydantic import ( - BaseModel, - ConfigDict, - Field, - PositiveInt, - SecretStr, - field_validator, -) -from servicelib.aiohttp import status -from servicelib.aiohttp.requests_validation import parse_request_body_as -from servicelib.logging_errors import create_troubleshotting_log_kwargs -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from simcore_postgres_database.models.users import UserStatus - -from .._meta import API_VTAG -from ..groups.api import auto_add_user_to_groups, auto_add_user_to_product_group -from ..invitations.api import is_service_invitation_code -from ..products import products_web -from ..products.models import Product -from ..session.access_policies import ( - on_success_grant_session_access_to, - session_access_required, -) -from ..utils import MINUTE -from ..utils_aiohttp import NextPage, envelope_json_response -from ..utils_rate_limiting import global_rate_limit_route -from . import _2fa_service, _auth_service, _confirmation_service, _security_service -from ._constants import ( - CODE_2FA_SMS_CODE_REQUIRED, - MAX_2FA_CODE_RESEND, - MAX_2FA_CODE_TRIALS, - MSG_2FA_CODE_SENT, - MSG_CANT_SEND_MAIL, - MSG_UNAUTHORIZED_REGISTER_PHONE, - MSG_WEAK_PASSWORD, -) -from ._invitations_service import ( - check_and_consume_invitation, - check_other_registrations, - extract_email_from_invitation, -) -from ._login_repository_legacy import ( - AsyncpgStorage, - ConfirmationTokenDict, - get_plugin_storage, -) -from ._models import InputSchema, check_confirm_password_match -from .settings import ( - LoginOptions, - LoginSettingsForProduct, - get_plugin_options, - get_plugin_settings, -) -from .utils import ( - envelope_response, - flash_response, - get_user_name_from_email, - notify_user_confirmation, -) -from .utils_email import get_template_path, send_email_from_template - -_logger = logging.getLogger(__name__) - - -routes = RouteTableDef() - - -class InvitationCheck(InputSchema): - invitation: str = Field(..., description="Invitation code") - - -class InvitationInfo(InputSchema): - email: LowerCaseEmailStr | None = Field( - None, description="Email associated to invitation or None" - ) - - -@routes.post( - f"/{API_VTAG}/auth/register/invitations:check", - name="auth_check_registration_invitation", -) -@global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) -async def check_registration_invitation(request: web.Request): - """ - Decrypts invitation and extracts associated email or - returns None if is not an encrypted invitation (might be a database invitation). - - raises HTTPForbidden, HTTPServiceUnavailable - """ - product: Product = products_web.get_current_product(request) - settings: LoginSettingsForProduct = get_plugin_settings( - request.app, product_name=product.name - ) - - # disabled -> None - if not settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: - return envelope_json_response(InvitationInfo(email=None)) - - # non-encrypted -> None - # NOTE: that None is given if the code is the old type (and does not fail) - check = await parse_request_body_as(InvitationCheck, request) - if not is_service_invitation_code(code=check.invitation): - return envelope_json_response(InvitationInfo(email=None)) - - # extracted -> email - email = await extract_email_from_invitation( - request.app, invitation_code=check.invitation - ) - return envelope_json_response(InvitationInfo(email=email)) - - -class RegisterBody(InputSchema): - email: LowerCaseEmailStr - password: SecretStr - confirm: SecretStr | None = Field(None, description="Password confirmation") - invitation: str | None = Field(None, description="Invitation code") - - _password_confirm_match = field_validator("confirm")(check_confirm_password_match) - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "email": "foo@mymail.com", - "password": "my secret", # NOSONAR - "confirm": "my secret", # optional - "invitation": "33c451d4-17b7-4e65-9880-694559b8ffc2", # optional only active - } - ] - } - ) - - -@routes.post(f"/{API_VTAG}/auth/register", name="auth_register") -async def register(request: web.Request): - """ - Starts user's registration by providing an email, password and - invitation code (required by configuration). - - An email with a link to 'email_confirmation' is sent to complete registration - """ - product: Product = products_web.get_current_product(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 - ) - - # Check for weak passwords - # This should strictly happen before invitation links are checked and consumed - # So the invitation can be re-used with a stronger password. - if ( - len(registration.password.get_secret_value()) - < settings.LOGIN_PASSWORD_MIN_LENGTH - ): - raise web.HTTPUnauthorized( - reason=MSG_WEAK_PASSWORD.format( - LOGIN_PASSWORD_MIN_LENGTH=settings.LOGIN_PASSWORD_MIN_LENGTH - ), - content_type=MIMETYPE_APPLICATION_JSON, - ) - - # INVITATIONS - expires_at: datetime | None = None # = does not expire - invitation = None - # There are 3 possible states for an invitation: - # 1. Invitation is not required (i.e. the app has disabled invitations) - # 2. Invitation is invalid - # 3. Invitation is valid - # - # For those states the `invitation` variable get the following values - # 1. `None - # 2. no value, it raises and exception - # 3. gets `InvitationData` - # ` - # In addition, for 3. there are two types of invitations: - # 1. the invitation generated by the `invitation` service (new). - # 2. the invitation created by hand in the db confirmation table (deprecated). This - # one does not understand products. - # - if settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: - # Only requests with INVITATION can register user - # to either a permanent or to a trial account - invitation_code = registration.invitation - if invitation_code is None: - raise web.HTTPBadRequest( - reason="invitation field is required", - content_type=MIMETYPE_APPLICATION_JSON, - ) - - invitation = await check_and_consume_invitation( - invitation_code, - product=product, - guest_email=registration.email, - db=db, - cfg=cfg, - app=request.app, - ) - if invitation.trial_account_days: - expires_at = datetime.now(UTC) + timedelta(invitation.trial_account_days) - - # get authorized user or create new - user = await _auth_service.get_user_by_email(request.app, email=registration.email) - if user: - await _auth_service.check_authorized_user_credentials_or_raise( - user, - password=registration.password.get_secret_value(), - product=product, - ) - else: - user = await _auth_service.create_user( - request.app, - email=registration.email, - password=registration.password.get_secret_value(), - status_upon_creation=( - UserStatus.CONFIRMATION_PENDING - if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED - else UserStatus.ACTIVE - ), - expires_at=expires_at, - ) - - # setup user groups - assert ( # nosec - product.name == invitation.product - if invitation and invitation.product - else True - ) - - await auto_add_user_to_groups(app=request.app, user_id=user["id"]) - await auto_add_user_to_product_group( - app=request.app, - user_id=user["id"], - product_name=product.name, - ) - - if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: - # Confirmation required: send confirmation email - _confirmation: ConfirmationTokenDict = await db.create_confirmation( - user_id=user["id"], - action="REGISTRATION", - data=invitation.model_dump_json() if invitation else None, - ) - - try: - email_confirmation_url = _confirmation_service.make_confirmation_link( - request, _confirmation - ) - email_template_path = await get_template_path( - request, "registration_email.jinja2" - ) - await send_email_from_template( - request, - from_=product.support_email, - to=registration.email, - template=email_template_path, - context={ - "host": request.host, - "link": email_confirmation_url, # SEE email_confirmation handler (action=REGISTRATION) - "name": user.get("first_name") or user["name"], - "support_email": product.support_email, - "product": product, - }, - ) - except Exception as err: # pylint: disable=broad-except - error_code = create_error_code(err) - user_error_msg = MSG_CANT_SEND_MAIL - - _logger.exception( - **create_troubleshotting_log_kwargs( - user_error_msg, - error=err, - error_code=error_code, - error_context={ - "request": request, - "registration": registration, - "user_id": user.get("id"), - "user": user, - "confirmation": _confirmation, - }, - tip="Failed while sending confirmation email", - ) - ) - - await db.delete_confirmation_and_user(user, _confirmation) - - raise web.HTTPServiceUnavailable(reason=user_error_msg) from err - - return flash_response( - "You are registered successfully! To activate your account, please, " - f"click on the verification link in the email we sent you to {registration.email}.", - "INFO", - ) - - # NOTE: Here confirmation is disabled - assert settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED is False # nosec - assert ( # nosec - product.name == invitation.product - if invitation and invitation.product - else True - ) - - await notify_user_confirmation( - request.app, - user_id=user["id"], - product_name=product.name, - extra_credits_in_usd=invitation.extra_credits_in_usd if invitation else None, - ) - - # No confirmation required: authorize login - assert not settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED # nosec - assert not settings.LOGIN_2FA_REQUIRED # nosec - - return await _security_service.login_granted_response(request=request, user=user) - - -class RegisterPhoneBody(InputSchema): - email: LowerCaseEmailStr - phone: str = Field( - ..., description="Phone number E.164, needed on the deployments with 2FA" - ) - - -class _PageParams(BaseModel): - expiration_2fa: PositiveInt | None = None - - -class RegisterPhoneNextPage(NextPage[_PageParams]): - logger: str = Field("user", deprecated=True) - level: Literal["INFO", "WARNING", "ERROR"] = "INFO" - message: str - - -@routes.post(f"/{API_VTAG}/auth/verify-phone-number", name="auth_register_phone") -@session_access_required( - name="auth_register_phone", - unauthorized_reason=MSG_UNAUTHORIZED_REGISTER_PHONE, -) -@on_success_grant_session_access_to( - name="auth_phone_confirmation", - max_access_count=MAX_2FA_CODE_TRIALS, -) -@on_success_grant_session_access_to( - name="auth_resend_2fa_code", - max_access_count=MAX_2FA_CODE_RESEND, -) -async def register_phone(request: web.Request): - """ - Submits phone registration - - sends a code - - registration is completed requesting to 'phone_confirmation' route with the code received - """ - product: Product = products_web.get_current_product(request) - settings: LoginSettingsForProduct = get_plugin_settings( - request.app, product_name=product.name - ) - - if not settings.LOGIN_2FA_REQUIRED: - raise web.HTTPServiceUnavailable( - reason="Phone registration is not available", - content_type=MIMETYPE_APPLICATION_JSON, - ) - - registration = await parse_request_body_as(RegisterPhoneBody, request) - - try: - assert settings.LOGIN_2FA_REQUIRED - assert settings.LOGIN_TWILIO - if not product.twilio_messaging_sid: - msg = f"Messaging SID is not configured in {product}. Update product's twilio_messaging_sid in database." - raise ValueError(msg) - - code = await _2fa_service.create_2fa_code( - app=request.app, - user_email=registration.email, - expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, - ) - await _2fa_service.send_sms_code( - phone_number=registration.phone, - code=code, - twilio_auth=settings.LOGIN_TWILIO, - twilio_messaging_sid=product.twilio_messaging_sid, - twilio_alpha_numeric_sender=product.twilio_alpha_numeric_sender_id, - first_name=get_user_name_from_email(registration.email), - ) - - return envelope_response( - # RegisterPhoneNextPage - data={ - "name": CODE_2FA_SMS_CODE_REQUIRED, - "parameters": { - "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, - }, - "message": MSG_2FA_CODE_SENT.format( - phone_number=mask_phone_number(registration.phone) - ), - "level": "INFO", - "logger": "user", - }, - status=status.HTTP_202_ACCEPTED, - ) - - except web.HTTPException: - raise - - except Exception as err: # pylint: disable=broad-except - # Unhandled errors -> 503 - error_code = create_error_code(err) - user_error_msg = "Currently we cannot register phone numbers" - - _logger.exception( - **create_troubleshotting_log_kwargs( - user_error_msg, - error=err, - error_code=error_code, - error_context={"request": request, "registration": registration}, - tip="Phone registration failed", - ) - ) - - raise web.HTTPServiceUnavailable( - reason=user_error_msg, - content_type=MIMETYPE_APPLICATION_JSON, - ) from err From 7a10e19cabb64c39242cdd019ecb960478e783f9 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:58:50 +0100 Subject: [PATCH 15/39] cleanup pre-post registration --- api/specs/web-server/_auth.py | 2 +- .../login/_preregistration_rest.py | 160 ++++++ .../login/_registration_rest.py | 489 ++++++++++++++---- .../login/_registration_rest_2.py | 437 ---------------- .../simcore_service_webserver/login/plugin.py | 4 +- ...login_handlers_registration_invitations.py | 2 +- .../tests/unit/with_dbs/03/test_email.py | 2 +- 7 files changed, 548 insertions(+), 548 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py delete mode 100644 services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index a35db14eeba..b80817af3c7 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -31,7 +31,7 @@ PhoneConfirmationBody, ResetPasswordConfirmation, ) -from simcore_service_webserver.login._registration_rest_2 import ( +from simcore_service_webserver.login._registration_rest import ( InvitationCheck, InvitationInfo, RegisterBody, diff --git a/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py b/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py new file mode 100644 index 00000000000..b2d9197804e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py @@ -0,0 +1,160 @@ +import logging +from typing import Any + +from aiohttp import web +from models_library.api_schemas_webserver.auth import ( + AccountRequestInfo, + UnregisterCheck, +) +from models_library.users import UserID +from pydantic import BaseModel, Field +from servicelib.aiohttp import status +from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY +from servicelib.aiohttp.requests_validation import parse_request_body_as +from servicelib.logging_utils import get_log_record_extra, log_context +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from servicelib.request_keys import RQT_USERID_KEY +from servicelib.utils import fire_and_forget_task + +from .._meta import API_VTAG +from ..constants import RQ_PRODUCT_KEY +from ..products import products_web +from ..products.models import Product +from ..security import api as security_service +from ..security.decorators import permission_required +from ..session.api import get_session +from ..users.api import get_user_credentials, set_user_as_deleted +from ..utils import MINUTE +from ..utils_rate_limiting import global_rate_limit_route +from . import _preregistration_service +from ._constants import CAPTCHA_SESSION_KEY, MSG_LOGGED_OUT, MSG_WRONG_CAPTCHA__INVALID +from .decorators import login_required +from .settings import LoginSettingsForProduct, get_plugin_settings +from .utils import flash_response, notify_user_logout + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +def _get_ipinfo(request: web.Request) -> dict[str, Any]: + # NOTE: Traefik is also configured to transmit the original IP. + x_real_ip = request.headers.get("X-Real-IP", None) + # SEE https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest.transport + peername: tuple | None = ( + request.transport.get_extra_info("peername") if request.transport else None + ) + return { + "x-real-ip": x_real_ip, + "x-forwarded-for": request.headers.get("X-Forwarded-For", None), + "peername": peername, + "test_url": f"https://ipinfo.io/{x_real_ip}/json", + } + + +@routes.post( + f"/{API_VTAG}/auth/request-account", + name="request_product_account", +) +@global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) +async def request_product_account(request: web.Request): + product = products_web.get_current_product(request) + session = await get_session(request) + + body = await parse_request_body_as(AccountRequestInfo, request) + assert body.form # nosec + assert body.captcha # nosec + + if body.captcha != session.get(CAPTCHA_SESSION_KEY): + raise web.HTTPUnprocessableEntity( + reason=MSG_WRONG_CAPTCHA__INVALID, content_type=MIMETYPE_APPLICATION_JSON + ) + session.pop(CAPTCHA_SESSION_KEY, None) + + # send email to fogbugz or user itself + fire_and_forget_task( + _preregistration_service.send_account_request_email_to_support( + request, + product=product, + request_form=body.form, + ipinfo=_get_ipinfo(request), + ), + task_suffix_name=f"{__name__}.request_product_account.send_account_request_email_to_support", + fire_and_forget_tasks_collection=request.app[APP_FIRE_AND_FORGET_TASKS_KEY], + ) + return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +class _AuthenticatedContext(BaseModel): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] + + +@routes.post(f"/{API_VTAG}/auth/unregister", name="unregister_account") +@login_required +@permission_required("user.profile.delete") +async def unregister_account(request: web.Request): + req_ctx = _AuthenticatedContext.model_validate(request) + body = await parse_request_body_as(UnregisterCheck, request) + + product: Product = products_web.get_current_product(request) + settings: LoginSettingsForProduct = get_plugin_settings( + request.app, product_name=product.name + ) + + # checks before deleting + credentials = await get_user_credentials(request.app, user_id=req_ctx.user_id) + if body.email != credentials.email.lower() or not security_service.check_password( + body.password.get_secret_value(), credentials.password_hash + ): + raise web.HTTPConflict( + reason="Wrong email or password. Please try again to delete this account" + ) + + with log_context( + _logger, + logging.INFO, + "Mark account for deletion to %s", + credentials.email, + extra=get_log_record_extra(user_id=req_ctx.user_id), + ): + # update user table + await set_user_as_deleted(request.app, user_id=req_ctx.user_id) + + # logout + await notify_user_logout( + request.app, user_id=req_ctx.user_id, client_session_id=None + ) + response = flash_response(MSG_LOGGED_OUT, "INFO") + await security_service.forget_identity(request, response) + + # send email in the background + fire_and_forget_task( + _preregistration_service.send_close_account_email( + request, + user_email=credentials.email, + user_first_name=credentials.display_name, + retention_days=settings.LOGIN_ACCOUNT_DELETION_RETENTION_DAYS, + ), + task_suffix_name=f"{__name__}.unregister_account.send_close_account_email", + fire_and_forget_tasks_collection=request.app[APP_FIRE_AND_FORGET_TASKS_KEY], + ) + + return response + + +@routes.get( + f"/{API_VTAG}/auth/captcha", + name="request_captcha", +) +@global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) +async def request_captcha(request: web.Request): + session = await get_session(request) + + captcha_text, image_data = await _preregistration_service.generate_captcha() + + # Store captcha text in session + session[CAPTCHA_SESSION_KEY] = captcha_text + + return web.Response(body=image_data, content_type="image/png") diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py index b2d9197804e..e2671488a9c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py @@ -1,160 +1,437 @@ import logging -from typing import Any +from datetime import UTC, datetime, timedelta +from typing import Literal from aiohttp import web -from models_library.api_schemas_webserver.auth import ( - AccountRequestInfo, - UnregisterCheck, +from aiohttp.web import RouteTableDef +from common_library.error_codes import create_error_code +from models_library.emails import LowerCaseEmailStr +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PositiveInt, + SecretStr, + field_validator, ) -from models_library.users import UserID -from pydantic import BaseModel, Field from servicelib.aiohttp import status -from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.aiohttp.requests_validation import parse_request_body_as -from servicelib.logging_utils import get_log_record_extra, log_context +from servicelib.logging_errors import create_troubleshotting_log_kwargs from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from servicelib.request_keys import RQT_USERID_KEY -from servicelib.utils import fire_and_forget_task +from simcore_postgres_database.models.users import UserStatus from .._meta import API_VTAG -from ..constants import RQ_PRODUCT_KEY +from ..groups.api import auto_add_user_to_groups, auto_add_user_to_product_group +from ..invitations.api import is_service_invitation_code from ..products import products_web from ..products.models import Product -from ..security import api as security_service -from ..security.decorators import permission_required -from ..session.api import get_session -from ..users.api import get_user_credentials, set_user_as_deleted +from ..session.access_policies import ( + on_success_grant_session_access_to, + session_access_required, +) from ..utils import MINUTE +from ..utils_aiohttp import NextPage, envelope_json_response from ..utils_rate_limiting import global_rate_limit_route -from . import _preregistration_service -from ._constants import CAPTCHA_SESSION_KEY, MSG_LOGGED_OUT, MSG_WRONG_CAPTCHA__INVALID -from .decorators import login_required -from .settings import LoginSettingsForProduct, get_plugin_settings -from .utils import flash_response, notify_user_logout +from . import _2fa_service, _auth_service, _confirmation_service, _security_service +from ._constants import ( + CODE_2FA_SMS_CODE_REQUIRED, + MAX_2FA_CODE_RESEND, + MAX_2FA_CODE_TRIALS, + MSG_2FA_CODE_SENT, + MSG_CANT_SEND_MAIL, + MSG_UNAUTHORIZED_REGISTER_PHONE, + MSG_WEAK_PASSWORD, +) +from ._invitations_service import ( + check_and_consume_invitation, + check_other_registrations, + extract_email_from_invitation, +) +from ._login_repository_legacy import ( + AsyncpgStorage, + ConfirmationTokenDict, + get_plugin_storage, +) +from ._models import InputSchema, check_confirm_password_match +from .settings import ( + LoginOptions, + LoginSettingsForProduct, + get_plugin_options, + get_plugin_settings, +) +from .utils import ( + envelope_response, + flash_response, + get_user_name_from_email, + notify_user_confirmation, +) +from .utils_email import get_template_path, send_email_from_template _logger = logging.getLogger(__name__) -routes = web.RouteTableDef() +routes = RouteTableDef() + +class InvitationCheck(InputSchema): + invitation: str = Field(..., description="Invitation code") -def _get_ipinfo(request: web.Request) -> dict[str, Any]: - # NOTE: Traefik is also configured to transmit the original IP. - x_real_ip = request.headers.get("X-Real-IP", None) - # SEE https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest.transport - peername: tuple | None = ( - request.transport.get_extra_info("peername") if request.transport else None + +class InvitationInfo(InputSchema): + email: LowerCaseEmailStr | None = Field( + None, description="Email associated to invitation or None" ) - return { - "x-real-ip": x_real_ip, - "x-forwarded-for": request.headers.get("X-Forwarded-For", None), - "peername": peername, - "test_url": f"https://ipinfo.io/{x_real_ip}/json", - } @routes.post( - f"/{API_VTAG}/auth/request-account", - name="request_product_account", + f"/{API_VTAG}/auth/register/invitations:check", + name="auth_check_registration_invitation", ) @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) -async def request_product_account(request: web.Request): - product = products_web.get_current_product(request) - session = await get_session(request) +async def check_registration_invitation(request: web.Request): + """ + Decrypts invitation and extracts associated email or + returns None if is not an encrypted invitation (might be a database invitation). + + raises HTTPForbidden, HTTPServiceUnavailable + """ + product: Product = products_web.get_current_product(request) + settings: LoginSettingsForProduct = get_plugin_settings( + request.app, product_name=product.name + ) - body = await parse_request_body_as(AccountRequestInfo, request) - assert body.form # nosec - assert body.captcha # nosec + # disabled -> None + if not settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: + return envelope_json_response(InvitationInfo(email=None)) - if body.captcha != session.get(CAPTCHA_SESSION_KEY): - raise web.HTTPUnprocessableEntity( - reason=MSG_WRONG_CAPTCHA__INVALID, content_type=MIMETYPE_APPLICATION_JSON - ) - session.pop(CAPTCHA_SESSION_KEY, None) + # non-encrypted -> None + # NOTE: that None is given if the code is the old type (and does not fail) + check = await parse_request_body_as(InvitationCheck, request) + if not is_service_invitation_code(code=check.invitation): + return envelope_json_response(InvitationInfo(email=None)) - # send email to fogbugz or user itself - fire_and_forget_task( - _preregistration_service.send_account_request_email_to_support( - request, - product=product, - request_form=body.form, - ipinfo=_get_ipinfo(request), - ), - task_suffix_name=f"{__name__}.request_product_account.send_account_request_email_to_support", - fire_and_forget_tasks_collection=request.app[APP_FIRE_AND_FORGET_TASKS_KEY], + # extracted -> email + email = await extract_email_from_invitation( + request.app, invitation_code=check.invitation ) - return web.json_response(status=status.HTTP_204_NO_CONTENT) + return envelope_json_response(InvitationInfo(email=email)) + +class RegisterBody(InputSchema): + email: LowerCaseEmailStr + password: SecretStr + confirm: SecretStr | None = Field(None, description="Password confirmation") + invitation: str | None = Field(None, description="Invitation code") -class _AuthenticatedContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] + _password_confirm_match = field_validator("confirm")(check_confirm_password_match) + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "email": "foo@mymail.com", + "password": "my secret", # NOSONAR + "confirm": "my secret", # optional + "invitation": "33c451d4-17b7-4e65-9880-694559b8ffc2", # optional only active + } + ] + } + ) -@routes.post(f"/{API_VTAG}/auth/unregister", name="unregister_account") -@login_required -@permission_required("user.profile.delete") -async def unregister_account(request: web.Request): - req_ctx = _AuthenticatedContext.model_validate(request) - body = await parse_request_body_as(UnregisterCheck, request) +@routes.post(f"/{API_VTAG}/auth/register", name="auth_register") +async def register(request: web.Request): + """ + Starts user's registration by providing an email, password and + invitation code (required by configuration). + An email with a link to 'email_confirmation' is sent to complete registration + """ product: Product = products_web.get_current_product(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) - # checks before deleting - credentials = await get_user_credentials(request.app, user_id=req_ctx.user_id) - if body.email != credentials.email.lower() or not security_service.check_password( - body.password.get_secret_value(), credentials.password_hash + await check_other_registrations( + request.app, email=registration.email, current_product=product, db=db, cfg=cfg + ) + + # Check for weak passwords + # This should strictly happen before invitation links are checked and consumed + # So the invitation can be re-used with a stronger password. + if ( + len(registration.password.get_secret_value()) + < settings.LOGIN_PASSWORD_MIN_LENGTH ): - raise web.HTTPConflict( - reason="Wrong email or password. Please try again to delete this account" + raise web.HTTPUnauthorized( + reason=MSG_WEAK_PASSWORD.format( + LOGIN_PASSWORD_MIN_LENGTH=settings.LOGIN_PASSWORD_MIN_LENGTH + ), + content_type=MIMETYPE_APPLICATION_JSON, ) - with log_context( - _logger, - logging.INFO, - "Mark account for deletion to %s", - credentials.email, - extra=get_log_record_extra(user_id=req_ctx.user_id), - ): - # update user table - await set_user_as_deleted(request.app, user_id=req_ctx.user_id) + # INVITATIONS + expires_at: datetime | None = None # = does not expire + invitation = None + # There are 3 possible states for an invitation: + # 1. Invitation is not required (i.e. the app has disabled invitations) + # 2. Invitation is invalid + # 3. Invitation is valid + # + # For those states the `invitation` variable get the following values + # 1. `None + # 2. no value, it raises and exception + # 3. gets `InvitationData` + # ` + # In addition, for 3. there are two types of invitations: + # 1. the invitation generated by the `invitation` service (new). + # 2. the invitation created by hand in the db confirmation table (deprecated). This + # one does not understand products. + # + if settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: + # Only requests with INVITATION can register user + # to either a permanent or to a trial account + invitation_code = registration.invitation + if invitation_code is None: + raise web.HTTPBadRequest( + reason="invitation field is required", + content_type=MIMETYPE_APPLICATION_JSON, + ) - # logout - await notify_user_logout( - request.app, user_id=req_ctx.user_id, client_session_id=None + invitation = await check_and_consume_invitation( + invitation_code, + product=product, + guest_email=registration.email, + db=db, + cfg=cfg, + app=request.app, ) - response = flash_response(MSG_LOGGED_OUT, "INFO") - await security_service.forget_identity(request, response) + if invitation.trial_account_days: + expires_at = datetime.now(UTC) + timedelta(invitation.trial_account_days) - # send email in the background - fire_and_forget_task( - _preregistration_service.send_close_account_email( - request, - user_email=credentials.email, - user_first_name=credentials.display_name, - retention_days=settings.LOGIN_ACCOUNT_DELETION_RETENTION_DAYS, + # get authorized user or create new + user = await _auth_service.get_user_by_email(request.app, email=registration.email) + if user: + await _auth_service.check_authorized_user_credentials_or_raise( + user, + password=registration.password.get_secret_value(), + product=product, + ) + else: + user = await _auth_service.create_user( + request.app, + email=registration.email, + password=registration.password.get_secret_value(), + status_upon_creation=( + UserStatus.CONFIRMATION_PENDING + if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED + else UserStatus.ACTIVE ), - task_suffix_name=f"{__name__}.unregister_account.send_close_account_email", - fire_and_forget_tasks_collection=request.app[APP_FIRE_AND_FORGET_TASKS_KEY], + expires_at=expires_at, ) - return response + # setup user groups + assert ( # nosec + product.name == invitation.product + if invitation and invitation.product + else True + ) + + await auto_add_user_to_groups(app=request.app, user_id=user["id"]) + await auto_add_user_to_product_group( + app=request.app, + user_id=user["id"], + product_name=product.name, + ) + + if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: + # Confirmation required: send confirmation email + _confirmation: ConfirmationTokenDict = await db.create_confirmation( + user_id=user["id"], + action="REGISTRATION", + data=invitation.model_dump_json() if invitation else None, + ) + + try: + email_confirmation_url = _confirmation_service.make_confirmation_link( + request, _confirmation + ) + email_template_path = await get_template_path( + request, "registration_email.jinja2" + ) + await send_email_from_template( + request, + from_=product.support_email, + to=registration.email, + template=email_template_path, + context={ + "host": request.host, + "link": email_confirmation_url, # SEE email_confirmation handler (action=REGISTRATION) + "name": user.get("first_name") or user["name"], + "support_email": product.support_email, + "product": product, + }, + ) + except Exception as err: # pylint: disable=broad-except + error_code = create_error_code(err) + user_error_msg = MSG_CANT_SEND_MAIL + + _logger.exception( + **create_troubleshotting_log_kwargs( + user_error_msg, + error=err, + error_code=error_code, + error_context={ + "request": request, + "registration": registration, + "user_id": user.get("id"), + "user": user, + "confirmation": _confirmation, + }, + tip="Failed while sending confirmation email", + ) + ) + + await db.delete_confirmation_and_user(user, _confirmation) + raise web.HTTPServiceUnavailable(reason=user_error_msg) from err -@routes.get( - f"/{API_VTAG}/auth/captcha", - name="request_captcha", + return flash_response( + "You are registered successfully! To activate your account, please, " + f"click on the verification link in the email we sent you to {registration.email}.", + "INFO", + ) + + # NOTE: Here confirmation is disabled + assert settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED is False # nosec + assert ( # nosec + product.name == invitation.product + if invitation and invitation.product + else True + ) + + await notify_user_confirmation( + request.app, + user_id=user["id"], + product_name=product.name, + extra_credits_in_usd=invitation.extra_credits_in_usd if invitation else None, + ) + + # No confirmation required: authorize login + assert not settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED # nosec + assert not settings.LOGIN_2FA_REQUIRED # nosec + + return await _security_service.login_granted_response(request=request, user=user) + + +class RegisterPhoneBody(InputSchema): + email: LowerCaseEmailStr + phone: str = Field( + ..., description="Phone number E.164, needed on the deployments with 2FA" + ) + + +class _PageParams(BaseModel): + expiration_2fa: PositiveInt | None = None + + +class RegisterPhoneNextPage(NextPage[_PageParams]): + logger: str = Field("user", deprecated=True) + level: Literal["INFO", "WARNING", "ERROR"] = "INFO" + message: str + + +@routes.post(f"/{API_VTAG}/auth/verify-phone-number", name="auth_register_phone") +@session_access_required( + name="auth_register_phone", + unauthorized_reason=MSG_UNAUTHORIZED_REGISTER_PHONE, ) -@global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) -async def request_captcha(request: web.Request): - session = await get_session(request) +@on_success_grant_session_access_to( + name="auth_phone_confirmation", + max_access_count=MAX_2FA_CODE_TRIALS, +) +@on_success_grant_session_access_to( + name="auth_resend_2fa_code", + max_access_count=MAX_2FA_CODE_RESEND, +) +async def register_phone(request: web.Request): + """ + Submits phone registration + - sends a code + - registration is completed requesting to 'phone_confirmation' route with the code received + """ + product: Product = products_web.get_current_product(request) + settings: LoginSettingsForProduct = get_plugin_settings( + request.app, product_name=product.name + ) + + if not settings.LOGIN_2FA_REQUIRED: + raise web.HTTPServiceUnavailable( + reason="Phone registration is not available", + content_type=MIMETYPE_APPLICATION_JSON, + ) + + registration = await parse_request_body_as(RegisterPhoneBody, request) - captcha_text, image_data = await _preregistration_service.generate_captcha() + try: + assert settings.LOGIN_2FA_REQUIRED + assert settings.LOGIN_TWILIO + if not product.twilio_messaging_sid: + msg = f"Messaging SID is not configured in {product}. Update product's twilio_messaging_sid in database." + raise ValueError(msg) - # Store captcha text in session - session[CAPTCHA_SESSION_KEY] = captcha_text + code = await _2fa_service.create_2fa_code( + app=request.app, + user_email=registration.email, + expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, + ) + await _2fa_service.send_sms_code( + phone_number=registration.phone, + code=code, + twilio_auth=settings.LOGIN_TWILIO, + twilio_messaging_sid=product.twilio_messaging_sid, + twilio_alpha_numeric_sender=product.twilio_alpha_numeric_sender_id, + first_name=get_user_name_from_email(registration.email), + ) + + return envelope_response( + # RegisterPhoneNextPage + data={ + "name": CODE_2FA_SMS_CODE_REQUIRED, + "parameters": { + "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, + }, + "message": MSG_2FA_CODE_SENT.format( + phone_number=mask_phone_number(registration.phone) + ), + "level": "INFO", + "logger": "user", + }, + status=status.HTTP_202_ACCEPTED, + ) + + except web.HTTPException: + raise + + except Exception as err: # pylint: disable=broad-except + # Unhandled errors -> 503 + error_code = create_error_code(err) + user_error_msg = "Currently we cannot register phone numbers" + + _logger.exception( + **create_troubleshotting_log_kwargs( + user_error_msg, + error=err, + error_code=error_code, + error_context={"request": request, "registration": registration}, + tip="Phone registration failed", + ) + ) - return web.Response(body=image_data, content_type="image/png") + raise web.HTTPServiceUnavailable( + reason=user_error_msg, + content_type=MIMETYPE_APPLICATION_JSON, + ) from err diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py deleted file mode 100644 index e2671488a9c..00000000000 --- a/services/web/server/src/simcore_service_webserver/login/_registration_rest_2.py +++ /dev/null @@ -1,437 +0,0 @@ -import logging -from datetime import UTC, datetime, timedelta -from typing import Literal - -from aiohttp import web -from aiohttp.web import RouteTableDef -from common_library.error_codes import create_error_code -from models_library.emails import LowerCaseEmailStr -from pydantic import ( - BaseModel, - ConfigDict, - Field, - PositiveInt, - SecretStr, - field_validator, -) -from servicelib.aiohttp import status -from servicelib.aiohttp.requests_validation import parse_request_body_as -from servicelib.logging_errors import create_troubleshotting_log_kwargs -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from simcore_postgres_database.models.users import UserStatus - -from .._meta import API_VTAG -from ..groups.api import auto_add_user_to_groups, auto_add_user_to_product_group -from ..invitations.api import is_service_invitation_code -from ..products import products_web -from ..products.models import Product -from ..session.access_policies import ( - on_success_grant_session_access_to, - session_access_required, -) -from ..utils import MINUTE -from ..utils_aiohttp import NextPage, envelope_json_response -from ..utils_rate_limiting import global_rate_limit_route -from . import _2fa_service, _auth_service, _confirmation_service, _security_service -from ._constants import ( - CODE_2FA_SMS_CODE_REQUIRED, - MAX_2FA_CODE_RESEND, - MAX_2FA_CODE_TRIALS, - MSG_2FA_CODE_SENT, - MSG_CANT_SEND_MAIL, - MSG_UNAUTHORIZED_REGISTER_PHONE, - MSG_WEAK_PASSWORD, -) -from ._invitations_service import ( - check_and_consume_invitation, - check_other_registrations, - extract_email_from_invitation, -) -from ._login_repository_legacy import ( - AsyncpgStorage, - ConfirmationTokenDict, - get_plugin_storage, -) -from ._models import InputSchema, check_confirm_password_match -from .settings import ( - LoginOptions, - LoginSettingsForProduct, - get_plugin_options, - get_plugin_settings, -) -from .utils import ( - envelope_response, - flash_response, - get_user_name_from_email, - notify_user_confirmation, -) -from .utils_email import get_template_path, send_email_from_template - -_logger = logging.getLogger(__name__) - - -routes = RouteTableDef() - - -class InvitationCheck(InputSchema): - invitation: str = Field(..., description="Invitation code") - - -class InvitationInfo(InputSchema): - email: LowerCaseEmailStr | None = Field( - None, description="Email associated to invitation or None" - ) - - -@routes.post( - f"/{API_VTAG}/auth/register/invitations:check", - name="auth_check_registration_invitation", -) -@global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) -async def check_registration_invitation(request: web.Request): - """ - Decrypts invitation and extracts associated email or - returns None if is not an encrypted invitation (might be a database invitation). - - raises HTTPForbidden, HTTPServiceUnavailable - """ - product: Product = products_web.get_current_product(request) - settings: LoginSettingsForProduct = get_plugin_settings( - request.app, product_name=product.name - ) - - # disabled -> None - if not settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: - return envelope_json_response(InvitationInfo(email=None)) - - # non-encrypted -> None - # NOTE: that None is given if the code is the old type (and does not fail) - check = await parse_request_body_as(InvitationCheck, request) - if not is_service_invitation_code(code=check.invitation): - return envelope_json_response(InvitationInfo(email=None)) - - # extracted -> email - email = await extract_email_from_invitation( - request.app, invitation_code=check.invitation - ) - return envelope_json_response(InvitationInfo(email=email)) - - -class RegisterBody(InputSchema): - email: LowerCaseEmailStr - password: SecretStr - confirm: SecretStr | None = Field(None, description="Password confirmation") - invitation: str | None = Field(None, description="Invitation code") - - _password_confirm_match = field_validator("confirm")(check_confirm_password_match) - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "email": "foo@mymail.com", - "password": "my secret", # NOSONAR - "confirm": "my secret", # optional - "invitation": "33c451d4-17b7-4e65-9880-694559b8ffc2", # optional only active - } - ] - } - ) - - -@routes.post(f"/{API_VTAG}/auth/register", name="auth_register") -async def register(request: web.Request): - """ - Starts user's registration by providing an email, password and - invitation code (required by configuration). - - An email with a link to 'email_confirmation' is sent to complete registration - """ - product: Product = products_web.get_current_product(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 - ) - - # Check for weak passwords - # This should strictly happen before invitation links are checked and consumed - # So the invitation can be re-used with a stronger password. - if ( - len(registration.password.get_secret_value()) - < settings.LOGIN_PASSWORD_MIN_LENGTH - ): - raise web.HTTPUnauthorized( - reason=MSG_WEAK_PASSWORD.format( - LOGIN_PASSWORD_MIN_LENGTH=settings.LOGIN_PASSWORD_MIN_LENGTH - ), - content_type=MIMETYPE_APPLICATION_JSON, - ) - - # INVITATIONS - expires_at: datetime | None = None # = does not expire - invitation = None - # There are 3 possible states for an invitation: - # 1. Invitation is not required (i.e. the app has disabled invitations) - # 2. Invitation is invalid - # 3. Invitation is valid - # - # For those states the `invitation` variable get the following values - # 1. `None - # 2. no value, it raises and exception - # 3. gets `InvitationData` - # ` - # In addition, for 3. there are two types of invitations: - # 1. the invitation generated by the `invitation` service (new). - # 2. the invitation created by hand in the db confirmation table (deprecated). This - # one does not understand products. - # - if settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: - # Only requests with INVITATION can register user - # to either a permanent or to a trial account - invitation_code = registration.invitation - if invitation_code is None: - raise web.HTTPBadRequest( - reason="invitation field is required", - content_type=MIMETYPE_APPLICATION_JSON, - ) - - invitation = await check_and_consume_invitation( - invitation_code, - product=product, - guest_email=registration.email, - db=db, - cfg=cfg, - app=request.app, - ) - if invitation.trial_account_days: - expires_at = datetime.now(UTC) + timedelta(invitation.trial_account_days) - - # get authorized user or create new - user = await _auth_service.get_user_by_email(request.app, email=registration.email) - if user: - await _auth_service.check_authorized_user_credentials_or_raise( - user, - password=registration.password.get_secret_value(), - product=product, - ) - else: - user = await _auth_service.create_user( - request.app, - email=registration.email, - password=registration.password.get_secret_value(), - status_upon_creation=( - UserStatus.CONFIRMATION_PENDING - if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED - else UserStatus.ACTIVE - ), - expires_at=expires_at, - ) - - # setup user groups - assert ( # nosec - product.name == invitation.product - if invitation and invitation.product - else True - ) - - await auto_add_user_to_groups(app=request.app, user_id=user["id"]) - await auto_add_user_to_product_group( - app=request.app, - user_id=user["id"], - product_name=product.name, - ) - - if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: - # Confirmation required: send confirmation email - _confirmation: ConfirmationTokenDict = await db.create_confirmation( - user_id=user["id"], - action="REGISTRATION", - data=invitation.model_dump_json() if invitation else None, - ) - - try: - email_confirmation_url = _confirmation_service.make_confirmation_link( - request, _confirmation - ) - email_template_path = await get_template_path( - request, "registration_email.jinja2" - ) - await send_email_from_template( - request, - from_=product.support_email, - to=registration.email, - template=email_template_path, - context={ - "host": request.host, - "link": email_confirmation_url, # SEE email_confirmation handler (action=REGISTRATION) - "name": user.get("first_name") or user["name"], - "support_email": product.support_email, - "product": product, - }, - ) - except Exception as err: # pylint: disable=broad-except - error_code = create_error_code(err) - user_error_msg = MSG_CANT_SEND_MAIL - - _logger.exception( - **create_troubleshotting_log_kwargs( - user_error_msg, - error=err, - error_code=error_code, - error_context={ - "request": request, - "registration": registration, - "user_id": user.get("id"), - "user": user, - "confirmation": _confirmation, - }, - tip="Failed while sending confirmation email", - ) - ) - - await db.delete_confirmation_and_user(user, _confirmation) - - raise web.HTTPServiceUnavailable(reason=user_error_msg) from err - - return flash_response( - "You are registered successfully! To activate your account, please, " - f"click on the verification link in the email we sent you to {registration.email}.", - "INFO", - ) - - # NOTE: Here confirmation is disabled - assert settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED is False # nosec - assert ( # nosec - product.name == invitation.product - if invitation and invitation.product - else True - ) - - await notify_user_confirmation( - request.app, - user_id=user["id"], - product_name=product.name, - extra_credits_in_usd=invitation.extra_credits_in_usd if invitation else None, - ) - - # No confirmation required: authorize login - assert not settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED # nosec - assert not settings.LOGIN_2FA_REQUIRED # nosec - - return await _security_service.login_granted_response(request=request, user=user) - - -class RegisterPhoneBody(InputSchema): - email: LowerCaseEmailStr - phone: str = Field( - ..., description="Phone number E.164, needed on the deployments with 2FA" - ) - - -class _PageParams(BaseModel): - expiration_2fa: PositiveInt | None = None - - -class RegisterPhoneNextPage(NextPage[_PageParams]): - logger: str = Field("user", deprecated=True) - level: Literal["INFO", "WARNING", "ERROR"] = "INFO" - message: str - - -@routes.post(f"/{API_VTAG}/auth/verify-phone-number", name="auth_register_phone") -@session_access_required( - name="auth_register_phone", - unauthorized_reason=MSG_UNAUTHORIZED_REGISTER_PHONE, -) -@on_success_grant_session_access_to( - name="auth_phone_confirmation", - max_access_count=MAX_2FA_CODE_TRIALS, -) -@on_success_grant_session_access_to( - name="auth_resend_2fa_code", - max_access_count=MAX_2FA_CODE_RESEND, -) -async def register_phone(request: web.Request): - """ - Submits phone registration - - sends a code - - registration is completed requesting to 'phone_confirmation' route with the code received - """ - product: Product = products_web.get_current_product(request) - settings: LoginSettingsForProduct = get_plugin_settings( - request.app, product_name=product.name - ) - - if not settings.LOGIN_2FA_REQUIRED: - raise web.HTTPServiceUnavailable( - reason="Phone registration is not available", - content_type=MIMETYPE_APPLICATION_JSON, - ) - - registration = await parse_request_body_as(RegisterPhoneBody, request) - - try: - assert settings.LOGIN_2FA_REQUIRED - assert settings.LOGIN_TWILIO - if not product.twilio_messaging_sid: - msg = f"Messaging SID is not configured in {product}. Update product's twilio_messaging_sid in database." - raise ValueError(msg) - - code = await _2fa_service.create_2fa_code( - app=request.app, - user_email=registration.email, - expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, - ) - await _2fa_service.send_sms_code( - phone_number=registration.phone, - code=code, - twilio_auth=settings.LOGIN_TWILIO, - twilio_messaging_sid=product.twilio_messaging_sid, - twilio_alpha_numeric_sender=product.twilio_alpha_numeric_sender_id, - first_name=get_user_name_from_email(registration.email), - ) - - return envelope_response( - # RegisterPhoneNextPage - data={ - "name": CODE_2FA_SMS_CODE_REQUIRED, - "parameters": { - "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, - }, - "message": MSG_2FA_CODE_SENT.format( - phone_number=mask_phone_number(registration.phone) - ), - "level": "INFO", - "logger": "user", - }, - status=status.HTTP_202_ACCEPTED, - ) - - except web.HTTPException: - raise - - except Exception as err: # pylint: disable=broad-except - # Unhandled errors -> 503 - error_code = create_error_code(err) - user_error_msg = "Currently we cannot register phone numbers" - - _logger.exception( - **create_troubleshotting_log_kwargs( - user_error_msg, - error=err, - error_code=error_code, - error_context={"request": request, "registration": registration}, - tip="Phone registration failed", - ) - ) - - raise web.HTTPServiceUnavailable( - reason=user_error_msg, - content_type=MIMETYPE_APPLICATION_JSON, - ) from err 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 cc5a35d3b28..760f1ff2c3d 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -28,8 +28,8 @@ _auth_rest, _change_service, _confirmation_rest, + _preregistration_rest, _registration_rest, - _registration_rest_2, ) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY from ._login_repository_legacy import APP_LOGIN_STORAGE_KEY, AsyncpgStorage @@ -140,8 +140,8 @@ def setup_login(app: web.Application): app.router.add_routes(_auth_rest.routes) app.router.add_routes(_confirmation_rest.routes) - app.router.add_routes(_registration_rest_2.routes) app.router.add_routes(_registration_rest.routes) + app.router.add_routes(_preregistration_rest.routes) app.router.add_routes(_change_service.routes) app.router.add_routes(_2fa_rest.routes) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py index ed62affc6ef..cf390235a03 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py @@ -18,7 +18,7 @@ from servicelib.aiohttp import status from servicelib.rest_constants import X_PRODUCT_NAME_HEADER from simcore_service_webserver.invitations.api import generate_invitation -from simcore_service_webserver.login._registration_rest_2 import ( +from simcore_service_webserver.login._registration_rest import ( InvitationCheck, InvitationInfo, ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_email.py b/services/web/server/tests/unit/with_dbs/03/test_email.py index ce3db9c9022..c59e252b01c 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_email.py +++ b/services/web/server/tests/unit/with_dbs/03/test_email.py @@ -29,10 +29,10 @@ from simcore_service_webserver.email._core import _remove_comments, _render_template from simcore_service_webserver.email._handlers import EmailTestFailed, EmailTestPassed from simcore_service_webserver.email.plugin import setup_email +from simcore_service_webserver.login._preregistration_rest import _get_ipinfo from simcore_service_webserver.login._preregistration_service import ( _json_encoder_and_dumps, ) -from simcore_service_webserver.login._registration_rest import _get_ipinfo @pytest.fixture From 62a1f1a906549cb6f00e1b849ad48dae123442ae Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:00:06 +0100 Subject: [PATCH 16/39] utils --- .../login/_login_repository_legacy.py | 50 +++++++++++++------ ...sql.py => _login_repository_legacy_sql.py} | 0 2 files changed, 36 insertions(+), 14 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_sql.py => _login_repository_legacy_sql.py} (100%) 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 e1c5e18d70c..d119c462d8b 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 @@ -6,7 +6,7 @@ from aiohttp import web from servicelib.utils_secrets import generate_passcode -from . import _sql +from . import _login_repository_legacy_sql _logger = getLogger(__name__) @@ -54,12 +54,18 @@ def __init__( async def get_user(self, with_data: dict[str, Any]) -> asyncpg.Record | None: async with self.pool.acquire() as conn: - return await _sql.find_one(conn, self.user_tbl, with_data) + return await _login_repository_legacy_sql.find_one( + conn, self.user_tbl, with_data + ) async def create_user(self, data: dict[str, Any]) -> dict[str, Any]: async with self.pool.acquire() as conn: - user_id = await _sql.insert(conn, self.user_tbl, data) - new_user = await _sql.find_one(conn, self.user_tbl, {"id": user_id}) + user_id = await _login_repository_legacy_sql.insert( + conn, self.user_tbl, data + ) + new_user = await _login_repository_legacy_sql.find_one( + conn, self.user_tbl, {"id": user_id} + ) assert new_user # nosec data.update( id=new_user["id"], @@ -70,11 +76,15 @@ async def create_user(self, data: dict[str, Any]) -> dict[str, Any]: async def update_user(self, user: dict[str, Any], updates: dict[str, Any]) -> None: async with self.pool.acquire() as conn: - await _sql.update(conn, self.user_tbl, {"id": user["id"]}, updates) + await _login_repository_legacy_sql.update( + conn, self.user_tbl, {"id": user["id"]}, updates + ) async def delete_user(self, user: dict[str, Any]) -> None: async with self.pool.acquire() as conn: - await _sql.delete(conn, self.user_tbl, {"id": user["id"]}) + await _login_repository_legacy_sql.delete( + conn, self.user_tbl, {"id": user["id"]} + ) # # CRUD confirmation @@ -87,7 +97,7 @@ async def create_confirmation( 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 _sql.find_one( + if not await _login_repository_legacy_sql.find_one( conn, self.confirm_tbl, {"code": numeric_code} ): break @@ -100,7 +110,7 @@ async def create_confirmation( data=data, created_at=datetime.utcnow(), ) - c = await _sql.insert( + c = await _login_repository_legacy_sql.insert( conn, self.confirm_tbl, dict(confirmation), returning="code" ) assert numeric_code == c # nosec @@ -112,7 +122,9 @@ async def get_confirmation( if "user" in filter_dict: filter_dict["user_id"] = filter_dict.pop("user")["id"] async with self.pool.acquire() as conn: - confirmation = await _sql.find_one(conn, self.confirm_tbl, filter_dict) + 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] ) @@ -120,7 +132,9 @@ async def get_confirmation( async def delete_confirmation(self, confirmation: ConfirmationTokenDict): async with self.pool.acquire() as conn: - await _sql.delete(conn, self.confirm_tbl, {"code": confirmation["code"]}) + await _login_repository_legacy_sql.delete( + conn, self.confirm_tbl, {"code": confirmation["code"]} + ) # # Transactions that guarantee atomicity. This avoids @@ -131,15 +145,23 @@ async def delete_confirmation_and_user( self, user: dict[str, Any], confirmation: ConfirmationTokenDict ): async with self.pool.acquire() as conn, conn.transaction(): - await _sql.delete(conn, self.confirm_tbl, {"code": confirmation["code"]}) - await _sql.delete(conn, self.user_tbl, {"id": user["id"]}) + 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 _sql.delete(conn, self.confirm_tbl, {"code": confirmation["code"]}) - await _sql.update(conn, self.user_tbl, {"id": user_id}, updates) + 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 + ) def get_plugin_storage(app: web.Application) -> AsyncpgStorage: diff --git a/services/web/server/src/simcore_service_webserver/login/_sql.py b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy_sql.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_sql.py rename to services/web/server/src/simcore_service_webserver/login/_login_repository_legacy_sql.py From 803d322bee2e6f3652d1b7c0715b06a55c32912c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:01:54 +0100 Subject: [PATCH 17/39] email service --- .../src/simcore_service_webserver/login/_2fa_service.py | 2 +- .../src/simcore_service_webserver/login/_change_service.py | 2 +- .../login/{utils_email.py => _emails_service.py} | 0 .../src/simcore_service_webserver/login/_registration_rest.py | 2 +- .../src/simcore_service_webserver/publications/_rest.py | 2 +- services/web/server/tests/unit/isolated/test_templates.py | 2 +- .../tests/unit/with_dbs/03/login/test_login_utils_emails.py | 4 ++-- 7 files changed, 7 insertions(+), 7 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{utils_email.py => _emails_service.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_service.py b/services/web/server/src/simcore_service_webserver/login/_2fa_service.py index e432d4b4a10..855888a19fd 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_2fa_service.py @@ -21,8 +21,8 @@ from ..products.models import Product from ..redis import get_redis_validation_code_client +from ._emails_service import get_template_path, send_email_from_template from .errors import SendingVerificationEmailError, SendingVerificationSmsError -from .utils_email import get_template_path, send_email_from_template log = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_change_service.py b/services/web/server/src/simcore_service_webserver/login/_change_service.py index 4e05098fd51..f5c4435e514 100644 --- a/services/web/server/src/simcore_service_webserver/login/_change_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_change_service.py @@ -27,6 +27,7 @@ MSG_PASSWORD_CHANGED, MSG_WRONG_PASSWORD, ) +from ._emails_service import get_template_path, send_email_from_template from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage from ._models import InputSchema, create_password_match_validator from .decorators import login_required @@ -37,7 +38,6 @@ flash_response, validate_user_status, ) -from .utils_email import get_template_path, send_email_from_template _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/utils_email.py b/services/web/server/src/simcore_service_webserver/login/_emails_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/utils_email.py rename to services/web/server/src/simcore_service_webserver/login/_emails_service.py diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py index e2671488a9c..60c8446fbd6 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py @@ -42,6 +42,7 @@ MSG_UNAUTHORIZED_REGISTER_PHONE, MSG_WEAK_PASSWORD, ) +from ._emails_service import get_template_path, send_email_from_template from ._invitations_service import ( check_and_consume_invitation, check_other_registrations, @@ -65,7 +66,6 @@ get_user_name_from_email, notify_user_confirmation, ) -from .utils_email import get_template_path, send_email_from_template _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/publications/_rest.py b/services/web/server/src/simcore_service_webserver/publications/_rest.py index 330905f94b3..63b9b64b61c 100644 --- a/services/web/server/src/simcore_service_webserver/publications/_rest.py +++ b/services/web/server/src/simcore_service_webserver/publications/_rest.py @@ -10,9 +10,9 @@ from servicelib.request_keys import RQT_USERID_KEY from .._meta import API_VTAG as VTAG +from ..login._emails_service import AttachmentTuple, send_email_from_template, themed from ..login.decorators import login_required from ..login.login_repository_legacy import AsyncpgStorage, get_plugin_storage -from ..login.utils_email import AttachmentTuple, send_email_from_template, themed from ..products import products_web from ._utils import json2html diff --git a/services/web/server/tests/unit/isolated/test_templates.py b/services/web/server/tests/unit/isolated/test_templates.py index dce2d63fbad..8cea0ff3619 100644 --- a/services/web/server/tests/unit/isolated/test_templates.py +++ b/services/web/server/tests/unit/isolated/test_templates.py @@ -13,7 +13,7 @@ from faker import Faker from simcore_service_webserver._resources import webserver_resources from simcore_service_webserver.email.plugin import setup_email -from simcore_service_webserver.login.utils_email import themed +from simcore_service_webserver.login._emails_service import themed @pytest.fixture diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py index 1eb7f810faa..d4fbefe1139 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py @@ -16,12 +16,12 @@ from simcore_service_webserver.application_settings import setup_settings from simcore_service_webserver.constants import RQ_PRODUCT_KEY from simcore_service_webserver.email.plugin import setup_email -from simcore_service_webserver.login.plugin import setup_login -from simcore_service_webserver.login.utils_email import ( +from simcore_service_webserver.login._emails_service import ( AttachmentTuple, get_template_path, send_email_from_template, ) +from simcore_service_webserver.login.plugin import setup_login from simcore_service_webserver.publications._utils import json2html from simcore_service_webserver.statics._constants import FRONTEND_APPS_AVAILABLE From 8591b3637c47e9ee0d38ab22262a8f78f46565ed Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:05:05 +0100 Subject: [PATCH 18/39] utils -> login_service --- .../garbage_collector/_tasks_users.py | 6 +++--- .../simcore_service_webserver/login/_2fa_rest.py | 2 +- .../login/_auth_rest.py | 2 +- .../login/_auth_service.py | 2 +- .../login/_change_service.py | 8 ++++---- .../login/_confirmation_rest.py | 16 ++++++++-------- .../login/{utils.py => _login_service.py} | 0 .../login/_preregistration_rest.py | 2 +- .../login/_registration_rest.py | 12 ++++++------ .../login/_security_service.py | 2 +- .../login/login_service.py | 5 +++++ .../studies_dispatcher/_users.py | 2 +- .../unit/with_dbs/04/wallets/test_wallets.py | 2 +- 13 files changed, 33 insertions(+), 28 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{utils.py => _login_service.py} (100%) create mode 100644 services/web/server/src/simcore_service_webserver/login/login_service.py diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_users.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_users.py index e99f9c4a225..edfeb47230b 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_users.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_users.py @@ -1,5 +1,5 @@ """ - Scheduled tasks addressing users +Scheduled tasks addressing users """ @@ -14,7 +14,7 @@ from tenacity.before_sleep import before_sleep_log from tenacity.wait import wait_exponential -from ..login.utils import notify_user_logout +from ..login import login_service from ..security.api import clean_auth_policy_cache from ..users.api import update_expired_users @@ -39,7 +39,7 @@ async def notify_user_logout_all_sessions( get_log_record_extra(user_id=user_id), ): try: - await notify_user_logout(app, user_id, client_session_id=None) + await login_service.notify_user_logout(app, user_id, client_session_id=None) except Exception: # pylint: disable=broad-except _logger.warning( "Ignored error while notifying logout for %s", diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py b/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py index a363a64e55c..f4e43ed5494 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py @@ -21,10 +21,10 @@ MSG_UNKNOWN_EMAIL, ) from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage +from ._login_service import envelope_response from ._models import InputSchema from .errors import handle_login_exceptions from .settings import LoginSettingsForProduct, get_plugin_settings -from .utils import envelope_response _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py index 157968a371a..9a9256b9003 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_rest.py @@ -38,11 +38,11 @@ MSG_WRONG_2FA_CODE__INVALID, ) from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage +from ._login_service import envelope_response, flash_response, notify_user_logout from ._models import InputSchema from .decorators import login_required from .errors import handle_login_exceptions from .settings import LoginSettingsForProduct, get_plugin_settings -from .utils import envelope_response, flash_response, notify_user_logout log = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_service.py b/services/web/server/src/simcore_service_webserver/login/_auth_service.py index 4dd7d724e18..e3933ed262b 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_service.py @@ -12,7 +12,7 @@ from ..security.api import check_password, encrypt_password from ._constants import MSG_UNKNOWN_EMAIL, MSG_WRONG_PASSWORD from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from .utils import validate_user_status +from ._login_service import validate_user_status async def get_user_by_email(app: web.Application, *, email: str) -> dict[str, Any]: diff --git a/services/web/server/src/simcore_service_webserver/login/_change_service.py b/services/web/server/src/simcore_service_webserver/login/_change_service.py index f5c4435e514..895317ebea3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_change_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_change_service.py @@ -29,15 +29,15 @@ ) from ._emails_service import get_template_path, send_email_from_template from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from ._models import InputSchema, create_password_match_validator -from .decorators import login_required -from .settings import LoginOptions, get_plugin_options -from .utils import ( +from ._login_service import ( ACTIVE, CHANGE_EMAIL, flash_response, validate_user_status, ) +from ._models import InputSchema, create_password_match_validator +from .decorators import login_required +from .settings import LoginOptions, get_plugin_options _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py index 5ca7351dccb..a46d765439e 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py @@ -45,14 +45,7 @@ ConfirmationTokenDict, get_plugin_storage, ) -from ._models import InputSchema, check_confirm_password_match -from .settings import ( - LoginOptions, - LoginSettingsForProduct, - get_plugin_options, - get_plugin_settings, -) -from .utils import ( +from ._login_service import ( ACTIVE, CHANGE_EMAIL, REGISTRATION, @@ -60,6 +53,13 @@ flash_response, notify_user_confirmation, ) +from ._models import InputSchema, check_confirm_password_match +from .settings import ( + LoginOptions, + LoginSettingsForProduct, + get_plugin_options, + get_plugin_settings, +) _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/utils.py b/services/web/server/src/simcore_service_webserver/login/_login_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/utils.py rename to services/web/server/src/simcore_service_webserver/login/_login_service.py diff --git a/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py b/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py index b2d9197804e..844ffa85d9f 100644 --- a/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py @@ -28,9 +28,9 @@ from ..utils_rate_limiting import global_rate_limit_route from . import _preregistration_service from ._constants import CAPTCHA_SESSION_KEY, MSG_LOGGED_OUT, MSG_WRONG_CAPTCHA__INVALID +from ._login_service import flash_response, notify_user_logout from .decorators import login_required from .settings import LoginSettingsForProduct, get_plugin_settings -from .utils import flash_response, notify_user_logout _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py index 60c8446fbd6..19282de67ba 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_rest.py @@ -53,6 +53,12 @@ ConfirmationTokenDict, get_plugin_storage, ) +from ._login_service import ( + envelope_response, + flash_response, + get_user_name_from_email, + notify_user_confirmation, +) from ._models import InputSchema, check_confirm_password_match from .settings import ( LoginOptions, @@ -60,12 +66,6 @@ get_plugin_options, get_plugin_settings, ) -from .utils import ( - envelope_response, - flash_response, - get_user_name_from_email, - notify_user_confirmation, -) _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_security_service.py b/services/web/server/src/simcore_service_webserver/login/_security_service.py index 7588f68d63d..0f8685d01ab 100644 --- a/services/web/server/src/simcore_service_webserver/login/_security_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_security_service.py @@ -8,7 +8,7 @@ from ..security import api as security_service from ._constants import MSG_LOGGED_IN -from .utils import flash_response +from ._login_service import flash_response _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/login_service.py b/services/web/server/src/simcore_service_webserver/login/login_service.py new file mode 100644 index 00000000000..0f315e28d80 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/login_service.py @@ -0,0 +1,5 @@ +from ._login_service import notify_user_logout + +__all__: tuple[str, ...] = ("notify_user_logout",) + +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py index 926d614079f..e49f7ad1b4e 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py @@ -27,8 +27,8 @@ from ..garbage_collector.settings import GUEST_USER_RC_LOCK_FORMAT from ..groups.api import auto_add_user_to_product_group +from ..login._login_service import ACTIVE, GUEST from ..login.login_repository_legacy import AsyncpgStorage, get_plugin_storage -from ..login.utils import ACTIVE, GUEST from ..products import products_web from ..redis import get_redis_lock_manager_client from ..security.api import ( diff --git a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py index 1a615af2551..1951161ddac 100644 --- a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py +++ b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py @@ -27,7 +27,7 @@ from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.login.utils import notify_user_confirmation +from simcore_service_webserver.login._login_service import notify_user_confirmation from simcore_service_webserver.products.products_service import get_product from simcore_service_webserver.projects.models import ProjectDict from simcore_service_webserver.users.api import UserDisplayAndIdNamesTuple From 3934258ab248114cf66941f69b7de7f80b898fe0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:08:50 +0100 Subject: [PATCH 19/39] mocks --- .../test_login_handlers_registration_invitations.py | 8 ++++---- .../tests/unit/with_dbs/03/login/test_login_2fa.py | 2 +- .../unit/with_dbs/03/login/test_login_registration.py | 10 +++++----- .../03/login/test_login_registration_handlers.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py index cf390235a03..420772472c5 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py @@ -31,7 +31,7 @@ async def test_check_registration_invitation_when_not_required( mocker: MockerFixture, ): mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -61,7 +61,7 @@ async def test_check_registration_invitations_with_old_code( mocker: MockerFixture, ): mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct.create_from_envs( LOGIN_REGISTRATION_INVITATION_REQUIRED=True, # <-- @@ -87,7 +87,7 @@ async def test_check_registration_invitation_and_get_email( ): mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct.create_from_envs( LOGIN_REGISTRATION_INVITATION_REQUIRED=True, # <-- @@ -122,7 +122,7 @@ async def test_registration_to_different_product( assert client.app mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 6144f489ea0..0bd03cb8844 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -79,7 +79,7 @@ def postgres_db(postgres_db: sa.engine.Engine): def mocked_twilio_service(mocker: MockerFixture) -> dict[str, Mock]: return { "send_sms_code_for_registration": mocker.patch( - "simcore_service_webserver.login.handlers_registration._2fa_service.send_sms_code", + "simcore_service_webserver.login._registration_rest._2fa_service.send_sms_code", autospec=True, ), "send_sms_code_for_login": mocker.patch( 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 cb370a87b80..7fd5f1b4177 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 @@ -152,7 +152,7 @@ async def test_registration_invitation_stays_valid_if_once_tried_with_weak_passw ): assert client.app mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -270,7 +270,7 @@ async def test_registration_without_confirmation( ): assert client.app mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -308,7 +308,7 @@ async def test_registration_with_confirmation( ): assert client.app mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=True, @@ -376,7 +376,7 @@ async def test_registration_with_invitation( ): assert client.app mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -436,7 +436,7 @@ async def test_registraton_with_invitation_for_trial_account( ): assert client.app mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py index 5d019f4fb57..8f292e70dca 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py @@ -57,7 +57,7 @@ def mocked_send_email(mocker: MockerFixture) -> MagicMock: @pytest.fixture def mocked_captcha_session(mocker: MockerFixture) -> MagicMock: return mocker.patch( - "simcore_service_webserver.login._registration_handlers.get_session", + "simcore_service_webserver.login._preregistration_rest.get_session", spec=True, return_value={"captcha": "123456"}, ) From 2ad07a56666db65467ad87be15c0102c93edaa0b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:13:16 +0100 Subject: [PATCH 20/39] cleanup --- api/specs/web-server/_auth.py | 16 +++++----- .../{_2fa_rest.py => _controller/2fa_rest.py} | 20 ++++++------- .../login/_controller/__init__.py | 0 .../auth_rest.py} | 30 +++++++++---------- .../confirmation_rest.py} | 28 ++++++++--------- .../preregistration_rest.py} | 30 +++++++++---------- .../simcore_service_webserver/login/plugin.py | 16 +++++----- ...login_handlers_registration_invitations.py | 8 ++--- .../unit/with_dbs/03/login/test_login_2fa.py | 4 +-- .../03/login/test_login_2fa_resend.py | 13 ++++---- .../03/login/test_login_registration.py | 10 +++---- .../login/test_login_registration_handlers.py | 2 +- .../tests/unit/with_dbs/03/test_email.py | 4 ++- 13 files changed, 94 insertions(+), 87 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_2fa_rest.py => _controller/2fa_rest.py} (89%) create mode 100644 services/web/server/src/simcore_service_webserver/login/_controller/__init__.py rename services/web/server/src/simcore_service_webserver/login/{_auth_rest.py => _controller/auth_rest.py} (93%) rename services/web/server/src/simcore_service_webserver/login/{_confirmation_rest.py => _controller/confirmation_rest.py} (93%) rename services/web/server/src/simcore_service_webserver/login/{_preregistration_rest.py => _controller/preregistration_rest.py} (87%) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index b80817af3c7..f60571d40ff 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -15,19 +15,19 @@ from models_library.rest_error import EnvelopedError, Log from pydantic import BaseModel, Field, confloat from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.login._2fa_rest import Resend2faBody -from simcore_service_webserver.login._auth_rest import ( - LoginBody, - LoginNextPage, - LoginTwoFactorAuthBody, - LogoutBody, -) from simcore_service_webserver.login._change_service import ( ChangeEmailBody, ChangePasswordBody, ResetPasswordBody, ) -from simcore_service_webserver.login._confirmation_rest import ( +from simcore_service_webserver.login._controller._2fa_rest import Resend2faBody +from simcore_service_webserver.login._controller.auth_rest import ( + LoginBody, + LoginNextPage, + LoginTwoFactorAuthBody, + LogoutBody, +) +from simcore_service_webserver.login._controller.confirmation_rest import ( PhoneConfirmationBody, ResetPasswordConfirmation, ) diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/2fa_rest.py similarity index 89% rename from services/web/server/src/simcore_service_webserver/login/_2fa_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/2fa_rest.py index f4e43ed5494..64b06f55ddb 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/2fa_rest.py @@ -9,22 +9,22 @@ from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from ..products import products_web -from ..products.models import Product -from ..session.access_policies import session_access_required -from . import _2fa_service -from ._constants import ( +from ...products import products_web +from ...products.models import Product +from ...session.access_policies import session_access_required +from .. import _2fa_service +from .._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, MSG_2FA_CODE_SENT, MSG_EMAIL_SENT, MSG_UNKNOWN_EMAIL, ) -from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from ._login_service import envelope_response -from ._models import InputSchema -from .errors import handle_login_exceptions -from .settings import LoginSettingsForProduct, get_plugin_settings +from .._login_repository_legacy import AsyncpgStorage, get_plugin_storage +from .._login_service import envelope_response +from .._models import InputSchema +from ..errors import handle_login_exceptions +from ..settings import LoginSettingsForProduct, get_plugin_settings _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/__init__.py b/services/web/server/src/simcore_service_webserver/login/_controller/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py similarity index 93% rename from services/web/server/src/simcore_service_webserver/login/_auth_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py index 9a9256b9003..bc779bbcc6d 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py @@ -12,18 +12,18 @@ from servicelib.request_keys import RQT_USERID_KEY from simcore_postgres_database.models.users import UserRole -from .._meta import API_VTAG -from ..products import products_web -from ..products.models import Product -from ..security import api as security_service -from ..session.access_policies import ( +from ..._meta import API_VTAG +from ...products import products_web +from ...products.models import Product +from ...security import api as security_service +from ...session.access_policies import ( on_success_grant_session_access_to, session_access_required, ) -from ..users import preferences_api as user_preferences_api -from ..utils_aiohttp import NextPage -from . import _2fa_service, _auth_service, _security_service -from ._constants import ( +from ...users import preferences_api as user_preferences_api +from ...utils_aiohttp import NextPage +from .. import _2fa_service, _auth_service, _security_service +from .._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, CODE_PHONE_NUMBER_REQUIRED, @@ -37,12 +37,12 @@ MSG_WRONG_2FA_CODE__EXPIRED, MSG_WRONG_2FA_CODE__INVALID, ) -from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from ._login_service import envelope_response, flash_response, notify_user_logout -from ._models import InputSchema -from .decorators import login_required -from .errors import handle_login_exceptions -from .settings import LoginSettingsForProduct, get_plugin_settings +from .._login_repository_legacy import AsyncpgStorage, get_plugin_storage +from .._login_service import envelope_response, flash_response, notify_user_logout +from .._models import InputSchema +from ..decorators import login_required +from ..errors import handle_login_exceptions +from ..settings import LoginSettingsForProduct, get_plugin_settings log = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py similarity index 93% rename from services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py index a46d765439e..f358e28f459 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py @@ -26,26 +26,26 @@ from simcore_postgres_database.aiopg_errors import UniqueViolation from yarl import URL -from ..products import products_web -from ..products.models import Product -from ..security.api import encrypt_password -from ..session.access_policies import session_access_required -from ..utils import HOUR, MINUTE -from ..utils_aiohttp import create_redirect_to_page_response -from ..utils_rate_limiting import global_rate_limit_route -from . import _2fa_service, _confirmation_service, _security_service -from ._constants import ( +from ...products import products_web +from ...products.models import Product +from ...security.api import encrypt_password +from ...session.access_policies import session_access_required +from ...utils import HOUR, MINUTE +from ...utils_aiohttp import create_redirect_to_page_response +from ...utils_rate_limiting import global_rate_limit_route +from .. import _2fa_service, _confirmation_service, _security_service +from .._constants import ( MSG_PASSWORD_CHANGE_NOT_ALLOWED, MSG_PASSWORD_CHANGED, MSG_UNAUTHORIZED_PHONE_CONFIRMATION, ) -from ._invitations_service import InvitationData -from ._login_repository_legacy import ( +from .._invitations_service import InvitationData +from .._login_repository_legacy import ( AsyncpgStorage, ConfirmationTokenDict, get_plugin_storage, ) -from ._login_service import ( +from .._login_service import ( ACTIVE, CHANGE_EMAIL, REGISTRATION, @@ -53,8 +53,8 @@ flash_response, notify_user_confirmation, ) -from ._models import InputSchema, check_confirm_password_match -from .settings import ( +from .._models import InputSchema, check_confirm_password_match +from ..settings import ( LoginOptions, LoginSettingsForProduct, get_plugin_options, diff --git a/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/preregistration_rest.py similarity index 87% rename from services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/preregistration_rest.py index 844ffa85d9f..3b2ebd25d6c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_preregistration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/preregistration_rest.py @@ -16,21 +16,21 @@ from servicelib.request_keys import RQT_USERID_KEY from servicelib.utils import fire_and_forget_task -from .._meta import API_VTAG -from ..constants import RQ_PRODUCT_KEY -from ..products import products_web -from ..products.models import Product -from ..security import api as security_service -from ..security.decorators import permission_required -from ..session.api import get_session -from ..users.api import get_user_credentials, set_user_as_deleted -from ..utils import MINUTE -from ..utils_rate_limiting import global_rate_limit_route -from . import _preregistration_service -from ._constants import CAPTCHA_SESSION_KEY, MSG_LOGGED_OUT, MSG_WRONG_CAPTCHA__INVALID -from ._login_service import flash_response, notify_user_logout -from .decorators import login_required -from .settings import LoginSettingsForProduct, get_plugin_settings +from ..._meta import API_VTAG +from ...constants import RQ_PRODUCT_KEY +from ...products import products_web +from ...products.models import Product +from ...security import api as security_service +from ...security.decorators import permission_required +from ...session.api import get_session +from ...users.api import get_user_credentials, set_user_as_deleted +from ...utils import MINUTE +from ...utils_rate_limiting import global_rate_limit_route +from .. import _preregistration_service +from .._constants import CAPTCHA_SESSION_KEY, MSG_LOGGED_OUT, MSG_WRONG_CAPTCHA__INVALID +from .._login_service import flash_response, notify_user_logout +from ..decorators import login_required +from ..settings import LoginSettingsForProduct, get_plugin_settings _logger = logging.getLogger(__name__) 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 760f1ff2c3d..a2e2c5b2cc8 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -24,14 +24,16 @@ from ..redis import setup_redis from ..rest.plugin import setup_rest from . import ( - _2fa_rest, - _auth_rest, _change_service, - _confirmation_rest, - _preregistration_rest, _registration_rest, ) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY +from ._controller import ( + _2fa_rest, + auth_rest, + confirmation_rest, + preregistration_rest, +) from ._login_repository_legacy import APP_LOGIN_STORAGE_KEY, AsyncpgStorage from .settings import ( APP_LOGIN_OPTIONS_KEY, @@ -138,10 +140,10 @@ def setup_login(app: web.Application): # routes - app.router.add_routes(_auth_rest.routes) - app.router.add_routes(_confirmation_rest.routes) + app.router.add_routes(auth_rest.routes) + app.router.add_routes(confirmation_rest.routes) app.router.add_routes(_registration_rest.routes) - app.router.add_routes(_preregistration_rest.routes) + app.router.add_routes(preregistration_rest.routes) app.router.add_routes(_change_service.routes) app.router.add_routes(_2fa_rest.routes) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py index 420772472c5..22cd46e07bb 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py @@ -31,7 +31,7 @@ async def test_check_registration_invitation_when_not_required( mocker: MockerFixture, ): mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -61,7 +61,7 @@ async def test_check_registration_invitations_with_old_code( mocker: MockerFixture, ): mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct.create_from_envs( LOGIN_REGISTRATION_INVITATION_REQUIRED=True, # <-- @@ -87,7 +87,7 @@ async def test_check_registration_invitation_and_get_email( ): mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct.create_from_envs( LOGIN_REGISTRATION_INVITATION_REQUIRED=True, # <-- @@ -122,7 +122,7 @@ async def test_registration_to_different_product( assert client.app mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 0bd03cb8844..12b68fe6b86 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -79,11 +79,11 @@ def postgres_db(postgres_db: sa.engine.Engine): def mocked_twilio_service(mocker: MockerFixture) -> dict[str, Mock]: return { "send_sms_code_for_registration": mocker.patch( - "simcore_service_webserver.login._registration_rest._2fa_service.send_sms_code", + "simcore_service_webserver.login._controller.registration_rest._2fa_service.send_sms_code", autospec=True, ), "send_sms_code_for_login": mocker.patch( - "simcore_service_webserver.login._auth_rest._2fa_service.send_sms_code", + "simcore_service_webserver.login._controller.auth_rest._2fa_service.send_sms_code", autospec=True, ), } diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py index 6a1e500f9ea..f134fef2dcb 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py @@ -13,8 +13,11 @@ from servicelib.aiohttp import status from simcore_postgres_database.models.products import ProductLoginSettingsDict, products from simcore_service_webserver.application_settings import ApplicationSettings -from simcore_service_webserver.login._auth_rest import CodePageParams, NextPage from simcore_service_webserver.login._constants import CODE_2FA_SMS_CODE_REQUIRED +from simcore_service_webserver.login._controller.auth_rest import ( + CodePageParams, + NextPage, +) @pytest.fixture @@ -79,21 +82,21 @@ async def test_resend_2fa_workflow( # spy send functions mocker.patch( - "simcore_service_webserver.login._2fa_rest._2fa_service.send_sms_code", + "simcore_service_webserver.login._controller.2fa_rest._2fa_service.send_sms_code", autospec=True, ) mock_send_sms_code2 = mocker.patch( - "simcore_service_webserver.login._auth_rest._2fa_service.send_sms_code", + "simcore_service_webserver.login._controller.auth_rest._2fa_service.send_sms_code", autospec=True, ) mock_send_email_code = mocker.patch( - "simcore_service_webserver.login._2fa_rest._2fa_service.send_email_code", + "simcore_service_webserver.login._controller.2fa_rest._2fa_service.send_email_code", autospec=True, ) mock_get_2fa_code = mocker.patch( - "simcore_service_webserver.login._2fa_rest._2fa_service.get_2fa_code", + "simcore_service_webserver.login._controller.2fa_rest._2fa_service.get_2fa_code", autospec=True, return_value=None, # <-- Emulates code expired ) 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 7fd5f1b4177..8c11db88065 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 @@ -152,7 +152,7 @@ async def test_registration_invitation_stays_valid_if_once_tried_with_weak_passw ): assert client.app mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -270,7 +270,7 @@ async def test_registration_without_confirmation( ): assert client.app mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -308,7 +308,7 @@ async def test_registration_with_confirmation( ): assert client.app mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=True, @@ -376,7 +376,7 @@ async def test_registration_with_invitation( ): assert client.app mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -436,7 +436,7 @@ async def test_registraton_with_invitation_for_trial_account( ): assert client.app mocker.patch( - "simcore_service_webserver.login._registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py index 8f292e70dca..78fa7de3bcc 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py @@ -57,7 +57,7 @@ def mocked_send_email(mocker: MockerFixture) -> MagicMock: @pytest.fixture def mocked_captcha_session(mocker: MockerFixture) -> MagicMock: return mocker.patch( - "simcore_service_webserver.login._preregistration_rest.get_session", + "simcore_service_webserver.login._controller.preregistration_rest.get_session", spec=True, return_value={"captcha": "123456"}, ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_email.py b/services/web/server/tests/unit/with_dbs/03/test_email.py index c59e252b01c..a5a2fc7e556 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_email.py +++ b/services/web/server/tests/unit/with_dbs/03/test_email.py @@ -29,7 +29,9 @@ from simcore_service_webserver.email._core import _remove_comments, _render_template from simcore_service_webserver.email._handlers import EmailTestFailed, EmailTestPassed from simcore_service_webserver.email.plugin import setup_email -from simcore_service_webserver.login._preregistration_rest import _get_ipinfo +from simcore_service_webserver.login._controller.preregistration_rest import ( + _get_ipinfo, +) from simcore_service_webserver.login._preregistration_service import ( _json_encoder_and_dumps, ) From 58e19e2d15146d212f9f1306e08045d6e918cb2c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:14:32 +0100 Subject: [PATCH 21/39] missing --- api/specs/web-server/_auth.py | 2 +- .../registration_rest.py} | 34 +++++++++---------- .../simcore_service_webserver/login/plugin.py | 4 +-- ...login_handlers_registration_invitations.py | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_registration_rest.py => _controller/registration_rest.py} (94%) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index f60571d40ff..e1d6174353a 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -31,7 +31,7 @@ PhoneConfirmationBody, ResetPasswordConfirmation, ) -from simcore_service_webserver.login._registration_rest import ( +from simcore_service_webserver.login._controller.registration_rest import ( InvitationCheck, InvitationInfo, RegisterBody, diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/login/_registration_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py index 19282de67ba..766751c9be3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py @@ -20,20 +20,20 @@ from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.models.users import UserStatus -from .._meta import API_VTAG -from ..groups.api import auto_add_user_to_groups, auto_add_user_to_product_group -from ..invitations.api import is_service_invitation_code -from ..products import products_web -from ..products.models import Product -from ..session.access_policies import ( +from ..._meta import API_VTAG +from ...groups.api import auto_add_user_to_groups, auto_add_user_to_product_group +from ...invitations.api import is_service_invitation_code +from ...products import products_web +from ...products.models import Product +from ...session.access_policies import ( on_success_grant_session_access_to, session_access_required, ) -from ..utils import MINUTE -from ..utils_aiohttp import NextPage, envelope_json_response -from ..utils_rate_limiting import global_rate_limit_route -from . import _2fa_service, _auth_service, _confirmation_service, _security_service -from ._constants import ( +from ...utils import MINUTE +from ...utils_aiohttp import NextPage, envelope_json_response +from ...utils_rate_limiting import global_rate_limit_route +from .. import _2fa_service, _auth_service, _confirmation_service, _security_service +from .._constants import ( CODE_2FA_SMS_CODE_REQUIRED, MAX_2FA_CODE_RESEND, MAX_2FA_CODE_TRIALS, @@ -42,25 +42,25 @@ MSG_UNAUTHORIZED_REGISTER_PHONE, MSG_WEAK_PASSWORD, ) -from ._emails_service import get_template_path, send_email_from_template -from ._invitations_service import ( +from .._emails_service import get_template_path, send_email_from_template +from .._invitations_service import ( check_and_consume_invitation, check_other_registrations, extract_email_from_invitation, ) -from ._login_repository_legacy import ( +from .._login_repository_legacy import ( AsyncpgStorage, ConfirmationTokenDict, get_plugin_storage, ) -from ._login_service import ( +from .._login_service import ( envelope_response, flash_response, get_user_name_from_email, notify_user_confirmation, ) -from ._models import InputSchema, check_confirm_password_match -from .settings import ( +from .._models import InputSchema, check_confirm_password_match +from ..settings import ( LoginOptions, LoginSettingsForProduct, get_plugin_options, 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 a2e2c5b2cc8..4746245f3c3 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -25,7 +25,6 @@ from ..rest.plugin import setup_rest from . import ( _change_service, - _registration_rest, ) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY from ._controller import ( @@ -33,6 +32,7 @@ auth_rest, confirmation_rest, preregistration_rest, + registration_rest, ) from ._login_repository_legacy import APP_LOGIN_STORAGE_KEY, AsyncpgStorage from .settings import ( @@ -142,7 +142,7 @@ def setup_login(app: web.Application): app.router.add_routes(auth_rest.routes) app.router.add_routes(confirmation_rest.routes) - app.router.add_routes(_registration_rest.routes) + app.router.add_routes(registration_rest.routes) app.router.add_routes(preregistration_rest.routes) app.router.add_routes(_change_service.routes) app.router.add_routes(_2fa_rest.routes) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py index 22cd46e07bb..5f1daa9301c 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py @@ -18,7 +18,7 @@ from servicelib.aiohttp import status from servicelib.rest_constants import X_PRODUCT_NAME_HEADER from simcore_service_webserver.invitations.api import generate_invitation -from simcore_service_webserver.login._registration_rest import ( +from simcore_service_webserver.login._controller.registration_rest import ( InvitationCheck, InvitationInfo, ) From a36758d7b37dd1031a4aa7d069e2bf1dcdc0fafa Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:51:31 +0100 Subject: [PATCH 22/39] 2fa --- .../login/_controller/auth_rest.py | 16 +++++++++------- .../login/_controller/confirmation_rest.py | 6 +++--- .../login/_controller/registration_rest.py | 6 +++--- .../_controller/{2fa_rest.py => twofa_rest.py} | 14 +++++++------- .../login/{_2fa_service.py => _twofa_service.py} | 0 .../simcore_service_webserver/login/plugin.py | 4 ++-- .../unit/with_dbs/03/login/test_login_2fa.py | 12 ++++++------ 7 files changed, 30 insertions(+), 28 deletions(-) rename services/web/server/src/simcore_service_webserver/login/_controller/{2fa_rest.py => twofa_rest.py} (91%) rename services/web/server/src/simcore_service_webserver/login/{_2fa_service.py => _twofa_service.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py index bc779bbcc6d..450c8466d7c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py @@ -22,7 +22,7 @@ ) from ...users import preferences_api as user_preferences_api from ...utils_aiohttp import NextPage -from .. import _2fa_service, _auth_service, _security_service +from .. import _auth_service, _security_service, _twofa_service from .._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, @@ -148,7 +148,7 @@ async def login(request: web.Request): status=status.HTTP_202_ACCEPTED, ) - code = await _2fa_service.create_2fa_code( + code = await _twofa_service.create_2fa_code( app=request.app, user_email=user["email"], expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, @@ -161,7 +161,7 @@ async def login(request: web.Request): assert settings.LOGIN_TWILIO # nosec assert product.twilio_messaging_sid # nosec - await _2fa_service.send_sms_code( + await _twofa_service.send_sms_code( phone_number=user["phone"], code=code, twilio_auth=settings.LOGIN_TWILIO, @@ -177,7 +177,7 @@ async def login(request: web.Request): "name": CODE_2FA_SMS_CODE_REQUIRED, "parameters": { "message": MSG_2FA_CODE_SENT.format( - phone_number=_2fa_service.mask_phone_number(user["phone"]) + phone_number=_twofa_service.mask_phone_number(user["phone"]) ), "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, }, @@ -189,7 +189,7 @@ async def login(request: web.Request): assert ( user_2fa_authentification_method == TwoFactorAuthentificationMethod.EMAIL ) # nosec - await _2fa_service.send_email_code( + await _twofa_service.send_email_code( request, user_email=user["email"], support_email=product.support_email, @@ -238,7 +238,9 @@ async def login_2fa(request: web.Request): login_2fa_ = await parse_request_body_as(LoginTwoFactorAuthBody, request) # validates code - _expected_2fa_code = await _2fa_service.get_2fa_code(request.app, login_2fa_.email) + _expected_2fa_code = await _twofa_service.get_2fa_code( + request.app, login_2fa_.email + ) if not _expected_2fa_code: raise web.HTTPUnauthorized( reason=MSG_WRONG_2FA_CODE__EXPIRED, content_type=MIMETYPE_APPLICATION_JSON @@ -255,7 +257,7 @@ async def login_2fa(request: web.Request): assert UserRole(user["role"]) <= UserRole.USER # nosec # dispose since code was used - await _2fa_service.delete_2fa_code(request.app, login_2fa_.email) + await _twofa_service.delete_2fa_code(request.app, login_2fa_.email) return await _security_service.login_granted_response(request, user=dict(user)) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py index f358e28f459..f1bbe18fa16 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py @@ -33,7 +33,7 @@ from ...utils import HOUR, MINUTE from ...utils_aiohttp import create_redirect_to_page_response from ...utils_rate_limiting import global_rate_limit_route -from .. import _2fa_service, _confirmation_service, _security_service +from .. import _confirmation_service, _security_service, _twofa_service from .._constants import ( MSG_PASSWORD_CHANGE_NOT_ALLOWED, MSG_PASSWORD_CHANGED, @@ -244,10 +244,10 @@ async def phone_confirmation(request: web.Request): request_body = await parse_request_body_as(PhoneConfirmationBody, request) if ( - expected := await _2fa_service.get_2fa_code(request.app, request_body.email) + expected := await _twofa_service.get_2fa_code(request.app, request_body.email) ) and request_body.code.get_secret_value() == expected: # consumes code - await _2fa_service.delete_2fa_code(request.app, request_body.email) + await _twofa_service.delete_2fa_code(request.app, request_body.email) # updates confirmed phone number try: diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py index 766751c9be3..df05a46a6bf 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py @@ -32,7 +32,7 @@ from ...utils import MINUTE from ...utils_aiohttp import NextPage, envelope_json_response from ...utils_rate_limiting import global_rate_limit_route -from .. import _2fa_service, _auth_service, _confirmation_service, _security_service +from .. import _auth_service, _confirmation_service, _security_service, _twofa_service from .._constants import ( CODE_2FA_SMS_CODE_REQUIRED, MAX_2FA_CODE_RESEND, @@ -383,12 +383,12 @@ async def register_phone(request: web.Request): msg = f"Messaging SID is not configured in {product}. Update product's twilio_messaging_sid in database." raise ValueError(msg) - code = await _2fa_service.create_2fa_code( + code = await _twofa_service.create_2fa_code( app=request.app, user_email=registration.email, expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, ) - await _2fa_service.send_sms_code( + await _twofa_service.send_sms_code( phone_number=registration.phone, code=code, twilio_auth=settings.LOGIN_TWILIO, diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/2fa_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/twofa_rest.py similarity index 91% rename from services/web/server/src/simcore_service_webserver/login/_controller/2fa_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/twofa_rest.py index 64b06f55ddb..fb5f942964a 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/2fa_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/twofa_rest.py @@ -12,7 +12,7 @@ from ...products import products_web from ...products.models import Product from ...session.access_policies import session_access_required -from .. import _2fa_service +from .. import _twofa_service from .._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, @@ -65,11 +65,11 @@ async def resend_2fa_code(request: web.Request): ) # Already a code? - previous_code = await _2fa_service.get_2fa_code( + previous_code = await _twofa_service.get_2fa_code( request.app, user_email=resend_2fa_.email ) if previous_code is not None: - await _2fa_service.delete_2fa_code(request.app, user_email=resend_2fa_.email) + await _twofa_service.delete_2fa_code(request.app, user_email=resend_2fa_.email) # guaranteed by LoginSettingsForProduct assert settings.LOGIN_2FA_REQUIRED # nosec @@ -77,7 +77,7 @@ async def resend_2fa_code(request: web.Request): assert product.twilio_messaging_sid # nosec # creates and stores code - code = await _2fa_service.create_2fa_code( + code = await _twofa_service.create_2fa_code( request.app, user_email=user["email"], expiration_in_seconds=settings.LOGIN_2FA_CODE_EXPIRATION_SEC, @@ -85,7 +85,7 @@ async def resend_2fa_code(request: web.Request): # sends via SMS if resend_2fa_.via == "SMS": - await _2fa_service.send_sms_code( + await _twofa_service.send_sms_code( phone_number=user["phone"], code=code, twilio_auth=settings.LOGIN_TWILIO, @@ -100,7 +100,7 @@ async def resend_2fa_code(request: web.Request): "name": CODE_2FA_SMS_CODE_REQUIRED, "parameters": { "message": MSG_2FA_CODE_SENT.format( - phone_number=_2fa_service.mask_phone_number(user["phone"]) + phone_number=_twofa_service.mask_phone_number(user["phone"]) ), "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, }, @@ -111,7 +111,7 @@ async def resend_2fa_code(request: web.Request): # sends via Email else: assert resend_2fa_.via == "Email" # nosec - await _2fa_service.send_email_code( + await _twofa_service.send_email_code( request, user_email=user["email"], support_email=product.support_email, diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_service.py b/services/web/server/src/simcore_service_webserver/login/_twofa_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/login/_2fa_service.py rename to services/web/server/src/simcore_service_webserver/login/_twofa_service.py 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 4746245f3c3..9bb8cb169a6 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -28,11 +28,11 @@ ) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY from ._controller import ( - _2fa_rest, auth_rest, confirmation_rest, preregistration_rest, registration_rest, + twofa_rest, ) from ._login_repository_legacy import APP_LOGIN_STORAGE_KEY, AsyncpgStorage from .settings import ( @@ -145,7 +145,7 @@ def setup_login(app: web.Application): app.router.add_routes(registration_rest.routes) app.router.add_routes(preregistration_rest.routes) app.router.add_routes(_change_service.routes) - app.router.add_routes(_2fa_rest.routes) + app.router.add_routes(twofa_rest.routes) _setup_login_options(app) setup_login_storage(app) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 12b68fe6b86..c803efa28f2 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -22,7 +22,12 @@ from simcore_postgres_database.models.products import ProductLoginSettingsDict, products from simcore_service_webserver.application_settings import ApplicationSettings from simcore_service_webserver.db.models import UserStatus -from simcore_service_webserver.login._2fa_service import ( +from simcore_service_webserver.login._constants import ( + CODE_2FA_SMS_CODE_REQUIRED, + MSG_2FA_UNAVAILABLE, +) +from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage +from simcore_service_webserver.login._twofa_service import ( _do_create_2fa_code, create_2fa_code, delete_2fa_code, @@ -30,11 +35,6 @@ get_redis_validation_code_client, send_email_code, ) -from simcore_service_webserver.login._constants import ( - CODE_2FA_SMS_CODE_REQUIRED, - MSG_2FA_UNAVAILABLE, -) -from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage from simcore_service_webserver.products import products_web from simcore_service_webserver.products.errors import UnknownProductError from simcore_service_webserver.products.models import Product From acd17085015195dc035b31fcfc8da57fbebcea05 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:52:06 +0100 Subject: [PATCH 23/39] change is rest --- api/specs/web-server/_auth.py | 10 +++--- .../change_rest.py} | 32 +++++++++---------- .../simcore_service_webserver/login/plugin.py | 6 ++-- 3 files changed, 23 insertions(+), 25 deletions(-) rename services/web/server/src/simcore_service_webserver/login/{_change_service.py => _controller/change_rest.py} (93%) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index e1d6174353a..b8ea3ee9843 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -15,11 +15,6 @@ from models_library.rest_error import EnvelopedError, Log from pydantic import BaseModel, Field, confloat from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.login._change_service import ( - ChangeEmailBody, - ChangePasswordBody, - ResetPasswordBody, -) from simcore_service_webserver.login._controller._2fa_rest import Resend2faBody from simcore_service_webserver.login._controller.auth_rest import ( LoginBody, @@ -27,6 +22,11 @@ LoginTwoFactorAuthBody, LogoutBody, ) +from simcore_service_webserver.login._controller.change_rest import ( + ChangeEmailBody, + ChangePasswordBody, + ResetPasswordBody, +) from simcore_service_webserver.login._controller.confirmation_rest import ( PhoneConfirmationBody, ResetPasswordConfirmation, diff --git a/services/web/server/src/simcore_service_webserver/login/_change_service.py b/services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py similarity index 93% rename from services/web/server/src/simcore_service_webserver/login/_change_service.py rename to services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py index 895317ebea3..c32cb906da5 100644 --- a/services/web/server/src/simcore_service_webserver/login/_change_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py @@ -10,16 +10,16 @@ from servicelib.request_keys import RQT_USERID_KEY from simcore_postgres_database.utils_users import UsersRepo -from .._meta import API_VTAG -from ..db.plugin import get_database_engine -from ..products import products_web -from ..products.models import Product -from ..security.api import check_password, encrypt_password -from ..users import api as users_service -from ..utils import HOUR -from ..utils_rate_limiting import global_rate_limit_route -from . import _confirmation_service -from ._constants import ( +from ..._meta import API_VTAG +from ...db.plugin import get_database_engine +from ...products import products_web +from ...products.models import Product +from ...security.api import check_password, encrypt_password +from ...users import api as users_service +from ...utils import HOUR +from ...utils_rate_limiting import global_rate_limit_route +from .. import _confirmation_service +from .._constants import ( MSG_CANT_SEND_MAIL, MSG_CHANGE_EMAIL_REQUESTED, MSG_EMAIL_SENT, @@ -27,17 +27,17 @@ MSG_PASSWORD_CHANGED, MSG_WRONG_PASSWORD, ) -from ._emails_service import get_template_path, send_email_from_template -from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from ._login_service import ( +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, flash_response, validate_user_status, ) -from ._models import InputSchema, create_password_match_validator -from .decorators import login_required -from .settings import LoginOptions, get_plugin_options +from .._models import InputSchema, create_password_match_validator +from ..decorators import login_required +from ..settings import LoginOptions, get_plugin_options _logger = logging.getLogger(__name__) 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 9bb8cb169a6..dd2fb710288 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -23,12 +23,10 @@ from ..products.plugin import setup_products from ..redis import setup_redis from ..rest.plugin import setup_rest -from . import ( - _change_service, -) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY from ._controller import ( auth_rest, + change_rest, confirmation_rest, preregistration_rest, registration_rest, @@ -144,7 +142,7 @@ def setup_login(app: web.Application): app.router.add_routes(confirmation_rest.routes) app.router.add_routes(registration_rest.routes) app.router.add_routes(preregistration_rest.routes) - app.router.add_routes(_change_service.routes) + app.router.add_routes(change_rest.routes) app.router.add_routes(twofa_rest.routes) _setup_login_options(app) From 70e5357f760138f99d64d61e4ed30a13b4019f2f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:54:16 +0100 Subject: [PATCH 24/39] cleanup and fixes static analysis --- .../login/_auth_service.py | 4 ++-- .../login/_confirmation_service.py | 6 +++--- .../login/_controller/auth_rest.py | 18 ++++++++---------- .../login/_controller/registration_rest.py | 2 +- .../src/simcore_service_webserver/login/cli.py | 4 ++-- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_service.py b/services/web/server/src/simcore_service_webserver/login/_auth_service.py index e3933ed262b..a936f7f62f2 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_service.py @@ -10,9 +10,9 @@ from ..groups.api import is_user_by_email_in_group from ..products.models import Product from ..security.api import check_password, encrypt_password +from . import _login_service from ._constants import MSG_UNKNOWN_EMAIL, MSG_WRONG_PASSWORD from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from ._login_service import validate_user_status async def get_user_by_email(app: web.Application, *, email: str) -> dict[str, Any]: @@ -55,7 +55,7 @@ async def check_authorized_user_credentials_or_raise( reason=MSG_UNKNOWN_EMAIL, content_type=MIMETYPE_APPLICATION_JSON ) - validate_user_status(user=user, support_email=product.support_email) + _login_service.validate_user_status(user=user, support_email=product.support_email) if not check_password(password, user["password_hash"]): raise web.HTTPUnauthorized( 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 e4c40b16ef5..cbd1fd907dd 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 @@ -21,7 +21,7 @@ ) from .settings import LoginOptions -log = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) async def validate_confirmation_code( @@ -37,7 +37,7 @@ async def validate_confirmation_code( ) if confirmation and is_confirmation_expired(cfg, confirmation): await db.delete_confirmation(confirmation) - log.warning( + _logger.warning( "Used expired token [%s]. Deleted from confirmations table.", confirmation, ) @@ -78,7 +78,7 @@ async def get_or_create_confirmation( if confirmation is not None and is_confirmation_expired(cfg, confirmation): await db.delete_confirmation(confirmation) - log.warning( + _logger.warning( "Used expired token [%s]. Deleted from confirmations table.", confirmation, ) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py index 450c8466d7c..731b421eb51 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py @@ -22,7 +22,7 @@ ) from ...users import preferences_api as user_preferences_api from ...utils_aiohttp import NextPage -from .. import _auth_service, _security_service, _twofa_service +from .. import _auth_service, _login_service, _security_service, _twofa_service from .._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, @@ -37,8 +37,6 @@ MSG_WRONG_2FA_CODE__EXPIRED, MSG_WRONG_2FA_CODE__INVALID, ) -from .._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from .._login_service import envelope_response, flash_response, notify_user_logout from .._models import InputSchema from ..decorators import login_required from ..errors import handle_login_exceptions @@ -136,7 +134,7 @@ async def login(request: web.Request): user_2fa_authentification_method == TwoFactorAuthentificationMethod.SMS and not user["phone"] ): - return envelope_response( + return _login_service.envelope_response( # LoginNextPage { "name": CODE_PHONE_NUMBER_REQUIRED, @@ -171,7 +169,7 @@ async def login(request: web.Request): user_id=user["id"], ) - return envelope_response( + return _login_service.envelope_response( # LoginNextPage { "name": CODE_2FA_SMS_CODE_REQUIRED, @@ -198,7 +196,7 @@ async def login(request: web.Request): product=product, user_id=user["id"], ) - return envelope_response( + return _login_service.envelope_response( { "name": CODE_2FA_EMAIL_CODE_REQUIRED, "parameters": { @@ -226,8 +224,6 @@ async def login_2fa(request: web.Request): settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) - db: AsyncpgStorage = get_plugin_storage(request.app) - if not settings.LOGIN_2FA_REQUIRED: raise web.HTTPServiceUnavailable( reason="2FA login is not available", @@ -284,8 +280,10 @@ async def logout(request: web.Request) -> web.Response: f"{logout_.client_session_id=}", extra=get_log_record_extra(user_id=user_id), ): - response = flash_response(MSG_LOGGED_OUT, "INFO") - await notify_user_logout(request.app, user_id, logout_.client_session_id) + response = _login_service.flash_response(MSG_LOGGED_OUT, "INFO") + await _login_service.notify_user_logout( + request.app, user_id, logout_.client_session_id + ) await security_service.forget_identity(request, response) return response diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py index df05a46a6bf..c5c20258de3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py @@ -405,7 +405,7 @@ async def register_phone(request: web.Request): "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, }, "message": MSG_2FA_CODE_SENT.format( - phone_number=mask_phone_number(registration.phone) + phone_number=_twofa_service.mask_phone_number(registration.phone) ), "level": "INFO", "logger": "user", diff --git a/services/web/server/src/simcore_service_webserver/login/cli.py b/services/web/server/src/simcore_service_webserver/login/cli.py index 86a70f8e77f..4c99dd57448 100644 --- a/services/web/server/src/simcore_service_webserver/login/cli.py +++ b/services/web/server/src/simcore_service_webserver/login/cli.py @@ -1,5 +1,5 @@ import sys -from datetime import datetime +from datetime import UTC, datetime import typer from servicelib.utils_secrets import generate_password @@ -46,7 +46,7 @@ def invitations( fg=typer.colors.BLUE, ) - utcnow = datetime.utcnow() + utcnow = datetime.now(tz=UTC) today: datetime = utcnow.today() print("code,user_id,action,data,created_at", file=sys.stdout) for n, code in enumerate(codes, start=1): From 8bb885efa5b6de32fb14d74a8c08a09fb92b3dd7 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 11:05:12 +0100 Subject: [PATCH 25/39] rename imports --- api/specs/web-server/_auth.py | 2 +- .../server/tests/unit/with_dbs/03/login/test_login_2fa.py | 6 +++--- .../tests/unit/with_dbs/03/login/test_login_2fa_resend.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index b8ea3ee9843..f9f04bf1a79 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -15,7 +15,6 @@ from models_library.rest_error import EnvelopedError, Log from pydantic import BaseModel, Field, confloat from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.login._controller._2fa_rest import Resend2faBody from simcore_service_webserver.login._controller.auth_rest import ( LoginBody, LoginNextPage, @@ -38,6 +37,7 @@ RegisterPhoneBody, RegisterPhoneNextPage, ) +from simcore_service_webserver.login._controller.twofa_rest import Resend2faBody router = APIRouter(prefix=f"/{API_VTAG}", tags=["auth"]) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index c803efa28f2..03d5b762c85 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -79,11 +79,11 @@ def postgres_db(postgres_db: sa.engine.Engine): def mocked_twilio_service(mocker: MockerFixture) -> dict[str, Mock]: return { "send_sms_code_for_registration": mocker.patch( - "simcore_service_webserver.login._controller.registration_rest._2fa_service.send_sms_code", + "simcore_service_webserver.login._controller.registration_rest.twofa_service.send_sms_code", autospec=True, ), "send_sms_code_for_login": mocker.patch( - "simcore_service_webserver.login._controller.auth_rest._2fa_service.send_sms_code", + "simcore_service_webserver.login._controller.auth_rest.twofa_service.send_sms_code", autospec=True, ), } @@ -421,7 +421,7 @@ async def test_2fa_sms_failure_during_login( mocker.patch( # MD: Emulates error in graylog https://monitoring.osparc.io/graylog/search/649e7619ce6e0838a96e9bf1?q=%222FA%22&rangetype=relative&from=172800 - "simcore_service_webserver.login._2fa_service.twilio.rest.Client", + "simcore_service_webserver.login.twofa_service.twilio.rest.Client", autospec=True, side_effect=TwilioRestException( status=400, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py index f134fef2dcb..25d1761f2b2 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py @@ -82,21 +82,21 @@ async def test_resend_2fa_workflow( # spy send functions mocker.patch( - "simcore_service_webserver.login._controller.2fa_rest._2fa_service.send_sms_code", + "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_sms_code", autospec=True, ) mock_send_sms_code2 = mocker.patch( - "simcore_service_webserver.login._controller.auth_rest._2fa_service.send_sms_code", + "simcore_service_webserver.login._controller.auth_rest._twofa_service.send_sms_code", autospec=True, ) mock_send_email_code = mocker.patch( - "simcore_service_webserver.login._controller.2fa_rest._2fa_service.send_email_code", + "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_email_code", autospec=True, ) mock_get_2fa_code = mocker.patch( - "simcore_service_webserver.login._controller.2fa_rest._2fa_service.get_2fa_code", + "simcore_service_webserver.login._controller.twofa_rest._twofa_service.get_2fa_code", autospec=True, return_value=None, # <-- Emulates code expired ) From f423a00b92c1f7231504ae9deaedcb2d69a9fb26 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 11:18:55 +0100 Subject: [PATCH 26/39] fixing tests --- .../login/_twofa_service.py | 1 + .../unit/with_dbs/03/login/test_login_2fa.py | 31 ++++++++++--------- .../03/login/test_login_2fa_resend.py | 8 ++--- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/login/_twofa_service.py b/services/web/server/src/simcore_service_webserver/login/_twofa_service.py index 855888a19fd..c799e29e4a3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_twofa_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_twofa_service.py @@ -92,6 +92,7 @@ class SMSError(RuntimeError): @log_decorator(log, level=logging.DEBUG) async def send_sms_code( + *, phone_number: str, code: str, twilio_auth: TwilioSettings, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 03d5b762c85..44cad16f5fa 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -6,14 +6,13 @@ import asyncio import logging from contextlib import AsyncExitStack -from unittest.mock import Mock import pytest import sqlalchemy as sa from aiohttp.test_utils import TestClient, make_mocked_request from faker import Faker from models_library.authentification import TwoFactorAuthentificationMethod -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict from pytest_simcore.helpers.webserver_login import NewUser, parse_link, parse_test_marks @@ -76,16 +75,20 @@ def postgres_db(postgres_db: sa.engine.Engine): @pytest.fixture -def mocked_twilio_service(mocker: MockerFixture) -> dict[str, Mock]: +def mocked_twilio_service(mocker: MockerFixture) -> dict[str, MockType]: + mock = mocker.patch( + "simcore_service_webserver.login._controller.registration_rest._twofa_service.send_sms_code", + autospec=True, + ) + + mock2 = mocker.patch( + "simcore_service_webserver.login._controller.auth_rest._twofa_service.send_sms_code", + autospec=False, + ) + return { - "send_sms_code_for_registration": mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.twofa_service.send_sms_code", - autospec=True, - ), - "send_sms_code_for_login": mocker.patch( - "simcore_service_webserver.login._controller.auth_rest.twofa_service.send_sms_code", - autospec=True, - ), + "send_sms_code_for_registration": mock, + "send_sms_code_for_login": mock, } @@ -120,7 +123,7 @@ async def test_workflow_register_and_login_with_2fa( fake_user_email: str, fake_user_password: str, fake_user_phone_number: str, - mocked_twilio_service: dict[str, Mock], + mocked_twilio_service: dict[str, MockType], mocked_email_core_remove_comments: None, cleanup_db_tables: None, ): @@ -309,7 +312,7 @@ async def test_can_register_same_phone_in_different_accounts( fake_user_email: str, fake_user_password: str, fake_user_phone_number: str, - mocked_twilio_service: dict[str, Mock], + mocked_twilio_service: dict[str, MockType], cleanup_db_tables: None, ): """ @@ -421,7 +424,7 @@ async def test_2fa_sms_failure_during_login( mocker.patch( # MD: Emulates error in graylog https://monitoring.osparc.io/graylog/search/649e7619ce6e0838a96e9bf1?q=%222FA%22&rangetype=relative&from=172800 - "simcore_service_webserver.login.twofa_service.twilio.rest.Client", + "simcore_service_webserver.login._twofa_service.twilio.rest.Client", autospec=True, side_effect=TwilioRestException( status=400, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py index 25d1761f2b2..2ea53c56332 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py @@ -81,10 +81,10 @@ async def test_resend_2fa_workflow( assert client.app # spy send functions - mocker.patch( - "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_sms_code", - autospec=True, - ) + # mocker.patch( + # "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_sms_code", + # autospec=True, + # ) mock_send_sms_code2 = mocker.patch( "simcore_service_webserver.login._controller.auth_rest._twofa_service.send_sms_code", autospec=True, From 6f9c34e7b3ae7c367177eb3d6a89f8bebc996d48 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 13:51:49 +0100 Subject: [PATCH 27/39] fixing tests --- .../unit/with_dbs/03/login/test_login_2fa.py | 9 ++++++--- .../with_dbs/03/login/test_login_2fa_resend.py | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 44cad16f5fa..1fd7a29a55b 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -81,14 +81,17 @@ def mocked_twilio_service(mocker: MockerFixture) -> dict[str, MockType]: autospec=True, ) - mock2 = mocker.patch( + mock_same_submodule = mocker.patch( "simcore_service_webserver.login._controller.auth_rest._twofa_service.send_sms_code", - autospec=False, + # NOTE: When importing the full submodule, we are mocking _twofa_service + # from .. import _twofa_service + # _twofa_service.send_sms_code(...) + new=mock, ) return { "send_sms_code_for_registration": mock, - "send_sms_code_for_login": mock, + "send_sms_code_for_login": mock_same_submodule, } diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py index 2ea53c56332..93dd420052d 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py @@ -81,13 +81,16 @@ async def test_resend_2fa_workflow( assert client.app # spy send functions - # mocker.patch( - # "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_sms_code", - # autospec=True, - # ) + mock_send_sms_code = mocker.patch( + "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_sms_code", + autospec=True, + ) mock_send_sms_code2 = mocker.patch( "simcore_service_webserver.login._controller.auth_rest._twofa_service.send_sms_code", - autospec=True, + # NOTE: When importing the full submodule, we are mocking _twofa_service + # from .. import _twofa_service + # _twofa_service.send_sms_code(...) + new=mock_send_sms_code, ) mock_send_email_code = mocker.patch( @@ -112,7 +115,11 @@ async def test_resend_2fa_workflow( ) data, _ = await assert_status(response, status.HTTP_202_ACCEPTED) next_page = NextPage[CodePageParams].model_validate(data) + assert next_page.name == CODE_2FA_SMS_CODE_REQUIRED + + assert next_page.parameters is not None + assert next_page.parameters.expiration_2fa is not None assert next_page.parameters.expiration_2fa > 0 # resend code via SMS From c8ea15a60f229cbe6bfe368ec68338ff86c04eb7 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 14:10:15 +0100 Subject: [PATCH 28/39] rename tests --- .../login/_controller/confirmation_rest.py | 4 ++-- .../login/_invitations_service.py | 18 +++++++++--------- .../src/simcore_service_webserver/login/cli.py | 4 ++-- ...ndlers.py => test_login_preregistration.py} | 0 .../{test_login_2fa.py => test_login_twofa.py} | 0 ...fa_resend.py => test_login_twofa_resend.py} | 0 6 files changed, 13 insertions(+), 13 deletions(-) rename services/web/server/tests/unit/with_dbs/03/login/{test_login_registration_handlers.py => test_login_preregistration.py} (100%) rename services/web/server/tests/unit/with_dbs/03/login/{test_login_2fa.py => test_login_twofa.py} (100%) rename services/web/server/tests/unit/with_dbs/03/login/{test_login_2fa_resend.py => test_login_twofa_resend.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py index f1bbe18fa16..9d0aa08cbce 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py @@ -39,7 +39,7 @@ MSG_PASSWORD_CHANGED, MSG_UNAUTHORIZED_PHONE_CONFIRMATION, ) -from .._invitations_service import InvitationData +from .._invitations_service import ConfirmedInvitationData from .._login_repository_legacy import ( AsyncpgStorage, ConfirmationTokenDict, @@ -76,7 +76,7 @@ def _parse_extra_credits_in_usd_or_none( ) -> PositiveInt | None: with suppress(ValidationError, JSONDecodeError): confirmation_data = confirmation.get("data", "EMPTY") or "EMPTY" - invitation = InvitationData.model_validate_json(confirmation_data) + invitation = ConfirmedInvitationData.model_validate_json(confirmation_data) return invitation.extra_credits_in_usd return None 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 eefd949d2e8..df26f4f6aae 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 @@ -61,7 +61,7 @@ class ConfirmationTokenInfoDict(ConfirmationTokenDict): url: str -class InvitationData(BaseModel): +class ConfirmedInvitationData(BaseModel): issuer: str | None = Field( None, description="Who has issued this invitation? (e.g. an email or a uid)", @@ -80,7 +80,7 @@ class InvitationData(BaseModel): class _InvitationValidator(BaseModel): action: ConfirmationAction - data: Json[InvitationData] # pylint: disable=unsubscriptable-object + data: Json[ConfirmedInvitationData] # pylint: disable=unsubscriptable-object @field_validator("action", mode="before") @classmethod @@ -91,7 +91,7 @@ def ensure_enum(cls, v): ACTION_TO_DATA_TYPE: dict[ConfirmationAction, type | None] = { - ConfirmationAction.INVITATION: InvitationData, + ConfirmationAction.INVITATION: ConfirmedInvitationData, ConfirmationAction.REGISTRATION: None, } @@ -188,7 +188,7 @@ async def create_invitation_token( :type host: Dict-like :param guest: some description of the guest, e.g. email, name or a json """ - data_model = InvitationData( + data_model = ConfirmedInvitationData( issuer=user_email, guest=tag, trial_account_days=trial_days, @@ -269,7 +269,7 @@ async def check_and_consume_invitation( db: AsyncpgStorage, cfg: LoginOptions, app: web.Application, -) -> InvitationData: +) -> ConfirmedInvitationData: """Consumes invitation: the code is validated, the invitation retrieives and then deleted since it only has one use @@ -292,7 +292,7 @@ async def check_and_consume_invitation( "Consuming invitation from service:\n%s", content.model_dump_json(indent=1), ) - return InvitationData( + return ConfirmedInvitationData( issuer=content.issuer, guest=content.guest, trial_account_days=content.trial_account_days, @@ -305,9 +305,9 @@ async def check_and_consume_invitation( invitation_code, db, cfg ): try: - invitation_data: InvitationData = _InvitationValidator.model_validate( - confirmation_token - ).data + invitation_data: ConfirmedInvitationData = ( + _InvitationValidator.model_validate(confirmation_token).data + ) return invitation_data except ValidationError as err: diff --git a/services/web/server/src/simcore_service_webserver/login/cli.py b/services/web/server/src/simcore_service_webserver/login/cli.py index 4c99dd57448..66b8f21588a 100644 --- a/services/web/server/src/simcore_service_webserver/login/cli.py +++ b/services/web/server/src/simcore_service_webserver/login/cli.py @@ -6,7 +6,7 @@ from simcore_postgres_database.models.confirmations import ConfirmationAction from yarl import URL -from ._invitations_service import InvitationData, get_invitation_url +from ._invitations_service import ConfirmedInvitationData, get_invitation_url def invitations( @@ -19,7 +19,7 @@ def invitations( ): """Generates a list of invitation links for registration""" - invitation = InvitationData(issuer=issuer_email, trial_account_days=trial_days) # type: ignore[call-arg] # guest field is deprecated + invitation = ConfirmedInvitationData(issuer=issuer_email, trial_account_days=trial_days) # type: ignore[call-arg] # guest field is deprecated codes = [generate_password(code_length) for _ in range(num_codes)] diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py similarity index 100% rename from services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py similarity index 100% rename from services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py similarity index 100% rename from services/web/server/tests/unit/with_dbs/03/login/test_login_2fa_resend.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py From 14d763fc4db64f0400edb506827a72986957c8c6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 14:30:04 +0100 Subject: [PATCH 29/39] increase coverage --- .../simcore_service_webserver/login/cli.py | 16 +++---- .../unit/with_dbs/03/login/test_login_cli.py | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/03/login/test_login_cli.py diff --git a/services/web/server/src/simcore_service_webserver/login/cli.py b/services/web/server/src/simcore_service_webserver/login/cli.py index 66b8f21588a..51dab15af2d 100644 --- a/services/web/server/src/simcore_service_webserver/login/cli.py +++ b/services/web/server/src/simcore_service_webserver/login/cli.py @@ -16,12 +16,12 @@ def invitations( user_id: int = 1, num_codes: int = 15, code_length: int = 30, -): +) -> None: """Generates a list of invitation links for registration""" invitation = ConfirmedInvitationData(issuer=issuer_email, trial_account_days=trial_days) # type: ignore[call-arg] # guest field is deprecated - codes = [generate_password(code_length) for _ in range(num_codes)] + codes: list[str] = [generate_password(code_length) for _ in range(num_codes)] typer.secho( "{:-^100}".format("invitations.md"), @@ -48,19 +48,19 @@ def invitations( utcnow = datetime.now(tz=UTC) today: datetime = utcnow.today() - print("code,user_id,action,data,created_at", file=sys.stdout) + print("code,user_id,action,data,created_at", file=sys.stdout) # noqa: T201 for n, code in enumerate(codes, start=1): - print(f'{code},{user_id},INVITATION,"{{', file=sys.stdout) - print( + print(f'{code},{user_id},INVITATION,"{{', file=sys.stdout) # noqa: T201 + print( # noqa: T201 f'""guest"": ""invitation-{today.year:04d}{today.month:02d}{today.day:02d}-{n}"" ,', file=sys.stdout, ) - print(f'""issuer"" : ""{invitation.issuer}"" ,', file=sys.stdout) - print( + print(f'""issuer"" : ""{invitation.issuer}"" ,', file=sys.stdout) # noqa: T201 + print( # noqa: T201 f'""trial_account_days"" : ""{invitation.trial_account_days}""', file=sys.stdout, ) - print('}",%s' % utcnow.isoformat(sep=" "), file=sys.stdout) + print('}}",{}'.format(utcnow.isoformat(sep=" ")), file=sys.stdout) # noqa: T201 typer.secho( "-" * 100, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_cli.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_cli.py new file mode 100644 index 00000000000..8ccd32310e2 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_cli.py @@ -0,0 +1,46 @@ +from io import StringIO + +import simcore_service_webserver.login.cli +from pytest_mock import MockerFixture +from simcore_service_webserver.login.cli import invitations +from yarl import URL + + +def test_invitations(mocker: MockerFixture): + base_url = "http://example.com" + issuer_email = "test@example.com" + trial_days = 7 + user_id = 42 + num_codes = 3 + code_length = 10 + + # Spy on generate_password to track generated codes + spy_generate_password = mocker.spy( + simcore_service_webserver.login.cli, "generate_password" + ) + + # Mock sys.stdout to capture printed output + mock_stdout = StringIO() + mocker.patch("sys.stdout", new=mock_stdout) + + invitations( + base_url=base_url, + issuer_email=issuer_email, + trial_days=trial_days, + user_id=user_id, + num_codes=num_codes, + code_length=code_length, + ) + + output = mock_stdout.getvalue() + + # Assert that the correct number of passwords were generated + assert spy_generate_password.call_count == num_codes + + # Collect generated codes + generated_codes = spy_generate_password.spy_return_list + + # Assert that the invitation links are correctly generated + for i, code in enumerate(generated_codes, start=1): + expected_url = URL(base_url).with_fragment(f"/registration/?invitation={code}") + assert f"{i:2d}. {expected_url}" in output From 6602bdc0d1acb4c77806e417b9f854e5e335a116 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 14:43:40 +0100 Subject: [PATCH 30/39] fixes tsts --- .../unit/with_dbs/03/login/test_login_twofa_resend.py | 10 +++++----- services/web/server/tests/unit/with_dbs/conftest.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py index 93dd420052d..a92b3d1fef0 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py @@ -80,17 +80,17 @@ async def test_resend_2fa_workflow( ): assert client.app - # spy send functions - mock_send_sms_code = mocker.patch( + # patch send functions + mock_send_sms_code_1 = mocker.patch( "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_sms_code", autospec=True, ) - mock_send_sms_code2 = mocker.patch( + mock_send_sms_code_2 = mocker.patch( "simcore_service_webserver.login._controller.auth_rest._twofa_service.send_sms_code", # NOTE: When importing the full submodule, we are mocking _twofa_service # from .. import _twofa_service # _twofa_service.send_sms_code(...) - new=mock_send_sms_code, + new=mock_send_sms_code_1, ) mock_send_email_code = mocker.patch( @@ -140,7 +140,7 @@ async def test_resend_2fa_workflow( assert not error assert mock_get_2fa_code.call_count == 1, "Emulates code expired" - assert mock_send_sms_code2.call_count == 1, "SMS was not sent??" + assert mock_send_sms_code_2.call_count == 2, "SMS was not sent??" # resend code via email response = await client.post( diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index 38e96c4367d..b08db8f8702 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -392,7 +392,7 @@ async def _mock_result(): @pytest.fixture def asyncpg_storage_system_mock(mocker): return mocker.patch( - "simcore_service_webserver.login.storage.AsyncpgStorage.delete_user", + "simcore_service_webserver.login._login_repository_legacy.AsyncpgStorage.delete_user", return_value="", ) From 6fe0082e35b7e80fed939c0354878d54bca42a1e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:47:37 +0100 Subject: [PATCH 31/39] tests --- .../login/_confirmation_service.py | 2 +- .../login/_controller/change_rest.py | 8 +-- .../login/_controller/registration_rest.py | 3 +- .../login/test_login_confirmation_service.py | 54 +++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py 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 cbd1fd907dd..60948c6273a 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 @@ -65,7 +65,7 @@ def get_expiration_date( return confirmation["created_at"] + lifetime -async def get_or_create_confirmation( +async def get_or_create_confirmation_without_data( cfg: LoginOptions, db: AsyncpgStorage, user_id: UserID, diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py index c32cb906da5..f6d33fdc3b4 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py @@ -180,8 +180,10 @@ def _get_error_context( try: # Confirmation token that includes code to `complete_reset_password`. # Recreated if non-existent or expired (Guideline #2) - confirmation = await _confirmation_service.get_or_create_confirmation( - cfg, db, user_id=user["id"], action="RESET_PASSWORD" + confirmation = ( + await _confirmation_service.get_or_create_confirmation_without_data( + cfg, db, user_id=user["id"], action="RESET_PASSWORD" + ) ) # Produce a link so that the front-end can hit `complete_reset_password` @@ -221,7 +223,7 @@ class ChangeEmailBody(InputSchema): email: LowerCaseEmailStr -async def submit_request_to_change_email(request: web.Request): +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) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py index c5c20258de3..50c655a587f 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py @@ -44,6 +44,7 @@ ) from .._emails_service import get_template_path, send_email_from_template from .._invitations_service import ( + ConfirmedInvitationData, check_and_consume_invitation, check_other_registrations, extract_email_from_invitation, @@ -175,7 +176,7 @@ async def register(request: web.Request): # INVITATIONS expires_at: datetime | None = None # = does not expire - invitation = None + invitation: ConfirmedInvitationData | None = None # There are 3 possible states for an invitation: # 1. Invitation is not required (i.e. the app has disabled invitations) # 2. Invitation is invalid 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 new file mode 100644 index 00000000000..9f145b5676e --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py @@ -0,0 +1,54 @@ +from aiohttp.test_utils import make_mocked_request +from aiohttp.web import Application +from models_library.users import UserID +from simcore_service_webserver.login._confirmation_service import ( + get_or_create_confirmation_without_data, + is_confirmation_expired, + make_confirmation_link, + validate_confirmation_code, +) +from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage +from simcore_service_webserver.login.settings import LoginOptions + + +async def test_confirmation_token_workflow( + db: AsyncpgStorage, login_options: LoginOptions, user_id: UserID +): + # Step 1: Create a new confirmation token + action = "RESET_PASSWORD" + confirmation = await get_or_create_confirmation_without_data( + login_options, db, user_id=user_id, action=action + ) + + assert confirmation is not None + assert confirmation["user_id"] == user_id + assert confirmation["action"] == action + + # Step 2: Check that the token is not expired + assert not is_confirmation_expired(login_options, confirmation) + + # Step 3: Validate the confirmation code + code = confirmation["code"] + validated_confirmation = await validate_confirmation_code(code, db, login_options) + + assert validated_confirmation is not None + assert validated_confirmation["code"] == code + assert validated_confirmation["user_id"] == user_id + assert validated_confirmation["action"] == action + + # Step 4: Create confirmation link + app = Application() + app.router.add_get( + "/auth/confirmation/{code}", lambda request: None, name="auth_confirmation" + ) + request = make_mocked_request( + "GET", "/auth/confirmation/{code}", app=app, headers={"Host": "example.com"} + ) + request.scheme = "http" + + # Create confirmation link + confirmation_link = make_confirmation_link(request, confirmation) + + # Assertions + assert confirmation_link.startswith("http://example.com/auth/confirmation/") + assert confirmation["code"] in confirmation_link From c85c456d3a057ff7a85018c608c1de4713474b68 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:03:50 +0100 Subject: [PATCH 32/39] test coverage --- .../login/_confirmation_service.py | 68 +++++++++---------- .../login/test_login_confirmation_service.py | 63 +++++++++++++++-- 2 files changed, 93 insertions(+), 38 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 60948c6273a..3d111fcc6f9 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 @@ -24,27 +24,38 @@ _logger = logging.getLogger(__name__) -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 +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( - {"code": code} + {"user": {"id": user_id}, "action": action} ) - if confirmation and is_confirmation_expired(cfg, confirmation): + + 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, ) - return None + 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 _url_for_confirmation(app: web.Application, code: str) -> URL: # NOTE: this is in a query parameter, and can contain ? for example. safe_code = quote(code, safe="") @@ -58,39 +69,28 @@ def make_confirmation_link( return f"{request.scheme}://{request.host}{link}" -def get_expiration_date( - cfg: LoginOptions, confirmation: ConfirmationTokenDict -) -> datetime: +def is_confirmation_expired(cfg: LoginOptions, confirmation: ConfirmationTokenDict): + age = datetime.utcnow() - confirmation["created_at"] lifetime = cfg.get_confirmation_lifetime(confirmation["action"]) - return confirmation["created_at"] + lifetime + return age > lifetime -async def get_or_create_confirmation_without_data( - cfg: LoginOptions, - db: AsyncpgStorage, - user_id: UserID, - action: ActionLiteralStr, -) -> ConfirmationTokenDict: +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( - {"user": {"id": user_id}, "action": action} + {"code": code} ) - - if confirmation is not None and is_confirmation_expired(cfg, confirmation): + if confirmation and is_confirmation_expired(cfg, confirmation): await db.delete_confirmation(confirmation) _logger.warning( "Used expired token [%s]. Deleted from confirmations table.", confirmation, ) - confirmation = None - - if confirmation is None: - confirmation = await db.create_confirmation(user_id, action=action) - + return None return confirmation - - -def is_confirmation_expired(cfg: LoginOptions, confirmation: ConfirmationTokenDict): - age = datetime.utcnow() - confirmation["created_at"] - lifetime = cfg.get_confirmation_lifetime(confirmation["action"]) - return age > lifetime 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 9f145b5676e..66e2cc351eb 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,6 +1,8 @@ +from datetime import timedelta + from aiohttp.test_utils import make_mocked_request from aiohttp.web import Application -from models_library.users import UserID +from pytest_simcore.helpers.webserver_login import UserInfoDict from simcore_service_webserver.login._confirmation_service import ( get_or_create_confirmation_without_data, is_confirmation_expired, @@ -12,9 +14,10 @@ async def test_confirmation_token_workflow( - db: AsyncpgStorage, login_options: LoginOptions, user_id: UserID + db: AsyncpgStorage, login_options: LoginOptions, registered_user: UserInfoDict ): # Step 1: Create a new confirmation token + user_id = registered_user["id"] action = "RESET_PASSWORD" confirmation = await get_or_create_confirmation_without_data( login_options, db, user_id=user_id, action=action @@ -42,9 +45,11 @@ async def test_confirmation_token_workflow( "/auth/confirmation/{code}", lambda request: None, name="auth_confirmation" ) request = make_mocked_request( - "GET", "/auth/confirmation/{code}", app=app, headers={"Host": "example.com"} + "GET", + "http://example.com/auth/confirmation/{code}", + app=app, + headers={"Host": "example.com"}, ) - request.scheme = "http" # Create confirmation link confirmation_link = make_confirmation_link(request, confirmation) @@ -52,3 +57,53 @@ async def test_confirmation_token_workflow( # Assertions assert confirmation_link.startswith("http://example.com/auth/confirmation/") assert confirmation["code"] in confirmation_link + + +async def test_expired_confirmation_token( + db: AsyncpgStorage, login_options: LoginOptions, registered_user: UserInfoDict +): + user_id = registered_user["id"] + action = "CHANGE_EMAIL" + + # Create a brand new confirmation token + confirmation_1 = await get_or_create_confirmation_without_data( + login_options, db, user_id=user_id, action=action + ) + + assert confirmation_1 is not None + assert confirmation_1["user_id"] == user_id + assert confirmation_1["action"] == action + + # Check that the token is not expired + assert not is_confirmation_expired(login_options, confirmation_1) + + confirmation_2 = await get_or_create_confirmation_without_data( + login_options, db, 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_3 = await get_or_create_confirmation_without_data( + login_options, db, user_id=user_id, action=action + ) + + # when expired, it gets renewed + assert confirmation_3 != confirmation_1 + + # now all have expired + assert ( + await validate_confirmation_code(confirmation_1["code"], db, login_options) + is None + ) + assert ( + await validate_confirmation_code(confirmation_2["code"], db, login_options) + is None + ) + assert ( + await validate_confirmation_code(confirmation_3["code"], db, login_options) + is None + ) From f6a9edf91b89393dcf4bb607236189d9bfe7a803 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:45:45 +0100 Subject: [PATCH 33/39] cleanup --- .../web/server/tests/unit/isolated/test_login_settings.py | 8 ++++---- .../with_dbs/03/login/test_login_confirmation_service.py | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/services/web/server/tests/unit/isolated/test_login_settings.py b/services/web/server/tests/unit/isolated/test_login_settings.py index 9a92dad7542..27c6d06ef29 100644 --- a/services/web/server/tests/unit/isolated/test_login_settings.py +++ b/services/web/server/tests/unit/isolated/test_login_settings.py @@ -6,7 +6,6 @@ from typing import Any import pytest -from models_library.errors import ErrorDict from pydantic import ValidationError from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from settings_library.email import SMTPSettings @@ -67,7 +66,8 @@ def test_login_settings_fails_with_2fa_but_wo_twilio( with pytest.raises(ValidationError) as exc_info: LoginSettingsForProduct.create_from_envs(LOGIN_2FA_REQUIRED=1) - errors: list[ErrorDict] = exc_info.value.errors() + assert exc_info.value + errors = exc_info.value.errors() assert len(errors) == 1 assert errors[0]["loc"] == ("LOGIN_2FA_REQUIRED",) @@ -94,7 +94,7 @@ def test_login_settings_fails_with_2fa_but_wo_confirmed_email( with pytest.raises(ValidationError) as exc_info: LoginSettingsForProduct.create_from_envs(LOGIN_2FA_REQUIRED=1) - errors: list[ErrorDict] = exc_info.value.errors() + errors = exc_info.value.errors() assert len(errors) == 1 assert errors[0]["loc"] == ("LOGIN_2FA_REQUIRED",) @@ -112,7 +112,7 @@ def test_login_settings_fails_with_2fa_but_wo_confirmed_email_using_merge( product_login_settings=product_settings, ) - errors: list[ErrorDict] = exc_info.value.errors() + errors = exc_info.value.errors() assert len(errors) == 1 assert errors[0]["loc"] == ("LOGIN_2FA_REQUIRED",) 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 66e2cc351eb..87b0ae89237 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 @@ -4,6 +4,7 @@ from aiohttp.web import Application from pytest_simcore.helpers.webserver_login import UserInfoDict from simcore_service_webserver.login._confirmation_service import ( + get_expiration_date, get_or_create_confirmation_without_data, is_confirmation_expired, make_confirmation_link, @@ -76,6 +77,7 @@ async def test_expired_confirmation_token( # Check that the token is not expired assert not is_confirmation_expired(login_options, confirmation_1) + assert get_expiration_date(login_options, confirmation_1) confirmation_2 = await get_or_create_confirmation_without_data( login_options, db, user_id=user_id, action=action From ffbd5deda8debaf98f244a73dfcd76c86717bf4d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:02:04 +0100 Subject: [PATCH 34/39] drafts test --- ...test_login_controller_confirmation_rest.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 services/web/server/tests/unit/with_dbs/03/login/test_login_controller_confirmation_rest.py 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 new file mode 100644 index 00000000000..45dfc352b61 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_controller_confirmation_rest.py @@ -0,0 +1,104 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +from collections.abc import Callable, Coroutine +from typing import Any, TypeAlias +from unittest.mock import AsyncMock, patch + +import pytest +from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.login._login_repository_legacy import ( + ActionLiteralStr, + AsyncpgStorage, + ConfirmationTokenDict, +) + +CreateTokenCallable: TypeAlias = Callable[ + [int, ActionLiteralStr], Coroutine[Any, Any, ConfirmationTokenDict] +] + + +@pytest.fixture +async def create_valid_confirmation_token(db: AsyncpgStorage) -> CreateTokenCallable: + """Fixture to create a valid confirmation token for a given action.""" + + async def _create_token( + user_id: int, action: ActionLiteralStr + ) -> ConfirmationTokenDict: + return await db.create_confirmation(user_id=user_id, action=action) + + return _create_token + + +async def test_confirm_registration( + client: TestClient, + create_valid_confirmation_token: CreateTokenCallable, + registered_user: UserInfoDict, +): + user_id = registered_user["id"] + confirmation = await create_valid_confirmation_token(user_id, "REGISTRATION") + code = confirmation["code"] + + response = await client.get(f"/v0/auth/confirmation/{code}") + assert response.status == status.HTTP_302_FOUND + assert response.headers["Location"].endswith("?registered=true") + + +async def test_confirm_change_email( + client: TestClient, + create_valid_confirmation_token: CreateTokenCallable, + registered_user: UserInfoDict, +): + user_id = registered_user["id"] + confirmation = await create_valid_confirmation_token(user_id, "CHANGE_EMAIL") + code = confirmation["code"] + + response = await client.get(f"/v0/auth/confirmation/{code}") + assert response.status == status.HTTP_302_FOUND + assert "Location" in response.headers + + +async def test_confirm_reset_password( + client: TestClient, + create_valid_confirmation_token: CreateTokenCallable, + registered_user: UserInfoDict, +): + user_id = registered_user["id"] + confirmation = await create_valid_confirmation_token(user_id, "RESET_PASSWORD") + code = confirmation["code"] + + response = await client.get(f"/v0/auth/confirmation/{code}") + assert response.status == status.HTTP_302_FOUND + assert response.headers["Location"].endswith(f"reset-password?code={code}") + + +async def test_handler_exception_logging( + client: TestClient, + create_valid_confirmation_token: CreateTokenCallable, + registered_user: UserInfoDict, +): + user_id = registered_user["id"] + confirmation = await create_valid_confirmation_token(user_id, "REGISTRATION") + code = confirmation["code"] + + with patch( + "simcore_service_webserver.login._controller.confirmation_rest._handle_confirm_registration", + new_callable=AsyncMock, + side_effect=Exception("Test exception"), + ) as mock_handler, patch( + "simcore_service_webserver.login._controller.confirmation_rest._logger.exception" + ) as mock_logger: + response = await client.get(f"/v0/auth/confirmation/{code}") + assert response.status == 503 + mock_handler.assert_called_once() + mock_logger.assert_called_once_with( + user_error_msg="Sorry, we cannot confirm your REGISTRATION." + "Please try again in a few moments.", + error=mock_handler.side_effect, + error_code=mock_handler.side_effect, + tip="Failed during email_confirmation", + ) From 548088569f6a55376d963740c14a605e76e7cbe9 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:17:03 +0100 Subject: [PATCH 35/39] adapts to user data fixtures --- .../tests/unit/with_dbs/03/login/conftest.py | 60 ++++++------ .../03/login/test_login_change_email.py | 4 +- .../03/login/test_login_change_password.py | 4 +- ...test_login_controller_confirmation_rest.py | 9 +- .../03/login/test_login_registration.py | 88 ++++++++--------- .../03/login/test_login_reset_password.py | 6 +- .../with_dbs/03/login/test_login_twofa.py | 94 +++++++++---------- .../03/login/test_login_twofa_resend.py | 4 +- 8 files changed, 139 insertions(+), 130 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 165ef39c0a4..7268bee5d2a 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 @@ -9,7 +9,9 @@ import pytest import sqlalchemy as sa from aiohttp.test_utils import TestClient +from common_library.users_enums import UserStatus from faker import Faker +from models_library.basic_types import IDStr from pytest_mock import MockerFixture from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict @@ -75,27 +77,10 @@ def app_environment( @pytest.fixture -def fake_user_email(faker: Faker) -> str: - return faker.email() - - -@pytest.fixture -def fake_user_name(fake_user_email: str) -> str: - return fake_user_email.split("@")[0] - - -@pytest.fixture -def fake_user_phone_number(faker: Faker) -> str: +def user_phone_number(faker: Faker) -> str: return faker.phone_number() -@pytest.fixture -def fake_user_password(faker: Faker) -> str: - return faker.password( - length=12, special_chars=True, digits=True, upper_case=True, lower_case=True - ) - - @pytest.fixture def fake_weak_password(faker: Faker) -> str: return faker.password( @@ -123,19 +108,40 @@ def login_options(client: TestClient) -> LoginOptions: @pytest.fixture async def registered_user( - fake_user_name: str, - fake_user_email: str, - fake_user_password: str, - fake_user_phone_number: str, + user_name: IDStr, + user_email: str, + user_password: str, + user_phone_number: str, + client: TestClient, +) -> AsyncIterable[UserInfoDict]: + async with NewUser( + user_data={ + "name": user_name, + "email": user_email, + "password": user_password, + "phone": user_phone_number, + "status": UserStatus.ACTIVE, + }, + app=client.app, + ) as user: + yield user + + +@pytest.fixture +async def unconfirmed_user( + user_name: str, + user_email: str, + user_password: str, + user_phone_number: str, client: TestClient, ) -> AsyncIterable[UserInfoDict]: async with NewUser( user_data={ - "name": fake_user_name, - "email": fake_user_email, - "password": fake_user_password, - "phone": fake_user_phone_number, - # active user + "name": user_name, + "email": user_email, + "password": user_password, + "phone": user_phone_number, + "status": UserStatus.CONFIRMATION_PENDING, }, app=client.app, ) as user: diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py index c256edb25cb..045dce69c02 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py @@ -18,8 +18,8 @@ @pytest.fixture -def new_email(fake_user_email: str) -> str: - return fake_user_email +def new_email(user_email: str) -> str: + return user_email async def test_change_email_disabled(client: TestClient, new_email: str): diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py index f496f0545d8..a9b5308a8b4 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py @@ -19,8 +19,8 @@ @pytest.fixture -def new_password(fake_user_password: str) -> str: - return fake_user_password +def new_password(user_password: str) -> str: + return user_password async def test_unauthorized_to_change_password(client: TestClient, new_password: str): 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 45dfc352b61..cd2375aefb5 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 @@ -9,6 +9,7 @@ import pytest from aiohttp.test_utils import TestClient +from common_library.users_enums import UserStatus from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status from simcore_service_webserver.login._login_repository_legacy import ( @@ -37,12 +38,14 @@ async def _create_token( async def test_confirm_registration( client: TestClient, create_valid_confirmation_token: CreateTokenCallable, - registered_user: UserInfoDict, + unconfirmed_user: UserInfoDict, ): - user_id = registered_user["id"] - confirmation = await create_valid_confirmation_token(user_id, "REGISTRATION") + assert unconfirmed_user["status"] == UserStatus.CONFIRMATION_PENDING + target_user_id = unconfirmed_user["id"] + confirmation = await create_valid_confirmation_token(target_user_id, "REGISTRATION") code = confirmation["code"] + # consuming code response = await client.get(f"/v0/auth/confirmation/{code}") assert response.status == status.HTTP_302_FOUND assert response.headers["Location"].endswith("?registered=true") 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 8c11db88065..2f92ce52295 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 @@ -52,8 +52,8 @@ def app_environment( async def test_register_entrypoint( client: TestClient, - fake_user_email: str, - fake_user_password: str, + user_email: str, + user_password: str, cleanup_db_tables: None, ): assert client.app @@ -62,18 +62,18 @@ async def test_register_entrypoint( response = await client.post( url.path, json={ - "email": fake_user_email, - "password": fake_user_password, - "confirm": fake_user_password, + "email": user_email, + "password": user_password, + "confirm": user_password, }, ) data, _ = await assert_status(response, status.HTTP_200_OK) - assert fake_user_email in data["message"] + assert user_email in data["message"] async def test_register_body_validation( - client: TestClient, fake_user_password: str, cleanup_db_tables: None + client: TestClient, user_password: str, cleanup_db_tables: None ): assert client.app url = client.app.router["auth_register"].url_for() @@ -81,8 +81,8 @@ async def test_register_body_validation( url.path, json={ "email": "not-an-email", - "password": fake_user_password, - "confirm": fake_user_password.upper(), + "password": user_password, + "confirm": user_password.upper(), }, ) @@ -144,8 +144,8 @@ async def test_registration_invitation_stays_valid_if_once_tried_with_weak_passw login_options: LoginOptions, db: AsyncpgStorage, mocker: MockerFixture, - fake_user_email: str, - fake_user_password: str, + user_email: str, + user_password: str, default_product_name: ProductName, fake_weak_password: str, cleanup_db_tables: None, @@ -177,7 +177,7 @@ async def test_registration_invitation_stays_valid_if_once_tried_with_weak_passw response = await client.post( url.path, json={ - "email": fake_user_email, + "email": user_email, "password": fake_weak_password, "confirm": fake_weak_password, "invitation": confirmation["code"], @@ -193,9 +193,9 @@ async def test_registration_invitation_stays_valid_if_once_tried_with_weak_passw response = await client.post( url.path, json={ - "email": fake_user_email, - "password": fake_user_password, - "confirm": fake_user_password, + "email": user_email, + "password": user_password, + "confirm": user_password, "invitation": confirmation["code"], }, ) @@ -207,7 +207,7 @@ async def test_registration_with_weak_password_fails( mocker: MockerFixture, cleanup_db_tables: None, default_product_name: ProductName, - fake_user_email: str, + user_email: str, fake_weak_password: str, ): assert client.app @@ -216,7 +216,7 @@ async def test_registration_with_weak_password_fails( response = await client.post( url.path, json={ - "email": fake_user_email, + "email": user_email, "password": fake_weak_password, "confirm": fake_weak_password, }, @@ -264,8 +264,8 @@ async def test_registration_without_confirmation( client: TestClient, db: AsyncpgStorage, mocker: MockerFixture, - fake_user_email: str, - fake_user_password: str, + user_email: str, + user_password: str, cleanup_db_tables: None, ): assert client.app @@ -283,16 +283,16 @@ async def test_registration_without_confirmation( response = await client.post( url.path, json={ - "email": fake_user_email, - "password": fake_user_password, - "confirm": fake_user_password, + "email": user_email, + "password": user_password, + "confirm": user_password, }, ) data, _ = await assert_status(response, status.HTTP_200_OK) assert MSG_LOGGED_IN in data["message"] - user = await db.get_user({"email": fake_user_email}) + user = await db.get_user({"email": user_email}) assert user @@ -301,8 +301,8 @@ async def test_registration_with_confirmation( db: AsyncpgStorage, capsys: pytest.CaptureFixture, mocker: MockerFixture, - fake_user_email: str, - fake_user_password: str, + user_email: str, + user_password: str, mocked_email_core_remove_comments: None, cleanup_db_tables: None, ): @@ -322,15 +322,15 @@ async def test_registration_with_confirmation( response = await client.post( url.path, json={ - "email": fake_user_email, - "password": fake_user_password, - "confirm": fake_user_password, + "email": user_email, + "password": user_password, + "confirm": user_password, }, ) data, error = unwrap_envelope(await response.json()) assert response.status == 200, (data, error) - user = await db.get_user({"email": fake_user_email}) + user = await db.get_user({"email": user_email}) assert user["status"] == UserStatus.CONFIRMATION_PENDING.name assert "verification link" in data["message"] @@ -349,7 +349,7 @@ async def test_registration_with_confirmation( assert response.status == 200 # user is active - user = await db.get_user({"email": fake_user_email}) + user = await db.get_user({"email": user_email}) assert user["status"] == UserStatus.ACTIVE.name @@ -370,8 +370,8 @@ async def test_registration_with_invitation( has_valid_invitation: bool, expected_response: HTTPStatus, mocker: MockerFixture, - fake_user_email: str, - fake_user_password: str, + user_email: str, + user_password: str, cleanup_db_tables: None, ): assert client.app @@ -400,9 +400,9 @@ async def test_registration_with_invitation( response = await client.post( url.path, json={ - "email": fake_user_email, - "password": fake_user_password, - "confirm": fake_user_password, + "email": user_email, + "password": user_password, + "confirm": user_password, "invitation": ( confirmation["code"] if has_valid_invitation else "WRONG_CODE" ), @@ -415,8 +415,8 @@ async def test_registration_with_invitation( response = await client.post( url.path, json={ - "email": "new-user" + fake_user_email, - "password": fake_user_password, + "email": "new-user" + user_email, + "password": user_password, }, ) await assert_status(response, expected_response) @@ -430,8 +430,8 @@ async def test_registraton_with_invitation_for_trial_account( login_options: LoginOptions, faker: Faker, mocker: MockerFixture, - fake_user_email: str, - fake_user_password: str, + user_email: str, + user_password: str, cleanup_db_tables: None, ): assert client.app @@ -471,9 +471,9 @@ async def test_registraton_with_invitation_for_trial_account( response = await client.post( url.path, json={ - "email": fake_user_email, - "password": fake_user_password, - "confirm": fake_user_password, + "email": user_email, + "password": user_password, + "confirm": user_password, "invitation": invitation.confirmation["code"], }, ) @@ -484,8 +484,8 @@ async def test_registraton_with_invitation_for_trial_account( response = await client.post( url.path, json={ - "email": fake_user_email, - "password": fake_user_password, + "email": user_email, + "password": user_password, }, ) await assert_status(response, status.HTTP_200_OK) 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 cf9b95f78ee..a4e8b2036bc 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 @@ -136,7 +136,7 @@ async def test_unknown_email( client: TestClient, capsys: pytest.CaptureFixture, caplog: pytest.LogCaptureFixture, - fake_user_email: str, + user_email: str, ): assert client.app reset_url = client.app.router["initiate_reset_password"].url_for() @@ -144,12 +144,12 @@ async def test_unknown_email( response = await client.post( f"{reset_url}", json={ - "email": fake_user_email, + "email": user_email, }, ) assert response.url.path == reset_url.path await assert_status( - response, status.HTTP_200_OK, MSG_EMAIL_SENT.format(email=fake_user_email) + response, status.HTTP_200_OK, MSG_EMAIL_SENT.format(email=user_email) ) # email is not sent 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 1fd7a29a55b..50295660799 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 @@ -123,9 +123,9 @@ async def test_workflow_register_and_login_with_2fa( client: TestClient, db: AsyncpgStorage, capsys: pytest.CaptureFixture, - fake_user_email: str, - fake_user_password: str, - fake_user_phone_number: str, + user_email: str, + user_password: str, + user_phone_number: str, mocked_twilio_service: dict[str, MockType], mocked_email_core_remove_comments: None, cleanup_db_tables: None, @@ -139,9 +139,9 @@ async def test_workflow_register_and_login_with_2fa( response = await client.post( f"{url}", json={ - "email": fake_user_email, - "password": fake_user_password, - "confirm": fake_user_password, + "email": user_email, + "password": user_password, + "confirm": user_password, }, ) await assert_status(response, status.HTTP_200_OK) @@ -160,7 +160,7 @@ def _get_confirmation_link_from_email(): assert response.status == status.HTTP_200_OK # check email+password registered - user = await db.get_user({"email": fake_user_email}) + user = await db.get_user({"email": user_email}) assert user["status"] == UserStatus.ACTIVE.name assert user["phone"] is None @@ -169,8 +169,8 @@ def _get_confirmation_link_from_email(): response = await client.post( f"{url}", json={ - "email": fake_user_email, - "password": fake_user_password, + "email": user_email, + "password": user_password, }, ) data, _ = await assert_status(response, status.HTTP_202_ACCEPTED) @@ -181,8 +181,8 @@ def _get_confirmation_link_from_email(): response = await client.post( f"{url}", json={ - "email": fake_user_email, - "phone": fake_user_phone_number, + "email": user_email, + "phone": user_phone_number, }, ) await assert_status(response, status.HTTP_202_ACCEPTED) @@ -191,10 +191,10 @@ def _get_confirmation_link_from_email(): assert mocked_twilio_service["send_sms_code_for_registration"].called kwargs = mocked_twilio_service["send_sms_code_for_registration"].call_args.kwargs phone, received_code = kwargs["phone_number"], kwargs["code"] - assert phone == fake_user_phone_number + assert phone == user_phone_number # check phone still NOT in db (TODO: should be in database and unconfirmed) - user = await db.get_user({"email": fake_user_email}) + user = await db.get_user({"email": user_email}) assert user["status"] == UserStatus.ACTIVE.name assert user["phone"] is None @@ -203,16 +203,16 @@ def _get_confirmation_link_from_email(): response = await client.post( f"{url}", json={ - "email": fake_user_email, - "phone": fake_user_phone_number, + "email": user_email, + "phone": user_phone_number, "code": received_code, }, ) await assert_status(response, status.HTTP_200_OK) # check user has phone confirmed - user = await db.get_user({"email": fake_user_email}) + user = await db.get_user({"email": user_email}) assert user["status"] == UserStatus.ACTIVE.name - assert user["phone"] == fake_user_phone_number + assert user["phone"] == user_phone_number # login (via SMS) --------------------------------------------------------- @@ -221,8 +221,8 @@ def _get_confirmation_link_from_email(): response = await client.post( f"{url}", json={ - "email": fake_user_email, - "password": fake_user_password, + "email": user_email, + "password": user_password, }, ) data, _ = await assert_status(response, status.HTTP_202_ACCEPTED) @@ -235,14 +235,14 @@ def _get_confirmation_link_from_email(): # assert SMS was sent kwargs = mocked_twilio_service["send_sms_code_for_login"].call_args.kwargs phone, received_code = kwargs["phone_number"], kwargs["code"] - assert phone == fake_user_phone_number + assert phone == user_phone_number # 2. check SMS code url = client.app.router["auth_login_2fa"].url_for() response = await client.post( f"{url}", json={ - "email": fake_user_email, + "email": user_email, "code": received_code, }, ) @@ -251,9 +251,9 @@ def _get_confirmation_link_from_email(): await assert_status(response, status.HTTP_200_OK) # assert users is successfully registered - user = await db.get_user({"email": fake_user_email}) - assert user["email"] == fake_user_email - assert user["phone"] == fake_user_phone_number + user = await db.get_user({"email": user_email}) + assert user["email"] == user_email + assert user["phone"] == user_phone_number assert user["status"] == UserStatus.ACTIVE.value # login (via EMAIL) --------------------------------------------------------- @@ -273,8 +273,8 @@ def _get_confirmation_link_from_email(): response = await client.post( f"{url}", json={ - "email": fake_user_email, - "password": fake_user_password, + "email": user_email, + "password": user_password, }, ) data, _ = await assert_status(response, status.HTTP_202_ACCEPTED) @@ -302,8 +302,8 @@ def _get_confirmation_link_from_email(): response = await client.post( f"{url}", json={ - "email": fake_user_email, - "password": fake_user_password, + "email": user_email, + "password": user_password, }, ) data, _ = await assert_status(response, status.HTTP_200_OK) @@ -312,9 +312,9 @@ def _get_confirmation_link_from_email(): async def test_can_register_same_phone_in_different_accounts( client: TestClient, - fake_user_email: str, - fake_user_password: str, - fake_user_phone_number: str, + user_email: str, + user_password: str, + user_phone_number: str, mocked_twilio_service: dict[str, MockType], cleanup_db_tables: None, ): @@ -327,15 +327,15 @@ async def test_can_register_same_phone_in_different_accounts( async with AsyncExitStack() as users_stack: # some user ALREADY registered with the same phone await users_stack.enter_async_context( - NewUser(user_data={"phone": fake_user_phone_number}, app=client.app) + NewUser(user_data={"phone": user_phone_number}, app=client.app) ) # some registered user w/o phone await users_stack.enter_async_context( NewUser( user_data={ - "email": fake_user_email, - "password": fake_user_password, + "email": user_email, + "password": user_password, "phone": None, }, app=client.app, @@ -347,8 +347,8 @@ async def test_can_register_same_phone_in_different_accounts( response = await client.post( f"{url}", json={ - "email": fake_user_email, - "password": fake_user_password, + "email": user_email, + "password": user_password, }, ) await assert_status(response, status.HTTP_202_ACCEPTED) @@ -358,8 +358,8 @@ async def test_can_register_same_phone_in_different_accounts( response = await client.post( f"{url}", json={ - "email": fake_user_email, - "phone": fake_user_phone_number, + "email": user_email, + "phone": user_phone_number, }, ) data, error = await assert_status(response, status.HTTP_202_ACCEPTED) @@ -416,9 +416,9 @@ async def test_send_email_code( async def test_2fa_sms_failure_during_login( client: TestClient, - fake_user_email: str, - fake_user_password: str, - fake_user_phone_number: str, + user_email: str, + user_password: str, + user_phone_number: str, caplog: pytest.LogCaptureFixture, mocker: MockerFixture, cleanup_db_tables: None, @@ -439,9 +439,9 @@ async def test_2fa_sms_failure_during_login( # A registered user ... async with NewUser( user_data={ - "email": fake_user_email, - "password": fake_user_password, - "phone": fake_user_phone_number, + "email": user_email, + "password": user_password, + "phone": user_phone_number, }, app=client.app, ): @@ -451,8 +451,8 @@ async def test_2fa_sms_failure_during_login( response = await client.post( f"{url}", json={ - "email": fake_user_email, - "password": fake_user_password, + "email": user_email, + "password": user_password, }, ) @@ -465,4 +465,4 @@ async def test_2fa_sms_failure_during_login( assert error["errors"][0]["message"].startswith(MSG_2FA_UNAVAILABLE[:10]) # Expects logs like 'Failed while setting up 2FA code and sending SMS to 157XXXXXXXX3 [OEC:140392495277888]' - assert f"{fake_user_phone_number[:3]}" in caplog.text + assert f"{user_phone_number[:3]}" in caplog.text diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py index a92b3d1fef0..3ec5f861cac 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py @@ -56,7 +56,7 @@ def postgres_db(postgres_db: sa.engine.Engine): async def test_resend_2fa_entrypoint_is_protected( client: TestClient, - fake_user_email: str, + user_email: str, ): assert client.app @@ -64,7 +64,7 @@ async def test_resend_2fa_entrypoint_is_protected( response = await client.post( f"{url}", json={ - "email": fake_user_email, + "email": user_email, "send_as": "SMS", }, ) From 13ec44c43f2f82200d91f19d62078d8fe96473d3 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:26:56 +0100 Subject: [PATCH 36/39] drafting tests --- ...test_login_controller_confirmation_rest.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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 cd2375aefb5..f0cdb5d40eb 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 @@ -45,10 +45,14 @@ async def test_confirm_registration( confirmation = await create_valid_confirmation_token(target_user_id, "REGISTRATION") code = confirmation["code"] - # consuming code + # clicks link to confirm registration response = await client.get(f"/v0/auth/confirmation/{code}") - assert response.status == status.HTTP_302_FOUND - assert response.headers["Location"].endswith("?registered=true") + assert response.status == status.HTTP_200_OK + + # checks redirection + assert len(response.history) == 1 + assert response.history[0].status == status.HTTP_302_FOUND + assert response.history[0].headers["Location"].endswith("/#?registered=true") async def test_confirm_change_email( @@ -56,13 +60,20 @@ async def test_confirm_change_email( create_valid_confirmation_token: CreateTokenCallable, registered_user: UserInfoDict, ): + assert registered_user["status"] == UserStatus.ACTIVE + user_id = registered_user["id"] confirmation = await create_valid_confirmation_token(user_id, "CHANGE_EMAIL") code = confirmation["code"] + # clicks link to confirm registration response = await client.get(f"/v0/auth/confirmation/{code}") - assert response.status == status.HTTP_302_FOUND - assert "Location" in response.headers + assert response.status == status.HTTP_200_OK + + # checks redirection to front-end, which will prompt and then finalize change-email + assert len(response.history) == 1 + assert response.history[0].status == status.HTTP_302_FOUND + assert response.history[0].headers["Location"].endswith("/#?registered=true") async def test_confirm_reset_password( From 03afb8684cfca77ea82a3abfe369d901cfe03b84 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 26 Mar 2025 09:41:05 +0100 Subject: [PATCH 37/39] building test --- services/web/server/tests/unit/conftest.py | 7 ++ .../tests/unit/isolated/products/conftest.py | 6 -- ...test_login_controller_confirmation_rest.py | 79 ++++++++++++++----- .../tests/unit/with_dbs/03/tags/test_tags.py | 13 ++- .../04/folders/test_folders_repository.py | 5 -- 5 files changed, 70 insertions(+), 40 deletions(-) diff --git a/services/web/server/tests/unit/conftest.py b/services/web/server/tests/unit/conftest.py index 4c6dd952f46..7f3d2381991 100644 --- a/services/web/server/tests/unit/conftest.py +++ b/services/web/server/tests/unit/conftest.py @@ -13,9 +13,11 @@ import pytest import yaml +from models_library.products import ProductName from pytest_mock import MockFixture, MockType from pytest_simcore.helpers.webserver_projects import empty_project_data from simcore_service_webserver.application_settings_utils import AppConfigDict +from simcore_service_webserver.constants import FRONTEND_APP_DEFAULT CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent @@ -86,3 +88,8 @@ def disabled_setup_garbage_collector(mocker: MockFixture) -> MockType: autospec=True, return_value=False, ) + + +@pytest.fixture(scope="session") +def product_name() -> ProductName: + return ProductName(FRONTEND_APP_DEFAULT) diff --git a/services/web/server/tests/unit/isolated/products/conftest.py b/services/web/server/tests/unit/isolated/products/conftest.py index 8fe754e9307..ddc10b81fc9 100644 --- a/services/web/server/tests/unit/isolated/products/conftest.py +++ b/services/web/server/tests/unit/isolated/products/conftest.py @@ -13,16 +13,10 @@ from models_library.products import ProductName from pytest_simcore.helpers.faker_factories import random_product from simcore_postgres_database.models.products import products as products_table -from simcore_service_webserver.constants import FRONTEND_APP_DEFAULT from sqlalchemy import String from sqlalchemy.dialects import postgresql -@pytest.fixture(scope="session") -def product_name() -> ProductName: - return ProductName(FRONTEND_APP_DEFAULT) - - @pytest.fixture def product_db_server_defaults() -> dict[str, Any]: server_defaults = {} 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 f0cdb5d40eb..38d3aadbc5e 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 @@ -10,6 +10,7 @@ import pytest from aiohttp.test_utils import TestClient from common_library.users_enums import UserStatus +from models_library.products import ProductName from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status from simcore_service_webserver.login._login_repository_legacy import ( @@ -17,9 +18,12 @@ AsyncpgStorage, ConfirmationTokenDict, ) +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], Coroutine[Any, Any, ConfirmationTokenDict] + [int, ActionLiteralStr, str | None], Coroutine[Any, Any, ConfirmationTokenDict] ] @@ -28,9 +32,9 @@ async def create_valid_confirmation_token(db: AsyncpgStorage) -> CreateTokenCall """Fixture to create a valid confirmation token for a given action.""" async def _create_token( - user_id: int, action: ActionLiteralStr + user_id: int, action: ActionLiteralStr, data: str | None = None ) -> ConfirmationTokenDict: - return await db.create_confirmation(user_id=user_id, action=action) + return await db.create_confirmation(user_id=user_id, action=action, data=data) return _create_token @@ -39,10 +43,13 @@ async def test_confirm_registration( client: TestClient, create_valid_confirmation_token: CreateTokenCallable, unconfirmed_user: UserInfoDict, + product_name: ProductName, ): assert unconfirmed_user["status"] == UserStatus.CONFIRMATION_PENDING target_user_id = unconfirmed_user["id"] - confirmation = await create_valid_confirmation_token(target_user_id, "REGISTRATION") + confirmation = await create_valid_confirmation_token( + target_user_id, "REGISTRATION", None + ) code = confirmation["code"] # clicks link to confirm registration @@ -54,6 +61,24 @@ async def test_confirm_registration( assert response.history[0].status == status.HTTP_302_FOUND assert response.history[0].headers["Location"].endswith("/#?registered=true") + # checks _handle_confirm_registration updated status + assert client.app + user = await _users_service.get_user(client.app, user_id=unconfirmed_user["id"]) + assert user["status"] == UserStatus.ACTIVE + + # checks that the user has one wallet created (via SIGNAL_ON_USER_CONFIRMATION) + wallets = await _wallets_service.list_wallets_for_user( + client.app, user_id=unconfirmed_user["id"], product_name=product_name + ) + assert len(wallets) == 1 + + # delete to allow teardown + await _wallets_repository.delete_wallet( + client.app, + wallet_id=wallets[0].wallet_id, + product_name=product_name, + ) + async def test_confirm_change_email( client: TestClient, @@ -63,17 +88,19 @@ async def test_confirm_change_email( assert registered_user["status"] == UserStatus.ACTIVE user_id = registered_user["id"] - confirmation = await create_valid_confirmation_token(user_id, "CHANGE_EMAIL") + confirmation = await create_valid_confirmation_token( + user_id, "CHANGE_EMAIL", "new_" + registered_user["email"] + ) code = confirmation["code"] # clicks link to confirm registration response = await client.get(f"/v0/auth/confirmation/{code}") assert response.status == status.HTTP_200_OK - # checks redirection to front-end, which will prompt and then finalize change-email - assert len(response.history) == 1 - assert response.history[0].status == status.HTTP_302_FOUND - assert response.history[0].headers["Location"].endswith("/#?registered=true") + # checks _handle_confirm_registration updated status + assert client.app + user = await _users_service.get_user(client.app, user_id=registered_user["id"]) + assert user["email"] == "new_" + registered_user["email"] async def test_confirm_reset_password( @@ -82,12 +109,22 @@ async def test_confirm_reset_password( registered_user: UserInfoDict, ): user_id = registered_user["id"] - confirmation = await create_valid_confirmation_token(user_id, "RESET_PASSWORD") + confirmation = await create_valid_confirmation_token( + user_id, "RESET_PASSWORD", None + ) code = confirmation["code"] response = await client.get(f"/v0/auth/confirmation/{code}") - assert response.status == status.HTTP_302_FOUND - assert response.headers["Location"].endswith(f"reset-password?code={code}") + assert response.status == status.HTTP_200_OK + + # checks redirection + assert len(response.history) == 1 + assert response.history[0].status == status.HTTP_302_FOUND + assert ( + response.history[0] + .headers["Location"] + .endswith(f"/#reset-password?code={code}") + ) async def test_handler_exception_logging( @@ -96,7 +133,7 @@ async def test_handler_exception_logging( registered_user: UserInfoDict, ): user_id = registered_user["id"] - confirmation = await create_valid_confirmation_token(user_id, "REGISTRATION") + confirmation = await create_valid_confirmation_token(user_id, "REGISTRATION", None) code = confirmation["code"] with patch( @@ -107,12 +144,12 @@ async def test_handler_exception_logging( "simcore_service_webserver.login._controller.confirmation_rest._logger.exception" ) as mock_logger: response = await client.get(f"/v0/auth/confirmation/{code}") - assert response.status == 503 + assert response.status == status.HTTP_200_OK + + # checks redirection + assert len(response.history) == 1 + assert response.history[0].status == status.HTTP_302_FOUND + assert "/#/error?message=" in response.history[0].headers["Location"] + mock_handler.assert_called_once() - mock_logger.assert_called_once_with( - user_error_msg="Sorry, we cannot confirm your REGISTRATION." - "Please try again in a few moments.", - error=mock_handler.side_effect, - error_code=mock_handler.side_effect, - tip="Failed during email_confirmation", - ) + mock_logger.assert_called_once() diff --git a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py index 9669f1eea90..323bd0e3375 100644 --- a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py +++ b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py @@ -423,20 +423,17 @@ async def test_cannot_share_tag_with_everyone( assert error -@pytest.fixture -def product_name() -> str: - return "osparc" - - @pytest.mark.parametrize( "user_role,expected_status", [ ( role, # granted only to: - status.HTTP_403_FORBIDDEN - if role < UserRole.TESTER - else status.HTTP_201_CREATED, + ( + status.HTTP_403_FORBIDDEN + if role < UserRole.TESTER + else status.HTTP_201_CREATED + ), ) for role in UserRole if role >= UserRole.USER diff --git a/services/web/server/tests/unit/with_dbs/04/folders/test_folders_repository.py b/services/web/server/tests/unit/with_dbs/04/folders/test_folders_repository.py index c1485b0a2af..a7a32ad562f 100644 --- a/services/web/server/tests/unit/with_dbs/04/folders/test_folders_repository.py +++ b/services/web/server/tests/unit/with_dbs/04/folders/test_folders_repository.py @@ -18,11 +18,6 @@ def user_role(): return UserRole.USER -@pytest.fixture -def product_name(): - return "osparc" - - async def test_batch_get_trashed_by_primary_gid( client: TestClient, logged_user: dict[str, Any], From 42568b7d061408d6e2f483b422f5a857800c9cb2 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 26 Mar 2025 10:14:29 +0100 Subject: [PATCH 38/39] mocks --- .../test_login_handlers_registration_invitations.py | 8 ++++---- .../login/test_login_controller_confirmation_rest.py | 4 ++-- .../with_dbs/03/login/test_login_preregistration.py | 2 +- .../unit/with_dbs/03/login/test_login_registration.py | 10 +++++----- .../tests/unit/with_dbs/03/login/test_login_twofa.py | 4 ++-- .../unit/with_dbs/03/login/test_login_twofa_resend.py | 8 ++++---- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py index 5f1daa9301c..1cbfae911a0 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py @@ -31,7 +31,7 @@ async def test_check_registration_invitation_when_not_required( mocker: MockerFixture, ): mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -61,7 +61,7 @@ async def test_check_registration_invitations_with_old_code( mocker: MockerFixture, ): mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct.create_from_envs( LOGIN_REGISTRATION_INVITATION_REQUIRED=True, # <-- @@ -87,7 +87,7 @@ async def test_check_registration_invitation_and_get_email( ): mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct.create_from_envs( LOGIN_REGISTRATION_INVITATION_REQUIRED=True, # <-- @@ -122,7 +122,7 @@ async def test_registration_to_different_product( assert client.app mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, 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 38d3aadbc5e..826f98498c5 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 @@ -137,11 +137,11 @@ async def test_handler_exception_logging( code = confirmation["code"] with patch( - "simcore_service_webserver.login._controller.confirmation_rest._handle_confirm_registration", + "simcore_service_webserver.login._controller.rest.confirmation._handle_confirm_registration", new_callable=AsyncMock, side_effect=Exception("Test exception"), ) as mock_handler, patch( - "simcore_service_webserver.login._controller.confirmation_rest._logger.exception" + "simcore_service_webserver.login._controller.rest.confirmation._logger.exception" ) as mock_logger: response = await client.get(f"/v0/auth/confirmation/{code}") assert response.status == status.HTTP_200_OK diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py index 78fa7de3bcc..565df3495bc 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py @@ -57,7 +57,7 @@ def mocked_send_email(mocker: MockerFixture) -> MagicMock: @pytest.fixture def mocked_captcha_session(mocker: MockerFixture) -> MagicMock: return mocker.patch( - "simcore_service_webserver.login._controller.preregistration_rest.get_session", + "simcore_service_webserver.login._controller.rest.preregistration.get_session", spec=True, return_value={"captcha": "123456"}, ) 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 2f92ce52295..783acce546c 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 @@ -152,7 +152,7 @@ async def test_registration_invitation_stays_valid_if_once_tried_with_weak_passw ): assert client.app mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -270,7 +270,7 @@ async def test_registration_without_confirmation( ): assert client.app mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -308,7 +308,7 @@ async def test_registration_with_confirmation( ): assert client.app mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=True, @@ -376,7 +376,7 @@ async def test_registration_with_invitation( ): assert client.app mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -436,7 +436,7 @@ async def test_registraton_with_invitation_for_trial_account( ): assert client.app mocker.patch( - "simcore_service_webserver.login._controller.registration_rest.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, 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 50295660799..3bd849f086b 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 @@ -77,12 +77,12 @@ def postgres_db(postgres_db: sa.engine.Engine): @pytest.fixture def mocked_twilio_service(mocker: MockerFixture) -> dict[str, MockType]: mock = mocker.patch( - "simcore_service_webserver.login._controller.registration_rest._twofa_service.send_sms_code", + "simcore_service_webserver.login._controller.rest.registration._twofa_service.send_sms_code", autospec=True, ) mock_same_submodule = mocker.patch( - "simcore_service_webserver.login._controller.auth_rest._twofa_service.send_sms_code", + "simcore_service_webserver.login._controller.rest.auth._twofa_service.send_sms_code", # NOTE: When importing the full submodule, we are mocking _twofa_service # from .. import _twofa_service # _twofa_service.send_sms_code(...) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py index 3ec5f861cac..250174f2a1f 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py @@ -82,11 +82,11 @@ async def test_resend_2fa_workflow( # patch send functions mock_send_sms_code_1 = mocker.patch( - "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_sms_code", + "simcore_service_webserver.login._controller.rest.twofa._twofa_service.send_sms_code", autospec=True, ) mock_send_sms_code_2 = mocker.patch( - "simcore_service_webserver.login._controller.auth_rest._twofa_service.send_sms_code", + "simcore_service_webserver.login._controller.rest.auth._twofa_service.send_sms_code", # NOTE: When importing the full submodule, we are mocking _twofa_service # from .. import _twofa_service # _twofa_service.send_sms_code(...) @@ -94,12 +94,12 @@ async def test_resend_2fa_workflow( ) mock_send_email_code = mocker.patch( - "simcore_service_webserver.login._controller.twofa_rest._twofa_service.send_email_code", + "simcore_service_webserver.login._controller.rest.twofa._twofa_service.send_email_code", autospec=True, ) mock_get_2fa_code = mocker.patch( - "simcore_service_webserver.login._controller.twofa_rest._twofa_service.get_2fa_code", + "simcore_service_webserver.login._controller.rest.twofa._twofa_service.get_2fa_code", autospec=True, return_value=None, # <-- Emulates code expired ) From 54904a599e95ad0b890164463e86b609f4d5e970 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 26 Mar 2025 10:17:01 +0100 Subject: [PATCH 39/39] rename --- api/specs/web-server/_auth.py | 10 +++--- .../login/_controller/rest/__init__.py | 0 .../{auth_rest.py => rest/auth.py} | 26 +++++++------- .../{change_rest.py => rest/change.py} | 32 ++++++++--------- .../confirmation.py} | 28 +++++++-------- .../preregistration.py} | 34 +++++++++++-------- .../registration.py} | 34 +++++++++---------- .../{twofa_rest.py => rest/twofa.py} | 20 +++++------ .../simcore_service_webserver/login/plugin.py | 26 +++++++------- ...login_handlers_registration_invitations.py | 2 +- .../03/login/test_login_twofa_resend.py | 2 +- .../tests/unit/with_dbs/03/test_email.py | 2 +- 12 files changed, 110 insertions(+), 106 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/login/_controller/rest/__init__.py rename services/web/server/src/simcore_service_webserver/login/_controller/{auth_rest.py => rest/auth.py} (94%) rename services/web/server/src/simcore_service_webserver/login/_controller/{change_rest.py => rest/change.py} (92%) rename services/web/server/src/simcore_service_webserver/login/_controller/{confirmation_rest.py => rest/confirmation.py} (93%) rename services/web/server/src/simcore_service_webserver/login/_controller/{preregistration_rest.py => rest/preregistration.py} (87%) rename services/web/server/src/simcore_service_webserver/login/_controller/{registration_rest.py => rest/registration.py} (94%) rename services/web/server/src/simcore_service_webserver/login/_controller/{twofa_rest.py => rest/twofa.py} (89%) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index f9f04bf1a79..40f08406084 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -15,29 +15,29 @@ from models_library.rest_error import EnvelopedError, Log from pydantic import BaseModel, Field, confloat from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.login._controller.auth_rest import ( +from simcore_service_webserver.login._controller.rest.auth import ( LoginBody, LoginNextPage, LoginTwoFactorAuthBody, LogoutBody, ) -from simcore_service_webserver.login._controller.change_rest import ( +from simcore_service_webserver.login._controller.rest.change import ( ChangeEmailBody, ChangePasswordBody, ResetPasswordBody, ) -from simcore_service_webserver.login._controller.confirmation_rest import ( +from simcore_service_webserver.login._controller.rest.confirmation import ( PhoneConfirmationBody, ResetPasswordConfirmation, ) -from simcore_service_webserver.login._controller.registration_rest import ( +from simcore_service_webserver.login._controller.rest.registration import ( InvitationCheck, InvitationInfo, RegisterBody, RegisterPhoneBody, RegisterPhoneNextPage, ) -from simcore_service_webserver.login._controller.twofa_rest import Resend2faBody +from simcore_service_webserver.login._controller.rest.twofa import Resend2faBody router = APIRouter(prefix=f"/{API_VTAG}", tags=["auth"]) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/__init__.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py index 731b421eb51..2a737579bed 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/auth_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py @@ -12,18 +12,18 @@ from servicelib.request_keys import RQT_USERID_KEY from simcore_postgres_database.models.users import UserRole -from ..._meta import API_VTAG -from ...products import products_web -from ...products.models import Product -from ...security import api as security_service -from ...session.access_policies import ( +from ...._meta import API_VTAG +from ....products import products_web +from ....products.models import Product +from ....security import api as security_service +from ....session.access_policies import ( on_success_grant_session_access_to, session_access_required, ) -from ...users import preferences_api as user_preferences_api -from ...utils_aiohttp import NextPage -from .. import _auth_service, _login_service, _security_service, _twofa_service -from .._constants import ( +from ....users import preferences_api as user_preferences_api +from ....utils_aiohttp import NextPage +from ... import _auth_service, _login_service, _security_service, _twofa_service +from ..._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, CODE_PHONE_NUMBER_REQUIRED, @@ -37,10 +37,10 @@ MSG_WRONG_2FA_CODE__EXPIRED, MSG_WRONG_2FA_CODE__INVALID, ) -from .._models import InputSchema -from ..decorators import login_required -from ..errors import handle_login_exceptions -from ..settings import LoginSettingsForProduct, get_plugin_settings +from ..._models import InputSchema +from ...decorators import login_required +from ...errors import handle_login_exceptions +from ...settings import LoginSettingsForProduct, get_plugin_settings log = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py similarity index 92% rename from services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py index f6d33fdc3b4..0bebbb55738 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/change_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py @@ -10,16 +10,16 @@ from servicelib.request_keys import RQT_USERID_KEY from simcore_postgres_database.utils_users import UsersRepo -from ..._meta import API_VTAG -from ...db.plugin import get_database_engine -from ...products import products_web -from ...products.models import Product -from ...security.api import check_password, encrypt_password -from ...users import api as users_service -from ...utils import HOUR -from ...utils_rate_limiting import global_rate_limit_route -from .. import _confirmation_service -from .._constants import ( +from ...._meta import API_VTAG +from ....db.plugin import get_database_engine +from ....products import products_web +from ....products.models import Product +from ....security.api import check_password, encrypt_password +from ....users import api as users_service +from ....utils import HOUR +from ....utils_rate_limiting import global_rate_limit_route +from ... import _confirmation_service +from ..._constants import ( MSG_CANT_SEND_MAIL, MSG_CHANGE_EMAIL_REQUESTED, MSG_EMAIL_SENT, @@ -27,17 +27,17 @@ MSG_PASSWORD_CHANGED, MSG_WRONG_PASSWORD, ) -from .._emails_service import get_template_path, send_email_from_template -from .._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from .._login_service import ( +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, flash_response, validate_user_status, ) -from .._models import InputSchema, create_password_match_validator -from ..decorators import login_required -from ..settings import LoginOptions, get_plugin_options +from ..._models import InputSchema, create_password_match_validator +from ...decorators import login_required +from ...settings import LoginOptions, get_plugin_options _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py similarity index 93% rename from services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py index 9d0aa08cbce..10a31cd2958 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/confirmation_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py @@ -26,26 +26,26 @@ from simcore_postgres_database.aiopg_errors import UniqueViolation from yarl import URL -from ...products import products_web -from ...products.models import Product -from ...security.api import encrypt_password -from ...session.access_policies import session_access_required -from ...utils import HOUR, MINUTE -from ...utils_aiohttp import create_redirect_to_page_response -from ...utils_rate_limiting import global_rate_limit_route -from .. import _confirmation_service, _security_service, _twofa_service -from .._constants import ( +from ....products import products_web +from ....products.models import Product +from ....security.api import encrypt_password +from ....session.access_policies import session_access_required +from ....utils import HOUR, MINUTE +from ....utils_aiohttp import create_redirect_to_page_response +from ....utils_rate_limiting import global_rate_limit_route +from ... import _confirmation_service, _security_service, _twofa_service +from ..._constants import ( MSG_PASSWORD_CHANGE_NOT_ALLOWED, MSG_PASSWORD_CHANGED, MSG_UNAUTHORIZED_PHONE_CONFIRMATION, ) -from .._invitations_service import ConfirmedInvitationData -from .._login_repository_legacy import ( +from ..._invitations_service import ConfirmedInvitationData +from ..._login_repository_legacy import ( AsyncpgStorage, ConfirmationTokenDict, get_plugin_storage, ) -from .._login_service import ( +from ..._login_service import ( ACTIVE, CHANGE_EMAIL, REGISTRATION, @@ -53,8 +53,8 @@ flash_response, notify_user_confirmation, ) -from .._models import InputSchema, check_confirm_password_match -from ..settings import ( +from ..._models import InputSchema, check_confirm_password_match +from ...settings import ( LoginOptions, LoginSettingsForProduct, get_plugin_options, diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/preregistration_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py similarity index 87% rename from services/web/server/src/simcore_service_webserver/login/_controller/preregistration_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py index 3b2ebd25d6c..3f8d72309c4 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/preregistration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py @@ -16,21 +16,25 @@ from servicelib.request_keys import RQT_USERID_KEY from servicelib.utils import fire_and_forget_task -from ..._meta import API_VTAG -from ...constants import RQ_PRODUCT_KEY -from ...products import products_web -from ...products.models import Product -from ...security import api as security_service -from ...security.decorators import permission_required -from ...session.api import get_session -from ...users.api import get_user_credentials, set_user_as_deleted -from ...utils import MINUTE -from ...utils_rate_limiting import global_rate_limit_route -from .. import _preregistration_service -from .._constants import CAPTCHA_SESSION_KEY, MSG_LOGGED_OUT, MSG_WRONG_CAPTCHA__INVALID -from .._login_service import flash_response, notify_user_logout -from ..decorators import login_required -from ..settings import LoginSettingsForProduct, get_plugin_settings +from ...._meta import API_VTAG +from ....constants import RQ_PRODUCT_KEY +from ....products import products_web +from ....products.models import Product +from ....security import api as security_service +from ....security.decorators import permission_required +from ....session.api import get_session +from ....users.api import get_user_credentials, set_user_as_deleted +from ....utils import MINUTE +from ....utils_rate_limiting import global_rate_limit_route +from ... import _preregistration_service +from ..._constants import ( + CAPTCHA_SESSION_KEY, + MSG_LOGGED_OUT, + MSG_WRONG_CAPTCHA__INVALID, +) +from ..._login_service import flash_response, notify_user_logout +from ...decorators import login_required +from ...settings import LoginSettingsForProduct, get_plugin_settings _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py index 50c655a587f..2f5f3fc850d 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/registration_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py @@ -20,20 +20,20 @@ from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.models.users import UserStatus -from ..._meta import API_VTAG -from ...groups.api import auto_add_user_to_groups, auto_add_user_to_product_group -from ...invitations.api import is_service_invitation_code -from ...products import products_web -from ...products.models import Product -from ...session.access_policies import ( +from ...._meta import API_VTAG +from ....groups.api import auto_add_user_to_groups, auto_add_user_to_product_group +from ....invitations.api import is_service_invitation_code +from ....products import products_web +from ....products.models import Product +from ....session.access_policies import ( on_success_grant_session_access_to, session_access_required, ) -from ...utils import MINUTE -from ...utils_aiohttp import NextPage, envelope_json_response -from ...utils_rate_limiting import global_rate_limit_route -from .. import _auth_service, _confirmation_service, _security_service, _twofa_service -from .._constants import ( +from ....utils import MINUTE +from ....utils_aiohttp import NextPage, envelope_json_response +from ....utils_rate_limiting import global_rate_limit_route +from ... import _auth_service, _confirmation_service, _security_service, _twofa_service +from ..._constants import ( CODE_2FA_SMS_CODE_REQUIRED, MAX_2FA_CODE_RESEND, MAX_2FA_CODE_TRIALS, @@ -42,26 +42,26 @@ MSG_UNAUTHORIZED_REGISTER_PHONE, MSG_WEAK_PASSWORD, ) -from .._emails_service import get_template_path, send_email_from_template -from .._invitations_service import ( +from ..._emails_service import get_template_path, send_email_from_template +from ..._invitations_service import ( ConfirmedInvitationData, check_and_consume_invitation, check_other_registrations, extract_email_from_invitation, ) -from .._login_repository_legacy import ( +from ..._login_repository_legacy import ( AsyncpgStorage, ConfirmationTokenDict, get_plugin_storage, ) -from .._login_service import ( +from ..._login_service import ( envelope_response, flash_response, get_user_name_from_email, notify_user_confirmation, ) -from .._models import InputSchema, check_confirm_password_match -from ..settings import ( +from ..._models import InputSchema, check_confirm_password_match +from ...settings import ( LoginOptions, LoginSettingsForProduct, get_plugin_options, diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/twofa_rest.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py similarity index 89% rename from services/web/server/src/simcore_service_webserver/login/_controller/twofa_rest.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py index fb5f942964a..bb9cc3ff01c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/twofa_rest.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py @@ -9,22 +9,22 @@ from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from ...products import products_web -from ...products.models import Product -from ...session.access_policies import session_access_required -from .. import _twofa_service -from .._constants import ( +from ....products import products_web +from ....products.models import Product +from ....session.access_policies import session_access_required +from ... import _twofa_service +from ..._constants import ( CODE_2FA_EMAIL_CODE_REQUIRED, CODE_2FA_SMS_CODE_REQUIRED, MSG_2FA_CODE_SENT, MSG_EMAIL_SENT, MSG_UNKNOWN_EMAIL, ) -from .._login_repository_legacy import AsyncpgStorage, get_plugin_storage -from .._login_service import envelope_response -from .._models import InputSchema -from ..errors import handle_login_exceptions -from ..settings import LoginSettingsForProduct, get_plugin_settings +from ..._login_repository_legacy import AsyncpgStorage, get_plugin_storage +from ..._login_service import envelope_response +from ..._models import InputSchema +from ...errors import handle_login_exceptions +from ...settings import LoginSettingsForProduct, get_plugin_settings _logger = logging.getLogger(__name__) 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 dd2fb710288..60c8db47b6f 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -24,13 +24,13 @@ from ..redis import setup_redis from ..rest.plugin import setup_rest from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY -from ._controller import ( - auth_rest, - change_rest, - confirmation_rest, - preregistration_rest, - registration_rest, - twofa_rest, +from ._controller.rest import ( + auth, + change, + confirmation, + preregistration, + registration, + twofa, ) from ._login_repository_legacy import APP_LOGIN_STORAGE_KEY, AsyncpgStorage from .settings import ( @@ -138,12 +138,12 @@ def setup_login(app: web.Application): # routes - app.router.add_routes(auth_rest.routes) - app.router.add_routes(confirmation_rest.routes) - app.router.add_routes(registration_rest.routes) - app.router.add_routes(preregistration_rest.routes) - app.router.add_routes(change_rest.routes) - app.router.add_routes(twofa_rest.routes) + app.router.add_routes(auth.routes) + app.router.add_routes(confirmation.routes) + app.router.add_routes(registration.routes) + app.router.add_routes(preregistration.routes) + app.router.add_routes(change.routes) + app.router.add_routes(twofa.routes) _setup_login_options(app) setup_login_storage(app) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py index 1cbfae911a0..d0490d01ffd 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py @@ -18,7 +18,7 @@ from servicelib.aiohttp import status from servicelib.rest_constants import X_PRODUCT_NAME_HEADER from simcore_service_webserver.invitations.api import generate_invitation -from simcore_service_webserver.login._controller.registration_rest import ( +from simcore_service_webserver.login._controller.rest.registration import ( InvitationCheck, InvitationInfo, ) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py index 250174f2a1f..4f413eb3500 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py @@ -14,7 +14,7 @@ from simcore_postgres_database.models.products import ProductLoginSettingsDict, products from simcore_service_webserver.application_settings import ApplicationSettings from simcore_service_webserver.login._constants import CODE_2FA_SMS_CODE_REQUIRED -from simcore_service_webserver.login._controller.auth_rest import ( +from simcore_service_webserver.login._controller.rest.auth import ( CodePageParams, NextPage, ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_email.py b/services/web/server/tests/unit/with_dbs/03/test_email.py index a5a2fc7e556..f31c54f259e 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_email.py +++ b/services/web/server/tests/unit/with_dbs/03/test_email.py @@ -29,7 +29,7 @@ from simcore_service_webserver.email._core import _remove_comments, _render_template from simcore_service_webserver.email._handlers import EmailTestFailed, EmailTestPassed from simcore_service_webserver.email.plugin import setup_email -from simcore_service_webserver.login._controller.preregistration_rest import ( +from simcore_service_webserver.login._controller.rest.preregistration import ( _get_ipinfo, ) from simcore_service_webserver.login._preregistration_service import (