Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/specs/web-server/_users_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from models_library.generics import Envelope
from models_library.rest_pagination import Page
from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.users._common.schemas import PreRegisteredUserGet
from simcore_service_webserver.users.schemas import PreRegisteredUserGet

router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,9 @@ class UserAccountGet(OutputSchema):
),
] = DEFAULT_FACTORY

# pre-registration
# pre-registration NOTE: that some users have no pre-registartion and therefore all options here can be none
pre_registration_id: int | None
pre_registration_created: datetime | None
invited_by: str | None = None
account_request_status: AccountRequestStatus | None
account_request_reviewed_by: UserID | None = None
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# pylint:disable=unused-import

from sys import version
from typing import Final

from common_library.user_messages import user_message
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from common_library.user_messages import user_message
from servicelib.aiohttp import status

from ....exception_handling import (
ExceptionToHttpErrorMap,
HttpErrorInfo,
exception_handling_decorator,
to_exceptions_handlers_map,
)
from ....users.exceptions import AlreadyPreRegisteredError
from ..._constants import MSG_2FA_UNAVAILABLE
from ...errors import SendingVerificationEmailError, SendingVerificationSmsError

_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
AlreadyPreRegisteredError: HttpErrorInfo(
status.HTTP_409_CONFLICT,
user_message(
"An account for the email {email} has been submitted. If you haven't received any updates, please contact support.",
_version=1,
),
),
SendingVerificationSmsError: HttpErrorInfo(
status.HTTP_503_SERVICE_UNAVAILABLE,
MSG_2FA_UNAVAILABLE,
),
SendingVerificationEmailError: HttpErrorInfo(
status.HTTP_503_SERVICE_UNAVAILABLE,
MSG_2FA_UNAVAILABLE,
),
}


