diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index 978dcef3d63..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._2fa_handlers import Resend2faBody -from simcore_service_webserver.login._auth_handlers import ( +from simcore_service_webserver.login._controller.rest.auth import ( LoginBody, LoginNextPage, LoginTwoFactorAuthBody, LogoutBody, ) -from simcore_service_webserver.login.handlers_change import ( +from simcore_service_webserver.login._controller.rest.change import ( ChangeEmailBody, ChangePasswordBody, ResetPasswordBody, ) -from simcore_service_webserver.login.handlers_confirmation import ( +from simcore_service_webserver.login._controller.rest.confirmation import ( PhoneConfirmationBody, ResetPasswordConfirmation, ) -from simcore_service_webserver.login.handlers_registration import ( +from simcore_service_webserver.login._controller.rest.registration import ( InvitationCheck, InvitationInfo, RegisterBody, RegisterPhoneBody, RegisterPhoneNextPage, ) +from simcore_service_webserver.login._controller.rest.twofa import Resend2faBody router = APIRouter(prefix=f"/{API_VTAG}", tags=["auth"]) 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..d055e3a110c 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py @@ -11,8 +11,11 @@ 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.storage import AsyncpgStorage, get_plugin_storage +from simcore_service_webserver.login._invitations_service import create_invitation_token +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/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/_auth_api.py b/services/web/server/src/simcore_service_webserver/login/_auth_service.py similarity index 92% 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 index 5e00ae0b9e6..a936f7f62f2 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_api.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 .storage import AsyncpgStorage, get_plugin_storage -from .utils import validate_user_status +from ._login_repository_legacy import AsyncpgStorage, get_plugin_storage 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.py b/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py similarity index 91% 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 index 386629a7482..3d111fcc6f9 100644 --- a/services/web/server/src/simcore_service_webserver/login/_confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/_confirmation_service.py @@ -14,33 +14,48 @@ 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__) +_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) - log.warning( + _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="") @@ -54,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( - 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) - log.warning( + _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/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/_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/_auth_handlers.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py similarity index 80% rename from services/web/server/src/simcore_service_webserver/login/_auth_handlers.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py index fe1794363d8..2a737579bed 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py @@ -12,30 +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.api import forget_identity -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 ._2fa_api import ( - create_2fa_code, - delete_2fa_code, - get_2fa_code, - mask_phone_number, - send_email_code, - send_sms_code, -) -from ._auth_api import ( - check_authorized_user_credentials_or_raise, - check_authorized_user_in_product_or_raise, - get_user_by_email, -) -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, @@ -49,13 +37,10 @@ MSG_WRONG_2FA_CODE__EXPIRED, 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 -from .storage import AsyncpgStorage, get_plugin_storage -from .utils 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__) @@ -74,8 +59,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") @@ -104,19 +88,19 @@ 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 ) # 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( @@ -143,14 +127,14 @@ 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 ( user_2fa_authentification_method == TwoFactorAuthentificationMethod.SMS and not user["phone"] ): - return envelope_response( + return _login_service.envelope_response( # LoginNextPage { "name": CODE_PHONE_NUMBER_REQUIRED, @@ -162,7 +146,7 @@ async def login(request: web.Request): status=status.HTTP_202_ACCEPTED, ) - code = await 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, @@ -175,7 +159,7 @@ async def login(request: web.Request): assert settings.LOGIN_TWILIO # nosec assert product.twilio_messaging_sid # nosec - await send_sms_code( + await _twofa_service.send_sms_code( phone_number=user["phone"], code=code, twilio_auth=settings.LOGIN_TWILIO, @@ -185,13 +169,13 @@ 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, "parameters": { "message": MSG_2FA_CODE_SENT.format( - phone_number=mask_phone_number(user["phone"]) + phone_number=_twofa_service.mask_phone_number(user["phone"]) ), "expiration_2fa": settings.LOGIN_2FA_CODE_EXPIRATION_SEC, }, @@ -203,7 +187,7 @@ async def login(request: web.Request): assert ( user_2fa_authentification_method == TwoFactorAuthentificationMethod.EMAIL ) # nosec - await send_email_code( + await _twofa_service.send_email_code( request, user_email=user["email"], support_email=product.support_email, @@ -212,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": { @@ -240,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", @@ -252,7 +234,9 @@ 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 _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 @@ -262,16 +246,16 @@ 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 assert UserRole(user["role"]) <= UserRole.USER # nosec # dispose since code was used - await delete_2fa_code(request.app, login_2fa_.email) + await _twofa_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): @@ -296,9 +280,11 @@ 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) - await forget_identity(request, response) + 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/handlers_change.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py similarity index 89% rename from services/web/server/src/simcore_service_webserver/login/handlers_change.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py index 6710022bf74..0bebbb55738 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_change.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 ._confirmation import get_or_create_confirmation, make_confirmation_link -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 ._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 ( +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 .utils_email import get_template_path, send_email_from_template +from ..._models import InputSchema, create_password_match_validator +from ...decorators import login_required +from ...settings import LoginOptions, get_plugin_options _logger = logging.getLogger(__name__) @@ -180,12 +180,14 @@ 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( - 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` - 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( @@ -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) @@ -247,7 +249,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, diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py similarity index 87% rename from services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py index f4b9bb755a8..10a31cd2958 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py @@ -26,31 +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 ._2fa_api import delete_2fa_code, get_2fa_code -from ._confirmation import validate_confirmation_code -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 ._models import InputSchema, check_confirm_password_match -from ._registration import InvitationData -from ._security import login_granted_response -from .settings import ( - LoginOptions, - LoginSettingsForProduct, - get_plugin_options, - get_plugin_settings, +from ..._invitations_service import ConfirmedInvitationData +from ..._login_repository_legacy import ( + AsyncpgStorage, + ConfirmationTokenDict, + get_plugin_storage, ) -from .storage import AsyncpgStorage, ConfirmationTokenDict, get_plugin_storage -from .utils import ( +from ..._login_service import ( ACTIVE, CHANGE_EMAIL, REGISTRATION, @@ -58,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__) @@ -74,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 @@ -143,10 +145,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) @@ -240,10 +244,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 _twofa_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 _twofa_service.delete_2fa_code(request.app, request_body.email) # updates confirmed phone number try: @@ -257,7 +261,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( @@ -287,7 +291,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/_registration_handlers.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py similarity index 81% rename from services/web/server/src/simcore_service_webserver/login/_registration_handlers.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py index 2cbb69db5ee..3f8d72309c4 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py @@ -16,25 +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.api import check_password, forget_identity -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 ._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 ...._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 +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__) @@ -78,7 +78,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( + _preregistration_service.send_account_request_email_to_support( request, product=product, request_form=body.form, @@ -109,7 +109,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( @@ -131,11 +131,11 @@ 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( - send_close_account_email( + _preregistration_service.send_close_account_email( request, user_email=credentials.email, user_first_name=credentials.display_name, @@ -156,7 +156,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 _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/_controller/rest/registration.py similarity index 89% rename from services/web/server/src/simcore_service_webserver/login/handlers_registration.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py index e91556f4424..2f5f3fc850d 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py @@ -20,22 +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_api -from ._2fa_api import create_2fa_code, mask_phone_number, send_sms_code -from ._confirmation import make_confirmation_link -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, @@ -44,27 +42,31 @@ MSG_UNAUTHORIZED_REGISTER_PHONE, MSG_WEAK_PASSWORD, ) -from ._models import InputSchema, check_confirm_password_match -from ._registration 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 ._security import login_granted_response -from .settings import ( - LoginOptions, - LoginSettingsForProduct, - get_plugin_options, - get_plugin_settings, +from ..._login_repository_legacy import ( + AsyncpgStorage, + ConfirmationTokenDict, + get_plugin_storage, ) -from .storage import AsyncpgStorage, ConfirmationTokenDict, get_plugin_storage -from .utils import ( +from ..._login_service import ( envelope_response, flash_response, get_user_name_from_email, notify_user_confirmation, ) -from .utils_email import get_template_path, send_email_from_template +from ..._models import InputSchema, check_confirm_password_match +from ...settings import ( + LoginOptions, + LoginSettingsForProduct, + get_plugin_options, + get_plugin_settings, +) _logger = logging.getLogger(__name__) @@ -174,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 @@ -212,15 +214,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(), @@ -255,7 +257,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" ) @@ -321,7 +325,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): @@ -380,12 +384,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 _twofa_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 _twofa_service.send_sms_code( phone_number=registration.phone, code=code, twilio_auth=settings.LOGIN_TWILIO, @@ -402,7 +406,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/_2fa_handlers.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py similarity index 80% rename from services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py rename to services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py index 83c2119dab3..bb9cc3ff01c 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py @@ -9,29 +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 ._2fa_api import ( - create_2fa_code, - delete_2fa_code, - get_2fa_code, - mask_phone_number, - send_email_code, - send_sms_code, -) -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 ._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 +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__) @@ -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 _twofa_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 _twofa_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 _twofa_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 _twofa_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=_twofa_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 _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/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.py b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py similarity index 92% 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 index 0e924def7b1..df26f4f6aae 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_invitations_service.py @@ -40,14 +40,18 @@ 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, 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__) @@ -57,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)", @@ -76,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 @@ -87,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, } @@ -134,7 +138,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: @@ -183,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, @@ -264,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 @@ -287,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, @@ -296,11 +301,13 @@ 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 - ).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/storage.py b/services/web/server/src/simcore_service_webserver/login/_login_repository_legacy.py similarity index 72% 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 index e1c5e18d70c..d119c462d8b 100644 --- a/services/web/server/src/simcore_service_webserver/login/storage.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 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/_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_api.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_api.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/_security.py b/services/web/server/src/simcore_service_webserver/login/_security_service.py similarity index 82% 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..0f8685d01ab 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,15 +1,14 @@ -""" 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 +from ._login_service import flash_response _logger = logging.getLogger(__name__) @@ -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/_2fa_api.py b/services/web/server/src/simcore_service_webserver/login/_twofa_service.py similarity index 97% rename from services/web/server/src/simcore_service_webserver/login/_2fa_api.py rename to services/web/server/src/simcore_service_webserver/login/_twofa_service.py index cda2bc1721d..c799e29e4a3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_api.py +++ b/services/web/server/src/simcore_service_webserver/login/_twofa_service.py @@ -19,10 +19,10 @@ 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 .utils_email import get_template_path, send_email_from_template +from ._emails_service import get_template_path, send_email_from_template +from .errors import SendingVerificationEmailError, SendingVerificationSmsError log = logging.getLogger(__name__) @@ -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/src/simcore_service_webserver/login/cli.py b/services/web/server/src/simcore_service_webserver/login/cli.py index 561ec8c1e9f..51dab15af2d 100644 --- a/services/web/server/src/simcore_service_webserver/login/cli.py +++ b/services/web/server/src/simcore_service_webserver/login/cli.py @@ -1,12 +1,12 @@ import sys -from datetime import datetime +from datetime import UTC, datetime import typer from servicelib.utils_secrets import generate_password from simcore_postgres_database.models.confirmations import ConfirmationAction from yarl import URL -from ._registration import InvitationData, get_invitation_url +from ._invitations_service import ConfirmedInvitationData, get_invitation_url def invitations( @@ -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 = 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)] + codes: list[str] = [generate_password(code_length) for _ in range(num_codes)] typer.secho( "{:-^100}".format("invitations.md"), @@ -46,21 +46,21 @@ 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) + 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/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/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/login/plugin.py b/services/web/server/src/simcore_service_webserver/login/plugin.py index 149780b668e..60c8db47b6f 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -23,22 +23,22 @@ from ..products.plugin import setup_products from ..redis import setup_redis from ..rest.plugin import setup_rest -from . import ( - _2fa_handlers, - _auth_handlers, - _registration_handlers, - handlers_change, - handlers_confirmation, - handlers_registration, -) from ._constants import APP_LOGIN_SETTINGS_PER_PRODUCT_KEY +from ._controller.rest import ( + auth, + change, + confirmation, + preregistration, + registration, + twofa, +) +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__) @@ -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(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(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/src/simcore_service_webserver/publications/_rest.py b/services/web/server/src/simcore_service_webserver/publications/_rest.py index 35ccbac61a5..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.storage import AsyncpgStorage, get_plugin_storage -from ..login.utils_email import AttachmentTuple, send_email_from_template, themed +from ..login.login_repository_legacy import AsyncpgStorage, get_plugin_storage 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..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 @@ -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,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.storage import AsyncpgStorage, get_plugin_storage -from ..login.utils import ACTIVE, GUEST +from ..login._login_service import ACTIVE, GUEST +from ..login.login_repository_legacy import AsyncpgStorage, get_plugin_storage 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/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/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/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/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/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..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.handlers_registration import ( +from simcore_service_webserver.login._controller.rest.registration import ( InvitationCheck, InvitationInfo, ) @@ -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._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.handlers_registration.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.handlers_registration.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.handlers_registration.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/conftest.py b/services/web/server/tests/unit/with_dbs/03/login/conftest.py index c0eaf628d2e..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,14 +9,19 @@ 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 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 @@ -72,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( @@ -120,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_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 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..87b0ae89237 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py @@ -0,0 +1,111 @@ +from datetime import timedelta + +from aiohttp.test_utils import make_mocked_request +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, + 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, 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 + ) + + 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", + "http://example.com/auth/confirmation/{code}", + app=app, + headers={"Host": "example.com"}, + ) + + # 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 + + +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) + 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 + ) + + 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 + ) 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..826f98498c5 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_controller_confirmation_rest.py @@ -0,0 +1,155 @@ +# 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 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 ( + ActionLiteralStr, + 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, str | None], 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, data: str | None = None + ) -> ConfirmationTokenDict: + return await db.create_confirmation(user_id=user_id, action=action, data=data) + + return _create_token + + +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", None + ) + 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 + 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=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, + 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", "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 _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( + 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", None + ) + code = confirmation["code"] + + response = await client.get(f"/v0/auth/confirmation/{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( + client: TestClient, + create_valid_confirmation_token: CreateTokenCallable, + registered_user: UserInfoDict, +): + user_id = registered_user["id"] + confirmation = await create_valid_confirmation_token(user_id, "REGISTRATION", None) + code = confirmation["code"] + + with patch( + "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.rest.confirmation._logger.exception" + ) as mock_logger: + response = await client.get(f"/v0/auth/confirmation/{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 "/#/error?message=" in response.history[0].headers["Location"] + + mock_handler.assert_called_once() + mock_logger.assert_called_once() 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_handlers.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py similarity index 98% 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 index 5d019f4fb57..565df3495bc 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_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._registration_handlers.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 1abc63ac9f5..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 @@ -19,19 +19,19 @@ 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, 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 @@ -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,15 +144,15 @@ 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, ): assert client.app mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -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,13 +264,13 @@ 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 mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -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,14 +301,14 @@ 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, ): assert client.app mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=True, @@ -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,13 +370,13 @@ 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 mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -400,12 +400,12 @@ 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, - "invitation": confirmation["code"] - if has_valid_invitation - else "WRONG_CODE", + "email": user_email, + "password": user_password, + "confirm": user_password, + "invitation": ( + confirmation["code"] if has_valid_invitation else "WRONG_CODE" + ), }, ) await assert_status(response, expected_response) @@ -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,13 +430,13 @@ 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 mocker.patch( - "simcore_service_webserver.login.handlers_registration.get_plugin_settings", + "simcore_service_webserver.login._controller.rest.registration.get_plugin_settings", autospec=True, return_value=LoginSettingsForProduct( LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=False, @@ -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 ea121adb288..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 @@ -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 @@ -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_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py similarity index 82% 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 index 588e95182b6..3bd849f086b 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_twofa.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 @@ -22,7 +21,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_api 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 +34,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.storage 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 @@ -76,16 +75,23 @@ 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.rest.registration._twofa_service.send_sms_code", + autospec=True, + ) + + mock_same_submodule = mocker.patch( + "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(...) + new=mock, + ) + return { - "send_sms_code_for_registration": mocker.patch( - "simcore_service_webserver.login.handlers_registration.send_sms_code", - autospec=True, - ), - "send_sms_code_for_login": mocker.patch( - "simcore_service_webserver.login._auth_handlers.send_sms_code", - autospec=True, - ), + "send_sms_code_for_registration": mock, + "send_sms_code_for_login": mock_same_submodule, } @@ -117,10 +123,10 @@ 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, - mocked_twilio_service: dict[str, Mock], + 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, ): @@ -133,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) @@ -154,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 @@ -163,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) @@ -175,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) @@ -185,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 @@ -197,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) --------------------------------------------------------- @@ -215,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) @@ -229,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, }, ) @@ -245,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) --------------------------------------------------------- @@ -267,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) @@ -296,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) @@ -306,10 +312,10 @@ 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, - mocked_twilio_service: dict[str, Mock], + user_email: str, + user_password: str, + user_phone_number: str, + mocked_twilio_service: dict[str, MockType], cleanup_db_tables: None, ): """ @@ -321,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, @@ -341,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) @@ -352,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) @@ -410,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, @@ -421,7 +427,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._twofa_service.twilio.rest.Client", autospec=True, side_effect=TwilioRestException( status=400, @@ -433,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, ): @@ -445,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, }, ) @@ -459,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_2fa_resend.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa_resend.py similarity index 79% 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 index 1f3c76c2ea8..4f413eb3500 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_twofa_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_handlers import CodePageParams, NextPage from simcore_service_webserver.login._constants import CODE_2FA_SMS_CODE_REQUIRED +from simcore_service_webserver.login._controller.rest.auth import ( + CodePageParams, + NextPage, +) @pytest.fixture @@ -53,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 @@ -61,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", }, ) @@ -77,20 +80,26 @@ async def test_resend_2fa_workflow( ): assert client.app - # spy send functions - mocker.patch( - "simcore_service_webserver.login._2fa_handlers.send_sms_code", autospec=True + # patch send functions + mock_send_sms_code_1 = mocker.patch( + "simcore_service_webserver.login._controller.rest.twofa._twofa_service.send_sms_code", + autospec=True, ) - mock_send_sms_code2 = mocker.patch( - "simcore_service_webserver.login._auth_handlers.send_sms_code", autospec=True + mock_send_sms_code_2 = mocker.patch( + "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(...) + new=mock_send_sms_code_1, ) mock_send_email_code = mocker.patch( - "simcore_service_webserver.login._2fa_handlers.send_email_code", autospec=True + "simcore_service_webserver.login._controller.rest.twofa._twofa_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._controller.rest.twofa._twofa_service.get_2fa_code", autospec=True, return_value=None, # <-- Emulates code expired ) @@ -106,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 @@ -127,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/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 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/03/test_email.py b/services/web/server/tests/unit/with_dbs/03/test_email.py index 244f090ab40..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,8 +29,12 @@ 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._controller.rest.preregistration import ( + _get_ipinfo, +) +from simcore_service_webserver.login._preregistration_service import ( + _json_encoder_and_dumps, +) @pytest.fixture 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, 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], 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 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="", )