diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index 5366396078a..c0365628145 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -8,6 +8,7 @@ from _common import Error, Log from fastapi import APIRouter, status +from models_library.api_schemas_webserver.auth import UnregisterCheck from models_library.generics import Envelope from pydantic import BaseModel, Field, confloat from simcore_service_webserver._meta import API_VTAG @@ -53,16 +54,26 @@ async def check_registration_invitation(check: InvitationCheck): response_model=Envelope[Log], operation_id="auth_register", ) -async def register(registration: RegisterBody): +async def register(_body: RegisterBody): """User registration""" +@router.post( + "/auth/unregister", + response_model=Envelope[Log], + status_code=status.HTTP_200_OK, + responses={status.HTTP_409_CONFLICT: {"model": Envelope[Error]}}, +) +async def unregister_account(_body: UnregisterCheck): + ... + + @router.post( "/auth/verify-phone-number", response_model=Envelope[RegisterPhoneNextPage], operation_id="auth_register_phone", ) -async def register_phone(registration: RegisterPhoneBody): +async def register_phone(_body: RegisterPhoneBody): """user tries to verify phone number for 2 Factor Authentication when registering""" @@ -71,7 +82,7 @@ async def register_phone(registration: RegisterPhoneBody): response_model=Envelope[Log], operation_id="auth_phone_confirmation", ) -async def phone_confirmation(confirmation: PhoneConfirmationBody): +async def phone_confirmation(_body: PhoneConfirmationBody): """user enters 2 Factor Authentication code when registering""" @@ -88,7 +99,7 @@ async def phone_confirmation(confirmation: PhoneConfirmationBody): } }, ) -async def login(authentication: LoginBody): +async def login(_body: LoginBody): """user logs in""" @@ -103,7 +114,7 @@ async def login(authentication: LoginBody): } }, ) -async def login_2fa(authentication: LoginTwoFactorAuthBody): +async def login_2fa(_body: LoginTwoFactorAuthBody): """user enters 2 Factor Authentication code when login in""" @@ -127,7 +138,7 @@ async def resend_2fa_code(resend: Resend2faBody): response_model=Envelope[Log], operation_id="auth_logout", ) -async def logout(data: LogoutBody): +async def logout(_body: LogoutBody): """user logout""" @@ -137,7 +148,7 @@ async def logout(data: LogoutBody): operation_id="auth_reset_password", responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"model": Envelope[Error]}}, ) -async def reset_password(data: ResetPasswordBody): +async def reset_password(_body: ResetPasswordBody): """a non logged-in user requests a password reset""" @@ -152,7 +163,7 @@ async def reset_password(data: ResetPasswordBody): } }, ) -async def reset_password_allowed(code: str, data: ResetPasswordConfirmation): +async def reset_password_allowed(code: str, _body: ResetPasswordConfirmation): """changes password using a token code without being logged in""" @@ -171,7 +182,7 @@ async def reset_password_allowed(code: str, data: ResetPasswordConfirmation): }, }, ) -async def change_email(data: ChangeEmailBody): +async def change_email(_body: ChangeEmailBody): """logged in user changes email""" @@ -205,7 +216,7 @@ class PasswordCheckSchema(BaseModel): }, }, ) -async def change_password(data: ChangePasswordBody): +async def change_password(_body: ChangePasswordBody): """logged in user changes password""" @@ -265,7 +276,7 @@ async def list_api_keys(code: str): }, }, ) -async def create_api_key(data: ApiKeyCreate): +async def create_api_key(_body: ApiKeyCreate): """creates API keys to access public API""" @@ -285,5 +296,5 @@ async def create_api_key(data: ApiKeyCreate): }, }, ) -async def delete_api_key(data: ApiKeyCreate): +async def delete_api_key(_body: ApiKeyCreate): """deletes API key by name""" diff --git a/packages/models-library/src/models_library/api_schemas_webserver/auth.py b/packages/models-library/src/models_library/api_schemas_webserver/auth.py new file mode 100644 index 00000000000..7663c98cf19 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/auth.py @@ -0,0 +1,9 @@ +from models_library.emails import LowerCaseEmailStr +from pydantic import SecretStr + +from ._base import InputSchema + + +class UnregisterCheck(InputSchema): + email: LowerCaseEmailStr + password: SecretStr diff --git a/packages/postgres-database/VERSION b/packages/postgres-database/VERSION index a3df0a6959e..78bc1abd14f 100644 --- a/packages/postgres-database/VERSION +++ b/packages/postgres-database/VERSION @@ -1 +1 @@ -0.8.0 +0.10.0 diff --git a/packages/postgres-database/setup.cfg b/packages/postgres-database/setup.cfg index fea59dc49b7..2784c26578c 100644 --- a/packages/postgres-database/setup.cfg +++ b/packages/postgres-database/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.0 +current_version = 0.10.0 commit = True message = packages/postgres-database version: {current_version} → {new_version} tag = False diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/be0dece4e67c_rm_created_ip_and_add_deleted_user_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/be0dece4e67c_rm_created_ip_and_add_deleted_user_.py new file mode 100644 index 00000000000..f53c722a7cd --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/be0dece4e67c_rm_created_ip_and_add_deleted_user_.py @@ -0,0 +1,51 @@ +"""rm created_ip and add DELETED user status + +Revision ID: be0dece4e67c +Revises: 76d106b243c3 +Create Date: 2023-10-23 17:36:46.349925+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "be0dece4e67c" +down_revision = "76d106b243c3" +branch_labels = None +depends_on = None + +column_name = "status" +enum_typename = "userstatus" +new_value = "DELETED" + + +def upgrade(): + # SEE https://medium.com/makimo-tech-blog/upgrading-postgresqls-enum-type-with-sqlalchemy-using-alembic-migration-881af1e30abe + + with op.get_context().autocommit_block(): + op.execute(f"ALTER TYPE {enum_typename} ADD VALUE '{new_value}'") + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "created_ip") + # ### end Alembic commands ### + + +def downgrade(): + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "users", + sa.Column("created_ip", sa.VARCHAR(), autoincrement=False, nullable=True), + ) + # ### end Alembic commands ### + + # NOTE: Downgrade new updates requires re-building the entire enum! + op.execute(f"ALTER TYPE {enum_typename} RENAME TO {enum_typename}_old") + op.execute( + f"CREATE TYPE {enum_typename} AS ENUM('CONFIRMATION_PENDING', 'ACTIVE', 'BANNED')" + ) + op.execute( + f"ALTER TABLE users ALTER COLUMN {column_name} TYPE {enum_typename} USING " + f"{column_name}::text::{enum_typename}" + ) + op.execute(f"DROP TYPE {enum_typename}_old") diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users.py b/packages/postgres-database/src/simcore_postgres_database/models/users.py index 911b0fd2664..7c04800a07a 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users.py @@ -63,12 +63,14 @@ class UserStatus(Enum): active: user is confirmed and can use the platform expired: user is not authorized because it expired after a trial period banned: user is not authorized + deleted: this account is marked for deletion """ CONFIRMATION_PENDING = "PENDING" ACTIVE = "ACTIVE" EXPIRED = "EXPIRED" BANNED = "BANNED" + DELETED = "DELETED" users = sa.Table( @@ -146,12 +148,6 @@ class UserStatus(Enum): doc="Sets the expiration date for trial accounts." "If set to NULL then the account does not expire.", ), - sa.Column( - "created_ip", - sa.String(), - nullable=True, - doc="User IP from which use was created", - ), # --------------------------- sa.PrimaryKeyConstraint("id", name="user_pkey"), sa.UniqueConstraint("email", name="user_login_key"), diff --git a/packages/postgres-database/tests/projects/conftest.py b/packages/postgres-database/tests/projects/conftest.py index 705e5abc20d..a2bd01b5f3f 100644 --- a/packages/postgres-database/tests/projects/conftest.py +++ b/packages/postgres-database/tests/projects/conftest.py @@ -4,7 +4,7 @@ # pylint: disable=unused-variable -from typing import Optional +from typing import AsyncIterable import pytest from aiopg.sa.connection import SAConnection @@ -14,43 +14,42 @@ from simcore_postgres_database.models.projects import projects from simcore_postgres_database.models.users import users -USERNAME = f"{__name__}.me" -PARENT_PROJECT_NAME = f"{__name__}.parent" - @pytest.fixture async def user(pg_engine: Engine) -> RowProxy: + _USERNAME = f"{__name__}.me" # some user async with pg_engine.acquire() as conn: - result: Optional[ResultProxy] = await conn.execute( - users.insert().values(**random_user(name=USERNAME)).returning(users) + result: ResultProxy | None = await conn.execute( + users.insert().values(**random_user(name=_USERNAME)).returning(users) ) assert result.rowcount == 1 - _user: Optional[RowProxy] = await result.first() + _user: RowProxy | None = await result.first() assert _user - assert _user.name == USERNAME + assert _user.name == _USERNAME return _user @pytest.fixture async def project(pg_engine: Engine, user: RowProxy) -> RowProxy: + _PARENT_PROJECT_NAME = f"{__name__}.parent" # a user's project async with pg_engine.acquire() as conn: - result: Optional[ResultProxy] = await conn.execute( + result: ResultProxy | None = await conn.execute( projects.insert() - .values(**random_project(prj_owner=user.id, name=PARENT_PROJECT_NAME)) + .values(**random_project(prj_owner=user.id, name=_PARENT_PROJECT_NAME)) .returning(projects) ) assert result.rowcount == 1 - _project: Optional[RowProxy] = await result.first() + _project: RowProxy | None = await result.first() assert _project - assert _project.name == PARENT_PROJECT_NAME + assert _project.name == _PARENT_PROJECT_NAME return _project @pytest.fixture -async def conn(pg_engine: Engine) -> SAConnection: +async def conn(pg_engine: Engine) -> AsyncIterable[SAConnection]: async with pg_engine.acquire() as conn: yield conn diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py index 87a82b636f7..67ebd9ea0a8 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py @@ -65,7 +65,6 @@ def random_user(**overrides) -> dict[str, Any]: "email": FAKE.email().lower(), "password_hash": _DEFAULT_HASH, "status": UserStatus.ACTIVE, - "created_ip": FAKE.ipv4(), } assert set(data.keys()).issubset({c.name for c in users.columns}) # nosec diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py b/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py index f52d50ae44d..0dfb8c6121e 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py @@ -28,7 +28,6 @@ class _UserInfoDictRequired(TypedDict, total=True): class UserInfoDict(_UserInfoDictRequired, total=False): created_at: datetime - created_ip: int password_hash: str @@ -107,7 +106,7 @@ async def __aexit__(self, *args): class LoggedUser(NewUser): - def __init__(self, client, params=None, *, check_if_succeeds=True): + def __init__(self, client: TestClient, params=None, *, check_if_succeeds=True): super().__init__(params, client.app) self.client = client self.enable_check = check_if_succeeds diff --git a/packages/service-library/src/servicelib/utils.py b/packages/service-library/src/servicelib/utils.py index 03e49e077a3..3cbad7930db 100644 --- a/packages/service-library/src/servicelib/utils.py +++ b/packages/service-library/src/servicelib/utils.py @@ -71,6 +71,7 @@ def fire_and_forget_task( task_suffix_name: str, fire_and_forget_tasks_collection: set[asyncio.Task], ) -> asyncio.Task: + # NOTE: details on rationale in https://github.com/ITISFoundation/osparc-simcore/pull/3120 task = asyncio.create_task(obj, name=f"fire_and_forget_task_{task_suffix_name}") fire_and_forget_tasks_collection.add(task) diff --git a/services/storage/tests/data/users.csv b/services/storage/tests/data/users.csv index 58476f95d52..d35b80d9626 100644 --- a/services/storage/tests/data/users.csv +++ b/services/storage/tests/data/users.csv @@ -1,2 +1,2 @@ -id,name,email,password_hash,status,role,created_at,created_ip -21,devops,devops@itis.swiss,$5$rounds=1000$jjUWjHSG5F2dMKw.$9VRlE4YLl4bPfIrWkDz/8GtEx1XkzTpuZzyc/uiBFE4,ACTIVE,USER,2019-06-27 11:35:44.828696,172.16.8.64 +id,name,email,password_hash,status,role,created_at +21,devops,devops@itis.swiss,$5$rounds=1000$jjUWjHSG5F2dMKw.$9VRlE4YLl4bPfIrWkDz/8GtEx1XkzTpuZzyc/uiBFE4,ACTIVE,USER,2019-06-27 11:35:44.828696 diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 85e60ed180c..7b52f5e5178 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.34.0 +0.35.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index c1dba20396f..54246d2592b 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.34.0 +current_version = 0.35.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 4b15ca067fe..897a3a189da 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -3,7 +3,7 @@ info: title: simcore-service-webserver description: ' Main service with an interface (http-API & websockets) to the web front-end' - version: 0.34.0 + version: 0.35.0 servers: - url: '' description: webserver @@ -107,6 +107,31 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_Log_' + /v0/auth/unregister: + post: + tags: + - auth + summary: Unregister Account + operationId: unregister_account + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UnregisterCheck' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_Log_' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_Error_' /v0/auth/verify-phone-number: post: tags: @@ -9518,6 +9543,22 @@ components: example: service: github-api-v1 token_key: 5f21abf5-c596-47b7-bfd1-c0e436ef1107 + UnregisterCheck: + title: UnregisterCheck + required: + - email + - password + type: object + properties: + email: + title: Email + type: string + format: email + password: + title: Password + type: string + format: password + writeOnly: true UploadedPart: title: UploadedPart required: diff --git a/services/web/server/src/simcore_service_webserver/email/_core.py b/services/web/server/src/simcore_service_webserver/email/_core.py index a5f1ab22f2e..8974c9e4ff0 100644 --- a/services/web/server/src/simcore_service_webserver/email/_core.py +++ b/services/web/server/src/simcore_service_webserver/email/_core.py @@ -1,6 +1,7 @@ import logging import mimetypes import re +from collections.abc import Mapping from email import encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart @@ -8,7 +9,7 @@ from email.utils import formatdate, make_msgid from pathlib import Path from pprint import pformat -from typing import Any, Mapping, NamedTuple, TypedDict, Union +from typing import Any, NamedTuple, TypedDict, Union import aiosmtplib from aiohttp import web @@ -17,17 +18,17 @@ from .settings import get_plugin_settings -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) def _create_smtp_client(settings: SMTPSettings) -> aiosmtplib.SMTP: - smtp_args = dict( - hostname=settings.SMTP_HOST, - port=settings.SMTP_PORT, - use_tls=settings.SMTP_PROTOCOL == EmailProtocol.TLS, - start_tls=settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS, - ) - logger.debug("Sending email with smtp configuration: %s", pformat(smtp_args)) + smtp_args = { + "hostname": settings.SMTP_HOST, + "port": settings.SMTP_PORT, + "use_tls": settings.SMTP_PROTOCOL == EmailProtocol.TLS, + "start_tls": settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS, + } + _logger.debug("Sending email with smtp configuration: %s", pformat(smtp_args)) return aiosmtplib.SMTP(**smtp_args) @@ -36,7 +37,7 @@ async def _do_send_mail( ) -> None: # WARNING: _do_send_mail is mocked so be careful when changing the signature or name !! - logger.debug("Email configuration %s", settings.json(indent=1)) + _logger.debug("Email configuration %s", settings.json(indent=1)) if settings.SMTP_PORT == 587: # NOTE: aiosmtplib does not handle port 587 correctly this is a workaround @@ -44,20 +45,20 @@ async def _do_send_mail( smtp = _create_smtp_client(settings) if settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS: - logger.info("Unencrypted connection attempt to mailserver ...") + _logger.info("Unencrypted connection attempt to mailserver ...") await smtp.connect(use_tls=False, port=settings.SMTP_PORT) - logger.info("Starting STARTTLS ...") + _logger.info("Starting STARTTLS ...") await smtp.starttls() elif settings.SMTP_PROTOCOL == EmailProtocol.TLS: await smtp.connect(use_tls=True, port=settings.SMTP_PORT) elif settings.SMTP_PROTOCOL == EmailProtocol.UNENCRYPTED: - logger.info("Unencrypted connection attempt to mailserver ...") + _logger.info("Unencrypted connection attempt to mailserver ...") await smtp.connect(use_tls=False, port=settings.SMTP_PORT) if settings.SMTP_USERNAME and settings.SMTP_PASSWORD: - logger.info("Attempting a login into the email server ...") + _logger.info("Attempting a login into the email server ...") await smtp.login( settings.SMTP_USERNAME, settings.SMTP_PASSWORD.get_secret_value() ) @@ -68,7 +69,7 @@ async def _do_send_mail( else: async with _create_smtp_client(settings) as smtp: if settings.SMTP_USERNAME and settings.SMTP_PASSWORD: - logger.info("Login email server ...") + _logger.info("Login email server ...") await smtp.login( settings.SMTP_USERNAME, settings.SMTP_PASSWORD.get_secret_value() ) diff --git a/services/web/server/src/simcore_service_webserver/login/_constants.py b/services/web/server/src/simcore_service_webserver/login/_constants.py index 33f141e7130..3fe872c02ec 100644 --- a/services/web/server/src/simcore_service_webserver/login/_constants.py +++ b/services/web/server/src/simcore_service_webserver/login/_constants.py @@ -44,6 +44,9 @@ str ] = "Unauthorized: you cannot submit the code anymore, please restart." MSG_UNKNOWN_EMAIL: Final[str] = "This email is not registered" +MSG_USER_DELETED: Final[ + str +] = "This account was requested for deletion. To reactivate or further information please contact support: {support_email}" MSG_USER_BANNED: Final[ str ] = "This user does not have anymore access. Please contact support for further details: {support_email}" diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_api.py b/services/web/server/src/simcore_service_webserver/login/_registration_api.py new file mode 100644 index 00000000000..a0524fe5151 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_registration_api.py @@ -0,0 +1,40 @@ +import logging + +from aiohttp import web +from pydantic import EmailStr, PositiveInt + +from ..email.utils import send_email_from_template +from ..products.api import get_current_product, get_product_template_path + +_logger = logging.getLogger(__name__) + + +async def send_close_account_email( + request: web.Request, + user_email: EmailStr, + user_name: str, + retention_days: PositiveInt = 30, +): + template_name = "close_account.jinja2" + email_template_path = await get_product_template_path(request, template_name) + product = get_current_product(request) + + try: + await send_email_from_template( + request, + from_=product.support_email, + to=user_email, + template=email_template_path, + context={ + "host": request.host, + "name": user_name.capitalize(), + "support_email": product.support_email, + "retention_days": retention_days, + }, + ) + except Exception: # pylint: disable=broad-except + _logger.exception( + "Failed while sending '%s' email to %s", + template_name, + f"{user_email=}", + ) diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py b/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py new file mode 100644 index 00000000000..b78157432c4 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py @@ -0,0 +1,79 @@ +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.auth import UnregisterCheck +from models_library.users import UserID +from pydantic import BaseModel, Field +from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY +from servicelib.aiohttp.requests_validation import parse_request_body_as +from servicelib.logging_utils import get_log_record_extra, log_context +from servicelib.request_keys import RQT_USERID_KEY +from servicelib.utils import fire_and_forget_task + +from .._constants import RQ_PRODUCT_KEY +from .._meta import API_VTAG +from ..security.api import check_password, forget +from ..security.decorators import permission_required +from ..users.api import get_user_credentials, set_user_as_deleted +from ._constants import MSG_LOGGED_OUT +from ._registration_api import send_close_account_email +from .decorators import login_required +from .utils import flash_response, notify_user_logout + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +class _RequestContext(BaseModel): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[pydantic-alias] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[pydantic-alias] + + +@routes.post(f"/{API_VTAG}/auth/unregister", name="unregister_account") +@login_required +@permission_required("user.profile.delete") +async def unregister_account(request: web.Request): + req_ctx = _RequestContext.parse_obj(request) + body = await parse_request_body_as(UnregisterCheck, 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( + body.password.get_secret_value(), credentials.password_hash + ): + raise web.HTTPConflict( + reason="Wrong email or password. Please try again to delete this account" + ) + + with log_context( + _logger, + logging.INFO, + "Mark account for deletion to %s", + credentials.email, + extra=get_log_record_extra(user_id=req_ctx.user_id), + ): + # update user table + await set_user_as_deleted(request.app, user_id=req_ctx.user_id) + + # logout + await notify_user_logout( + request.app, user_id=req_ctx.user_id, client_session_id=None + ) + response = flash_response(MSG_LOGGED_OUT, "INFO") + await forget(request, response) + + # send email in the background + fire_and_forget_task( + send_close_account_email( + request, + user_email=credentials.email, + user_name=credentials.full_name.first_name, + retention_days=30, + ), + task_suffix_name=f"{__name__}.unregister_account.send_close_account_email", + fire_and_forget_tasks_collection=request.app[APP_FIRE_AND_FORGET_TASKS_KEY], + ) + + return response diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index d7e7ff0e6ea..601b3563217 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -54,7 +54,6 @@ USER, envelope_response, flash_response, - get_client_ip, notify_user_confirmation, ) from .utils_email import get_template_path, send_email_from_template @@ -204,7 +203,6 @@ async def register(request: web.Request): ), "role": USER, "expires_at": expires_at, - "created_ip": get_client_ip(request), } ) 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 8b371f653ec..61874884ae9 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -24,6 +24,7 @@ from ..redis import setup_redis from ..rest.plugin import setup_rest from . import ( + _registration_handlers, api_keys_handlers, handlers_2fa, handlers_auth, @@ -160,6 +161,7 @@ def setup_login(app: web.Application): app.router.add_routes(handlers_auth.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(handlers_2fa.routes) app.router.add_routes(api_keys_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/login/utils.py b/services/web/server/src/simcore_service_webserver/login/utils.py index a87313a6023..3635225236a 100644 --- a/services/web/server/src/simcore_service_webserver/login/utils.py +++ b/services/web/server/src/simcore_service_webserver/login/utils.py @@ -1,13 +1,12 @@ -import logging import random from dataclasses import asdict from typing import Any, cast import passlib.hash +import passlib.pwd from aiohttp import web from models_library.products import ProductName from models_library.users import UserID -from passlib import pwd from pydantic import PositiveInt from servicelib.aiohttp import observer from servicelib.aiohttp.rest_models import LogMessageType @@ -16,9 +15,12 @@ from simcore_postgres_database.models.users import UserRole from ..db.models import ConfirmationAction, UserStatus -from ._constants import MSG_ACTIVATION_REQUIRED, MSG_USER_BANNED, MSG_USER_EXPIRED - -log = logging.getLogger(__name__) +from ._constants import ( + MSG_ACTIVATION_REQUIRED, + MSG_USER_BANNED, + MSG_USER_DELETED, + MSG_USER_EXPIRED, +) def _to_names(enum_cls, names): @@ -26,13 +28,15 @@ def _to_names(enum_cls, names): return [getattr(enum_cls, att).name for att in names.split()] -CONFIRMATION_PENDING, ACTIVE, BANNED, EXPIRED = ( +CONFIRMATION_PENDING, ACTIVE, BANNED, EXPIRED, DELETED = ( UserStatus.CONFIRMATION_PENDING.name, UserStatus.ACTIVE.name, UserStatus.BANNED.name, UserStatus.EXPIRED.name, + UserStatus.DELETED.name, ) -assert len(UserStatus) == 4 # nosec +_EXPECTED_ENUMS = 5 +assert len(UserStatus) == _EXPECTED_ENUMS # nosec ANONYMOUS, GUEST, USER, TESTER = _to_names(UserRole, "ANONYMOUS GUEST USER TESTER") @@ -43,8 +47,21 @@ def _to_names(enum_cls, names): def validate_user_status(*, user: dict, support_email: str): + """ + + Raises: + web.HTTPUnauthorized + """ + assert "role" in user # nosec + user_status: str = user["status"] + if user_status == DELETED: + raise web.HTTPUnauthorized( + reason=MSG_USER_DELETED.format(support_email=support_email), + content_type=MIMETYPE_APPLICATION_JSON, + ) # 401 + if user_status == BANNED or user["role"] == ANONYMOUS: raise web.HTTPUnauthorized( reason=MSG_USER_BANNED.format(support_email=support_email), @@ -92,30 +109,19 @@ async def notify_user_logout( Listeners (e.g. sockets) will trigger logout mechanisms """ - await observer.emit(app, "SIGNAL_USER_LOGOUT", user_id, client_session_id, app) - - -def encrypt_password(password: str) -> str: - # SEE https://github.com/ITISFoundation/osparc-simcore/issues/3375 - return cast(str, passlib.hash.sha256_crypt.using(rounds=1000).hash(password)) - - -def check_password(password: str, password_hash: str) -> bool: - return cast(bool, passlib.hash.sha256_crypt.verify(password, password_hash)) + await observer.emit( + app, + "SIGNAL_USER_LOGOUT", + user_id, + client_session_id, + app, + ) def get_random_string(min_len: int, max_len: int | None = None) -> str: max_len = max_len or min_len size = random.randint(min_len, max_len) # noqa: S311 # nosec # NOSONAR - return cast(str, pwd.genword(entropy=52, length=size)) - - -def get_client_ip(request: web.Request) -> str: - try: - ips = request.headers["X-Forwarded-For"] - except KeyError: - ips = request.transport.get_extra_info("peername")[0] - return cast(str, ips.split(",")[0]) + return cast(str, passlib.pwd.genword(entropy=52, length=size)) def flash_response( diff --git a/services/web/server/src/simcore_service_webserver/security/_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_access_roles.py index 786dc983433..ad66450f53c 100644 --- a/services/web/server/src/simcore_service_webserver/security/_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_access_roles.py @@ -72,6 +72,7 @@ class PermissionDict(TypedDict, total=False): "user.apikey.*", "user.notifications.update", "user.notifications.write", + "user.profile.delete", "user.profile.update", "user.tokens.*", "wallets.*", diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py index 22bb492c2f6..985b73803ed 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py @@ -119,7 +119,7 @@ async def _get_published_template_project( async def _create_temporary_user(request: web.Request): from ..login.storage import AsyncpgStorage, get_plugin_storage - from ..login.utils import ACTIVE, GUEST, get_client_ip, get_random_string + from ..login.utils import ACTIVE, GUEST, get_random_string from ..security.api import encrypt_password db: AsyncpgStorage = get_plugin_storage(request.app) @@ -170,7 +170,6 @@ async def _create_temporary_user(request: web.Request): "password_hash": encrypt_password(password), "status": ACTIVE, "role": GUEST, - "created_ip": get_client_ip(request), "expires_at": expires_at, } ) 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 205e2304510..c7f8ef11b0c 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 @@ -18,7 +18,7 @@ from ..garbage_collector.settings import GUEST_USER_RC_LOCK_FORMAT from ..login.storage import AsyncpgStorage, get_plugin_storage -from ..login.utils import ACTIVE, GUEST, get_client_ip, get_random_string +from ..login.utils import ACTIVE, GUEST, get_random_string from ..redis import get_redis_lock_manager_client from ..security.api import authorized_userid, encrypt_password, is_anonymous, remember from ..users.api import get_user @@ -99,7 +99,6 @@ async def _create_temporary_guest_user(request: web.Request): "password_hash": encrypt_password(password), "status": ACTIVE, "role": GUEST, - "created_ip": get_client_ip(request), "expires_at": expires_at, } ) diff --git a/services/web/server/src/simcore_service_webserver/templates/common/close_account.jinja2 b/services/web/server/src/simcore_service_webserver/templates/common/close_account.jinja2 new file mode 100644 index 00000000000..eec9dbf21a4 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/templates/common/close_account.jinja2 @@ -0,0 +1,31 @@ +🐼 Closing your account in {{ host }} and best wishes + +
+Dear {{ name }}, +
++We've received your account closure request, and we want to say thank you for being a part of our platform. While we're sad to see you go, we respect your decision. +
+
+Your studies and data will be securely retained for {{ retention_days }} days.
+Within that period if you ever decide to return, you can reactivate your account by sending us an email to {{ support_email }}.
+Afterwards it will be completely deleted from our system.
+
+Until then, we wish you all the best in your future endeavors. +
+ +Warm regards,+ Our support team is here to help with anything you need. Please feel free to contact us to {{ support_email }} in case + of questions or any required assistance. +
diff --git a/services/web/server/src/simcore_service_webserver/users/_api.py b/services/web/server/src/simcore_service_webserver/users/_api.py new file mode 100644 index 00000000000..966ee27198b --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/users/_api.py @@ -0,0 +1,57 @@ +import logging +from typing import NamedTuple + +from aiohttp import web +from models_library.emails import LowerCaseEmailStr +from models_library.users import UserID +from pydantic import parse_obj_as +from simcore_postgres_database.models.users import ( + FullNameTuple, + UserNameConverter, + UserStatus, +) + +from ..db.plugin import get_database_engine +from ._db import get_user_or_raise +from ._db import list_user_permissions as db_list_of_permissions +from ._db import update_user_status +from .schemas import Permission + +_logger = logging.getLogger(__name__) + + +async def list_user_permissions( + app: web.Application, user_id: UserID, product_name: str +) -> list[Permission]: + permissions: list[Permission] = await db_list_of_permissions( + app, user_id=user_id, product_name=product_name + ) + return permissions + + +class UserCredentialsTuple(NamedTuple): + email: LowerCaseEmailStr + password_hash: str + full_name: FullNameTuple + + +async def get_user_credentials( + app: web.Application, *, user_id: UserID +) -> UserCredentialsTuple: + row = await get_user_or_raise( + get_database_engine(app), + user_id=user_id, + return_column_names=["name", "email", "password_hash"], + ) + + return UserCredentialsTuple( + email=parse_obj_as(LowerCaseEmailStr, row.email), + password_hash=row.password_hash, + full_name=UserNameConverter.get_full_name(row.name), + ) + + +async def set_user_as_deleted(app: web.Application, user_id: UserID) -> None: + await update_user_status( + get_database_engine(app), user_id=user_id, new_status=UserStatus.DELETED + ) diff --git a/services/web/server/src/simcore_service_webserver/users/_db.py b/services/web/server/src/simcore_service_webserver/users/_db.py index 8796bccae78..1814dd6b698 100644 --- a/services/web/server/src/simcore_service_webserver/users/_db.py +++ b/services/web/server/src/simcore_service_webserver/users/_db.py @@ -1,9 +1,9 @@ import contextlib -from typing import NamedTuple import sqlalchemy as sa from aiohttp import web from aiopg.sa.connection import SAConnection +from aiopg.sa.engine import Engine from aiopg.sa.result import ResultProxy, RowProxy from models_library.users import GroupID, UserID from simcore_postgres_database.models.users import UserStatus, users @@ -17,21 +17,29 @@ from ..db.plugin import get_database_engine from .schemas import Permission +_ALL = None -async def do_update_expired_users(conn: SAConnection) -> list[UserID]: - result: ResultProxy = await conn.execute( - users.update() - .values(status=UserStatus.EXPIRED) - .where( - (users.c.expires_at.is_not(None)) - & (users.c.status == UserStatus.ACTIVE) - & (users.c.expires_at < sa.sql.func.now()) - ) - .returning(users.c.id) - ) - if rows := await result.fetchall(): - return [r.id for r in rows] - return [] + +async def get_user_or_raise( + engine: Engine, *, user_id: UserID, return_column_names: list[str] | None = _ALL +) -> RowProxy: + if return_column_names == _ALL: + return_column_names = list(users.columns.keys()) + + assert return_column_names is not None # nosec + assert set(return_column_names).issubset(users.columns.keys()) # nosec + + async with engine.acquire() as conn: + row: RowProxy | None = await ( + await conn.execute( + sa.select(*(users.columns[name] for name in return_column_names)).where( + users.c.id == user_id + ) + ) + ).first() + if row is None: + raise UserNotFoundError(uid=user_id) + return row async def get_users_ids_in_group(conn: SAConnection, gid: GroupID) -> set[UserID]: @@ -65,21 +73,26 @@ async def list_user_permissions( return [override_services_specifications] -class UserNameAndEmailTuple(NamedTuple): - name: str - email: str +async def do_update_expired_users(conn: SAConnection) -> list[UserID]: + result: ResultProxy = await conn.execute( + users.update() + .values(status=UserStatus.EXPIRED) + .where( + (users.c.expires_at.is_not(None)) + & (users.c.status == UserStatus.ACTIVE) + & (users.c.expires_at < sa.sql.func.now()) + ) + .returning(users.c.id) + ) + if rows := await result.fetchall(): + return [r.id for r in rows] + return [] -async def get_username_and_email( - connection: SAConnection, user_id: UserID -) -> UserNameAndEmailTuple: - row: RowProxy | None = await ( - await connection.execute( - sa.select(users.c.name, users.c.email).where(users.c.id == user_id) +async def update_user_status( + engine: Engine, *, user_id: UserID, new_status: UserStatus +): + async with engine.acquire() as conn: + await conn.execute( + users.update().values(status=new_status).where(users.c.id == user_id) ) - ).first() - if row is None: - raise UserNotFoundError(uid=user_id) - assert row.name # nosec - assert row.email # nosec - return UserNameAndEmailTuple(name=row.name, email=row.email) diff --git a/services/web/server/src/simcore_service_webserver/users/_handlers.py b/services/web/server/src/simcore_service_webserver/users/_handlers.py index 6de1c24db08..8a59b437fa1 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -1,4 +1,5 @@ import functools +import logging import redis.asyncio as aioredis from aiohttp import web @@ -18,7 +19,7 @@ from ..redis import get_redis_user_notifications_client from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import _tokens, api +from . import _api, _tokens, api from ._notifications import ( MAX_NOTIFICATIONS_FOR_USER_TO_KEEP, MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, @@ -27,10 +28,12 @@ UserNotificationPatch, get_notification_key, ) -from .api import list_user_permissions as api_list_user_permissions from .exceptions import TokenNotFoundError, UserNotFoundError from .schemas import Permission, PermissionGet, ProfileGet, ProfileUpdate, TokenCreate +_logger = logging.getLogger(__name__) + + routes = web.RouteTableDef() @@ -214,7 +217,7 @@ async def mark_notification_as_read(request: web.Request) -> web.Response: @permission_required("user.permissions.read") async def list_user_permissions(request: web.Request) -> web.Response: req_ctx = _RequestContext.parse_obj(request) - list_permissions: list[Permission] = await api_list_user_permissions( + list_permissions: list[Permission] = await _api.list_user_permissions( request.app, req_ctx.user_id, req_ctx.product_name ) return envelope_json_response( diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py index 4b49c9f56d2..ad998674b1e 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -6,7 +6,7 @@ import logging from collections import deque -from typing import Any, TypedDict +from typing import Any, NamedTuple, TypedDict import sqlalchemy as sa from aiohttp import web @@ -23,11 +23,10 @@ from ..login.storage import AsyncpgStorage, get_plugin_storage from ..security.api import clean_auth_policy_cache from . import _db -from ._db import UserNameAndEmailTuple -from ._db import list_user_permissions as db_list_of_permissions +from ._api import get_user_credentials, set_user_as_deleted from ._preferences_api import get_frontend_user_preferences_aggregation from .exceptions import UserNotFoundError -from .schemas import Permission, ProfileGet, ProfileUpdate, convert_user_db_to_schema +from .schemas import ProfileGet, ProfileUpdate, convert_user_db_to_schema _logger = logging.getLogger(__name__) @@ -67,7 +66,7 @@ async def get_user_profile( ) .where(users.c.id == user_id) .order_by(sa.asc(groups.c.name)) - .apply_labels() + .set_label_style(sa.LABEL_STYLE_TABLENAME_PLUS_COL) ): user_profile.update(convert_user_db_to_schema(row, prefix="users_")) if row["groups_type"] == GroupType.EVERYONE: @@ -161,21 +160,27 @@ async def get_user_role(app: web.Application, user_id: UserID) -> UserRole: return UserRole(user_role) +class UserNameAndEmailTuple(NamedTuple): + name: str + email: str + + async def get_user_name_and_email( app: web.Application, *, user_id: UserID ) -> UserNameAndEmailTuple: """ Raises: - UserNotFoundError: _description_ + UserNotFoundError Returns: (user, email) """ - async with get_database_engine(app).acquire() as conn: - return await _db.get_username_and_email( - conn, - user_id=_parse_as_user(user_id), - ) + row = await _db.get_user_or_raise( + get_database_engine(app), + user_id=_parse_as_user(user_id), + return_column_names=["name", "email"], + ) + return UserNameAndEmailTuple(name=row.name, email=row.email) async def get_guest_user_ids_and_names(app: web.Application) -> list[tuple[int, str]]: @@ -239,14 +244,8 @@ async def get_user(app: web.Application, user_id: UserID) -> dict: """ :raises UserNotFoundError: """ - engine = get_database_engine(app) - user_id = _parse_as_user(user_id) - async with engine.acquire() as conn: - result = await conn.execute(sa.select(users).where(users.c.id == user_id)) - row: RowProxy = await result.fetchone() - if not row: - raise UserNotFoundError(uid=user_id) - return dict(row) + row = await _db.get_user_or_raise(engine=get_database_engine(app), user_id=user_id) + return dict(row) async def get_user_id_from_gid(app: web.Application, primary_gid: int) -> UserID: @@ -269,10 +268,10 @@ async def update_expired_users(engine: Engine) -> list[UserID]: return await _db.do_update_expired_users(conn) -async def list_user_permissions( - app: web.Application, user_id: UserID, product_name: str -) -> list[Permission]: - list_of_permissions = await db_list_of_permissions( - app, user_id=user_id, product_name=product_name - ) - return list_of_permissions +assert set_user_as_deleted # nosec +assert get_user_credentials # nosec + +__all__: tuple[str, ...] = ( + "get_user_credentials", + "set_user_as_deleted", +) diff --git a/services/web/server/src/simcore_service_webserver/users/plugin.py b/services/web/server/src/simcore_service_webserver/users/plugin.py index 86a2df52a0c..d237d5c02bb 100644 --- a/services/web/server/src/simcore_service_webserver/users/plugin.py +++ b/services/web/server/src/simcore_service_webserver/users/plugin.py @@ -6,6 +6,7 @@ from aiohttp import web from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup +from servicelib.aiohttp.observer import setup_observer_registry from . import _handlers, _preferences_handlers @@ -21,5 +22,7 @@ ) def setup_users(app: web.Application): assert app[APP_SETTINGS_KEY].WEBSERVER_USERS # nosec + setup_observer_registry(app) + app.router.add_routes(_handlers.routes) app.router.add_routes(_preferences_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py index 900caf43df6..f9e5b98b6a3 100644 --- a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py +++ b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py @@ -17,7 +17,7 @@ from ._constants import INDEX_RESOURCE_NAME -log = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) def rename_routes_as_handler_function(routes: RouteTableDef, *, prefix: str): @@ -101,7 +101,7 @@ def create_redirect_to_page_response( Front-end can then render this data either in an error or a view page """ - log.debug("page: '%s' parameters: '%s'", page, parameters) + _logger.debug("page: '%s' parameters: '%s'", page, parameters) assert page in ("view", "error") # nosec # NOTE: uniform encoding in front-end using url fragments diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py index d26d5a6e6ac..7f76229ee3f 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py @@ -12,7 +12,6 @@ from faker import Faker from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_login import NewUser -from servicelib.aiohttp.rest_responses import unwrap_envelope from simcore_service_webserver._constants import APP_SETTINGS_KEY from simcore_service_webserver.db.models import UserStatus from simcore_service_webserver.login._constants import ( @@ -20,6 +19,7 @@ MSG_LOGGED_IN, MSG_UNKNOWN_EMAIL, MSG_USER_BANNED, + MSG_USER_DELETED, MSG_USER_EXPIRED, MSG_WRONG_PASSWORD, ) @@ -39,77 +39,79 @@ async def test_login_with_unknown_email(client: TestClient): assert client.app url = client.app.router["auth_login"].url_for() r = await client.post( - f"{url}", json={"email": "unknown@email.com", "password": "wrong."} + url.path, json={"email": "unknown@email.com", "password": "wrong."} ) - payload = await r.json() - assert r.status == web.HTTPUnauthorized.status_code, str(payload) - assert r.url.path == url.path - assert MSG_UNKNOWN_EMAIL in await r.text() + _, error = await assert_status(r, web.HTTPUnauthorized) + assert MSG_UNKNOWN_EMAIL in error["errors"][0]["message"] + assert len(error["errors"]) == 1 async def test_login_with_wrong_password(client: TestClient): assert client.app url = client.app.router["auth_login"].url_for() - r = await client.post(f"{url}") + r = await client.post(url.path) payload = await r.json() assert MSG_WRONG_PASSWORD not in await r.text(), str(payload) async with NewUser(app=client.app) as user: r = await client.post( - f"{url}", + url.path, json={ "email": user["email"], "password": "wrong.", }, ) - payload = await r.json() - assert r.status == web.HTTPUnauthorized.status_code, str(payload) - assert r.url.path == url.path - assert MSG_WRONG_PASSWORD in await r.text() + _, error = await assert_status(r, web.HTTPUnauthorized) + assert MSG_WRONG_PASSWORD in error["errors"][0]["message"] + assert len(error["errors"]) == 1 @pytest.mark.parametrize( "user_status,expected_msg", - ((UserStatus.BANNED, MSG_USER_BANNED), (UserStatus.EXPIRED, MSG_USER_EXPIRED)), + [ + (UserStatus.BANNED, MSG_USER_BANNED), + (UserStatus.EXPIRED, MSG_USER_EXPIRED), + (UserStatus.DELETED, MSG_USER_DELETED), + ], ) async def test_login_blocked_user( client: TestClient, user_status: UserStatus, expected_msg: str ): assert client.app url = client.app.router["auth_login"].url_for() - r = await client.post(f"{url}") + r = await client.post(url.path) assert expected_msg not in await r.text() async with NewUser({"status": user_status.name}, app=client.app) as user: r = await client.post( - f"{url}", json={"email": user["email"], "password": user["raw_password"]} + url.path, json={"email": user["email"], "password": user["raw_password"]} ) - payload = await r.json() - assert r.status == web.HTTPUnauthorized.status_code, str(payload) - assert r.url.path == url.path + _, error = await assert_status(r, web.HTTPUnauthorized) # expected_msg contains {support_email} at the end of the string - assert expected_msg[:-20] in payload["error"]["errors"][0]["message"] + assert expected_msg[: -len("xxx{support_email}")] in error["errors"][0]["message"] + assert len(error["errors"]) == 1 async def test_login_inactive_user(client: TestClient): assert client.app url = client.app.router["auth_login"].url_for() - r = await client.post(f"{url}") + r = await client.post(url.path) assert MSG_ACTIVATION_REQUIRED not in await r.text() async with NewUser( {"status": UserStatus.CONFIRMATION_PENDING.name}, app=client.app ) as user: r = await client.post( - f"{url}", json={"email": user["email"], "password": user["raw_password"]} + url.path, json={"email": user["email"], "password": user["raw_password"]} ) - assert r.status == web.HTTPUnauthorized.status_code - assert r.url.path == url.path - assert MSG_ACTIVATION_REQUIRED in await r.text() + + _, error = await assert_status(r, web.HTTPUnauthorized) + assert MSG_ACTIVATION_REQUIRED in error["errors"][0]["message"] + assert len(error["errors"]) == 1 async def test_login_successfully(client: TestClient): @@ -118,13 +120,10 @@ async def test_login_successfully(client: TestClient): async with NewUser(app=client.app) as user: r = await client.post( - f"{url}", json={"email": user["email"], "password": user["raw_password"]} + url.path, json={"email": user["email"], "password": user["raw_password"]} ) - assert r.status == 200 - data, error = unwrap_envelope(await r.json()) - assert not error - assert data + data, _ = await assert_status(r, web.HTTPOk) assert MSG_LOGGED_IN in data["message"] @@ -138,17 +137,13 @@ async def test_login_successfully_with_email_containing_uppercase_letters( # Testing auth with upper case email for user registered with lower case email async with NewUser(app=client.app) as user: r = await client.post( - f"{url}", + url.path, json={ "email": user["email"].upper(), # <--- upper case email "password": user["raw_password"], }, ) - assert r.status == 200 - data, error = unwrap_envelope(await r.json()) - - assert not error - assert data + data, _ = await assert_status(r, web.HTTPOk) assert MSG_LOGGED_IN in data["message"] diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_unregister.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_unregister.py new file mode 100644 index 00000000000..834fc450002 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_unregister.py @@ -0,0 +1,146 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from unittest.mock import MagicMock + +import pytest +from aiohttp import ClientResponseError, web +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture +from pytest_simcore.helpers.utils_assert import assert_status +from pytest_simcore.helpers.utils_login import NewUser, UserInfoDict +from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver.login._constants import MSG_USER_DELETED + + +@pytest.mark.parametrize( + "user_role", [role for role in UserRole if role < UserRole.USER] +) +async def test_unregister_account_access_rights( + client: TestClient, logged_user: UserInfoDict, mocker: MockerFixture +): + response = await client.post( + "/v0/auth/unregister", + json={ + "email": logged_user["email"], + "password": logged_user["raw_password"], + }, + ) + + with pytest.raises(ClientResponseError) as err_info: + response.raise_for_status() + + error = err_info.value + assert error.status in ( + web.HTTPUnauthorized.status_code, + web.HTTPForbidden.status_code, + ), f"{error}" + + +@pytest.fixture +def mocked_send_email(mocker: MockerFixture) -> MagicMock: + # OVERRIDES services/web/server/tests/unit/with_dbs/conftest.py:mocked_send_email fixture + return mocker.patch( + "simcore_service_webserver.email._core._do_send_mail", + spec=True, + ) + + +@pytest.mark.parametrize( + "user_role", [role for role in UserRole if role >= UserRole.USER] +) +async def test_unregister_account( + client: TestClient, logged_user: UserInfoDict, mocked_send_email: MagicMock +): + assert client.app + + # is logged in + response = await client.get("/v0/me") + await assert_status(response, web.HTTPOk) + + # success to request deletion of account + response = await client.post( + "/v0/auth/unregister", + json={ + "email": logged_user["email"], + "password": logged_user["raw_password"], + }, + ) + await assert_status(response, web.HTTPOk) + + # sent email? + mimetext = mocked_send_email.call_args[1]["message"] + assert mimetext["Subject"] + assert mimetext["To"] == logged_user["email"] + + # should be logged-out + response = await client.get("/v0/me") + await assert_status(response, web.HTTPUnauthorized) + + # try to login again and get rejected + response = await client.post( + "/v0/auth/login", + json={"email": logged_user["email"], "password": logged_user["raw_password"]}, + ) + _, error = await assert_status(response, web.HTTPUnauthorized) + + prefix_msg = MSG_USER_DELETED.format(support_email="").strip() + assert prefix_msg in error["errors"][0]["message"] + + +@pytest.mark.parametrize( + "user_role", [role for role in UserRole if role >= UserRole.USER] +) +async def test_cannot_unregister_other_account( + client: TestClient, logged_user: UserInfoDict, mocked_send_email: MagicMock +): + assert client.app + + # is logged in + response = await client.get("/v0/me") + await assert_status(response, web.HTTPOk) + + # cannot delete another account + async with NewUser(app=client.app) as other_user: + response = await client.post( + "/v0/auth/unregister", + json={ + "email": other_user["email"], + "password": other_user["raw_password"], + }, + ) + await assert_status(response, web.HTTPConflict) + + +@pytest.mark.parametrize("invalidate", ["email", "raw_password"]) +@pytest.mark.parametrize( + "user_role", [role for role in UserRole if role >= UserRole.USER] +) +async def test_cannot_unregister_invalid_credentials( + client: TestClient, + logged_user: UserInfoDict, + mocked_send_email: MagicMock, + invalidate: str, +): + assert client.app + + # check is logged in + response = await client.get("/v0/me") + await assert_status(response, web.HTTPOk) + + # check cannot invalid credentials + credentials = {k: logged_user[k] for k in ("email", "raw_password")} + credentials[invalidate] += "error" + + response = await client.post( + "/v0/auth/unregister", + json={ + "email": credentials["email"], + "password": credentials["raw_password"], + }, + ) + await assert_status(response, web.HTTPConflict)