handle_rest_requests_exceptions = exception_handling_decorator(
to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP)
)
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
)
from ..._models import InputSchema
from ...decorators import login_required
from ...errors import handle_login_exceptions
from ...settings import LoginSettingsForProduct, get_plugin_settings
from ._rest_exceptions import handle_rest_requests_exceptions

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -75,7 +75,7 @@ class LoginNextPage(NextPage[CodePageParams]): ...
name="auth_resend_2fa_code",
max_access_count=MAX_2FA_CODE_RESEND,
)
@handle_login_exceptions
@handle_rest_requests_exceptions
async def login(request: web.Request):
"""Login: user submits an email (identification) and a password
Expand Down Expand Up @@ -218,6 +218,7 @@ class LoginTwoFactorAuthBody(InputSchema):
"auth_login_2fa",
unauthorized_reason=MSG_UNAUTHORIZED_LOGIN_2FA,
)
@handle_rest_requests_exceptions
async def login_2fa(request: web.Request):
"""Login (continuation): Submits 2FA code"""
product: Product = products_web.get_current_product(request)
Expand Down Expand Up @@ -266,6 +267,7 @@ class LogoutBody(InputSchema):

@routes.post(f"/{API_VTAG}/auth/logout", name="auth_logout")
@login_required
@handle_rest_requests_exceptions
async def logout(request: web.Request) -> web.Response:
user_id = request.get(RQT_USERID_KEY, -1)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from pydantic import SecretStr, field_validator
from servicelib.aiohttp.requests_validation import parse_request_body_as
from servicelib.logging_errors import create_troubleshootting_log_kwargs
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
from servicelib.request_keys import RQT_USERID_KEY
from simcore_postgres_database.utils_repos import pass_or_acquire_connection
from simcore_postgres_database.utils_users import UsersRepo
Expand Down Expand Up @@ -296,9 +295,7 @@ async def change_password(request: web.Request):
if not security_service.check_password(
passwords.current.get_secret_value(), user["password_hash"]
):
raise web.HTTPUnprocessableEntity(
text=MSG_WRONG_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON
) # 422
raise web.HTTPUnprocessableEntity(text=MSG_WRONG_PASSWORD) # 422

await db.update_user(
dict(user),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
from servicelib.aiohttp.requests_validation import parse_request_body_as
from servicelib.logging_utils import get_log_record_extra, log_context
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
from servicelib.request_keys import RQT_USERID_KEY
from servicelib.utils import fire_and_forget_task

Expand All @@ -22,8 +21,9 @@
from ....products.models import Product
from ....security import security_service, security_web
from ....security.decorators import permission_required
from ....session.api import get_session
from ....users.api import get_user_credentials, set_user_as_deleted
from ....session import api as session_service
from ....users import api as users_service
from ....users._common.schemas import PreRegisteredUserGet
from ....utils import MINUTE
from ....utils_rate_limiting import global_rate_limit_route
from ... import _preregistration_service
Expand All @@ -35,6 +35,7 @@
from ..._login_service import flash_response, notify_user_logout
from ...decorators import login_required
from ...settings import LoginSettingsForProduct, get_plugin_settings
from ._rest_exceptions import handle_rest_requests_exceptions

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,24 +63,30 @@ def _get_ipinfo(request: web.Request) -> dict[str, Any]:
name="request_product_account",
)
@global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE)
@handle_rest_requests_exceptions
async def request_product_account(request: web.Request):
product = products_web.get_current_product(request)
session = await get_session(request)
session = await session_service.get_session(request)

body = await parse_request_body_as(AccountRequestInfo, request)
assert body.form # nosec
assert body.captcha # nosec

if body.captcha != session.get(CAPTCHA_SESSION_KEY):
raise web.HTTPUnprocessableEntity(
text=MSG_WRONG_CAPTCHA__INVALID, content_type=MIMETYPE_APPLICATION_JSON
)
raise web.HTTPUnprocessableEntity(text=MSG_WRONG_CAPTCHA__INVALID)
session.pop(CAPTCHA_SESSION_KEY, None)

# send email to fogbugz or user itself
# create pre-regiatration or raise if already exists
await _preregistration_service.create_pre_registration(
request.app,
profile=PreRegisteredUserGet.model_validate(body.form),
product_name=product.name,
)

# if created send email to fogbugz or user itself
fire_and_forget_task(
_preregistration_service.send_account_request_email_to_support(
request,
request=request,
product=product,
request_form=body.form,
ipinfo=_get_ipinfo(request),
Expand All @@ -98,6 +105,7 @@ class _AuthenticatedContext(BaseModel):
@routes.post(f"/{API_VTAG}/auth/unregister", name="unregister_account")
@login_required
@permission_required("user.profile.delete")
@handle_rest_requests_exceptions
async def unregister_account(request: web.Request):
req_ctx = _AuthenticatedContext.model_validate(request)
body = await parse_request_body_as(UnregisterCheck, request)
Expand All @@ -108,7 +116,9 @@ async def unregister_account(request: web.Request):
)

# checks before deleting
credentials = await get_user_credentials(request.app, user_id=req_ctx.user_id)
credentials = await users_service.get_user_credentials(
request.app, user_id=req_ctx.user_id
)
if body.email != credentials.email.lower() or not security_service.check_password(
body.password.get_secret_value(), credentials.password_hash
):
Expand All @@ -124,7 +134,7 @@ async def unregister_account(request: web.Request):
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)
await users_service.set_user_as_deleted(request.app, user_id=req_ctx.user_id)

# logout
await notify_user_logout(
Expand All @@ -150,8 +160,9 @@ async def unregister_account(request: web.Request):

@routes.get(f"/{API_VTAG}/auth/captcha", name="create_captcha")
@global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE)
@handle_rest_requests_exceptions
async def create_captcha(request: web.Request):
session = await get_session(request)
session = await session_service.get_session(request)

captcha_text, image_data = await _preregistration_service.create_captcha()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
from ..._login_repository_legacy import AsyncpgStorage, get_plugin_storage
from ..._login_service import envelope_response
from ..._models import InputSchema
from ...errors import handle_login_exceptions
from ...settings import LoginSettingsForProduct, get_plugin_settings
from ._rest_exceptions import handle_rest_requests_exceptions

_logger = logging.getLogger(__name__)

Expand All @@ -42,7 +42,7 @@ class Resend2faBody(InputSchema):
name="auth_resend_2fa_code",
one_time_access=False,
)
@handle_login_exceptions
@handle_rest_requests_exceptions
async def resend_2fa_code(request: web.Request):
"""Resends 2FA code via SMS/Email"""
product: Product = products_web.get_current_product(request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from captcha.image import ImageCaptcha
from common_library.json_serialization import json_dumps
from models_library.emails import LowerCaseEmailStr
from models_library.products import ProductName
from models_library.utils.fastapi_encoders import jsonable_encoder
from PIL.Image import Image
from pydantic import EmailStr, PositiveInt, TypeAdapter, ValidationError
Expand All @@ -15,6 +16,8 @@
from ..email.utils import send_email_from_template
from ..products import products_web
from ..products.models import Product
from ..users import _users_service
from ..users.schemas import PreRegisteredUserGet

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -122,3 +125,12 @@ async def create_captcha() -> tuple[str, bytes]:
image_data = img_byte_arr.getvalue()

return (captcha_text, image_data)


async def create_pre_registration(
app: web.Application, profile: PreRegisteredUserGet, product_name: ProductName
):

await _users_service.pre_register_user(
app, profile=profile, creator_user_id=None, product_name=product_name
)
Original file line number Diff line number Diff line change
@@ -1,15 +1,4 @@
import functools
import logging

from aiohttp import web
from servicelib.aiohttp.typing_extension import Handler
from servicelib.logging_errors import create_troubleshootting_log_kwargs
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON

from ..errors import WebServerBaseError
from ._constants import MSG_2FA_UNAVAILABLE

_logger = logging.getLogger(__name__)


class LoginError(WebServerBaseError, ValueError): ...
Expand All @@ -21,28 +10,3 @@ class SendingVerificationSmsError(LoginError):

class SendingVerificationEmailError(LoginError):
msg_template = "Sending verification email failed. {reason}"


def handle_login_exceptions(handler: Handler):
@functools.wraps(handler)
async def wrapper(request: web.Request) -> web.StreamResponse:
try:
return await handler(request)

except (SendingVerificationSmsError, SendingVerificationEmailError) as exc:
error_code = exc.error_code()
front_end_msg = MSG_2FA_UNAVAILABLE
# in these cases I want to log the cause
_logger.exception(
**create_troubleshootting_log_kwargs(
front_end_msg,
error=exc,
error_code=error_code,
)
)
raise web.HTTPServiceUnavailable(
text=front_end_msg,
content_type=MIMETYPE_APPLICATION_JSON,
) from exc

return wrapper
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,7 @@ async def review_user_pre_registration(
pre_registration_id: int,
reviewed_by: UserID,
new_status: AccountRequestStatus,
invitation_extras: dict[str, Any] | None = None,
) -> None:
"""Updates the account request status of a pre-registered user.

Expand All @@ -703,19 +704,44 @@ async def review_user_pre_registration(
pre_registration_id: ID of the pre-registration record
reviewed_by: ID of the user who reviewed the request
new_status: New status (APPROVED or REJECTED)
invitation_extras: Optional invitation data to store in extras field
"""
if new_status not in (AccountRequestStatus.APPROVED, AccountRequestStatus.REJECTED):
msg = f"Invalid status for review: {new_status}. Must be APPROVED or REJECTED."
raise ValueError(msg)

async with transaction_context(engine, connection) as conn:
# Base update values
update_values = {
"account_request_status": new_status,
"account_request_reviewed_by": reviewed_by,
"account_request_reviewed_at": sa.func.now(),
}

# Add invitation extras to the existing extras if provided
if invitation_extras is not None:
assert list(invitation_extras.keys()) == ["invitation"] # nosec

# Get the current extras first
current_extras_result = await conn.execute(
sa.select(users_pre_registration_details.c.extras).where(
users_pre_registration_details.c.id == pre_registration_id
)
)
current_extras_row = current_extras_result.one_or_none()
current_extras = (
current_extras_row.extras
if current_extras_row and current_extras_row.extras
else {}
)

# Merge with invitation extras
merged_extras = {**current_extras, **invitation_extras}
update_values["extras"] = merged_extras

await conn.execute(
users_pre_registration_details.update()
.values(
account_request_status=new_status,
account_request_reviewed_by=reviewed_by,
account_request_reviewed_at=sa.func.now(),
)
.values(**update_values)
.where(users_pre_registration_details.c.id == pre_registration_id)
)

Expand Down Expand Up @@ -766,6 +792,7 @@ async def search_merged_pre_and_registered_users(
users_pre_registration_details.c.account_request_reviewed_at,
users.c.status,
invited_by,
users_pre_registration_details.c.created,
)

join_condition = users.c.id == users_pre_registration_details.c.user_id
Expand Down
Loading
Loading