diff --git a/api/specs/webserver/openapi-auth.yaml b/api/specs/webserver/openapi-auth.yaml index 0b0c808811a..a4496b14f50 100644 --- a/api/specs/webserver/openapi-auth.yaml +++ b/api/specs/webserver/openapi-auth.yaml @@ -95,7 +95,7 @@ paths: summary: user enters 2 Factor Authentication code when login in tags: - authentication - operationId: auth_validate_2fa_login + operationId: auth_login_2fa requestBody: content: application/json: diff --git a/api/specs/webserver/openapi.yaml b/api/specs/webserver/openapi.yaml index 1f672e860ed..20c8d62a3b0 100644 --- a/api/specs/webserver/openapi.yaml +++ b/api/specs/webserver/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: "osparc-simcore web API" - version: 0.12.0 + version: 0.12.2 description: "API designed for the front-end app" contact: name: IT'IS Foundation diff --git a/api/specs/webserver/scripts/openapi_auth.py b/api/specs/webserver/scripts/openapi_auth.py index 0bed81d5456..8b70661c1a8 100644 --- a/api/specs/webserver/scripts/openapi_auth.py +++ b/api/specs/webserver/scripts/openapi_auth.py @@ -13,8 +13,22 @@ from _common import Error, Log from fastapi import FastAPI, status from models_library.generics import Envelope -from pydantic import BaseModel, EmailStr, Field, SecretStr, confloat +from pydantic import BaseModel, Field, confloat from simcore_service_webserver.login.api_keys_handlers import ApiKeyCreate, ApiKeyGet +from simcore_service_webserver.login.handlers import Login2faBody, LoginBody, LogoutBody +from simcore_service_webserver.login.handlers_change import ( + ChangeEmailBody, + ChangePasswordBody, + ResetPasswordBody, +) +from simcore_service_webserver.login.handlers_confirmation import ( + PhoneConfirmationBody, + ResetPasswordConfirmation, +) +from simcore_service_webserver.login.handlers_registration import ( + RegisterBody, + RegisterPhoneBody, +) app = FastAPI(redoc_url=None) @@ -23,93 +37,43 @@ ] -class RegistrationCreate(BaseModel): - email: EmailStr - password: SecretStr - confirm: Optional[SecretStr] = Field(None, description="Password confirmation") - invitation: Optional[str] = Field(None, description="Invitation code") - - class Config: - schema_extra = { - "examples": [ - { - "email": "foo@mymail.com", - "password": "my secret", - "confirm": "my secret", - "invitation": "33c451d4-17b7-4e65-9880-694559b8ffc2", - } - ] - } - - @app.post( "/auth/register", response_model=Envelope[Log], tags=TAGS, operation_id="auth_register", ) -async def register(registration: RegistrationCreate): +async def register(registration: RegisterBody): """User registration""" -class Verify2FAPhone(BaseModel): - email: EmailStr - phone: str = Field( - ..., description="Phone number E.164, needed on the deployments with 2FA" - ) - - @app.post( "/auth/verify-phone-number", response_model=Envelope[Log], tags=TAGS, operation_id="auth_verify_2fa_phone", ) -async def register_phone(registration: Verify2FAPhone): +async def register_phone(registration: RegisterPhoneBody): """user tries to verify phone number for 2 Factor Authentication when registering""" -class Validate2FAPhone(BaseModel): - email: str - phone: str = Field( - ..., description="Phone number E.164, needed on the deployments with 2FA" - ) - code: str - - @app.post( "/auth/validate-code-register", response_model=Envelope[Log], tags=TAGS, operation_id="auth_validate_2fa_register", ) -async def phone_confirmation(confirmation: Validate2FAPhone): +async def phone_confirmation(confirmation: PhoneConfirmationBody): """user enters 2 Factor Authentication code when registering""" -class LoginForm(BaseModel): - email: Optional[str] = None - password: Optional[str] = None - - -class Login2FAForm(BaseModel): - email: str - code: str - - -class LogoutRequest(BaseModel): - client_session_id: Optional[str] = Field( - None, example="5ac57685-c40f-448f-8711-70be1936fd63" - ) - - @app.post( "/auth/login", response_model=Envelope[Log], tags=TAGS, operation_id="auth_login", ) -async def login(authentication: LoginForm): +async def login(authentication: LoginBody): """user logs in""" @@ -117,9 +81,9 @@ async def login(authentication: LoginForm): "/auth/validate-code-login", response_model=Envelope[Log], tags=TAGS, - operation_id="auth_validate_2fa_login", + operation_id="auth_login_2fa", ) -async def login_2fa(authentication: Login2FAForm): +async def login_2fa(authentication: Login2faBody): """user enters 2 Factor Authentication code when login in""" @@ -129,14 +93,10 @@ async def login_2fa(authentication: Login2FAForm): tags=TAGS, operation_id="auth_logout", ) -async def logout(data: LogoutRequest): +async def logout(data: LogoutBody): """user logout""" -class ResetPasswordRequest(BaseModel): - email: str - - @app.post( "/auth/reset-password", response_model=Envelope[Log], @@ -144,15 +104,10 @@ class ResetPasswordRequest(BaseModel): operation_id="auth_reset_password", responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"model": Envelope[Error]}}, ) -async def reset_password(data: ResetPasswordRequest): +async def reset_password(data: ResetPasswordBody): """a non logged-in user requests a password reset""" -class ResetPasswordForm(BaseModel): - password: str - confirm: str - - @app.post( "/auth/reset-password/{code}", response_model=Envelope[Log], @@ -165,14 +120,10 @@ class ResetPasswordForm(BaseModel): } }, ) -async def reset_password_allowed(code: str, data: ResetPasswordForm): +async def reset_password_allowed(code: str, data: ResetPasswordConfirmation): """changes password using a token code without being logged in""" -class ChangeEmailForm(BaseModel): - email: str - - @app.post( "/auth/change-email", response_model=Envelope[Log], @@ -189,16 +140,10 @@ class ChangeEmailForm(BaseModel): }, }, ) -async def change_email(data: ChangeEmailForm): +async def change_email(data: ChangeEmailBody): """logged in user changes email""" -class ChangePasswordForm(BaseModel): - current: str - new: str - confirm: str - - class PasswordCheckSchema(BaseModel): strength: confloat(ge=0.0, le=1.0) = Field( # type: ignore ..., @@ -230,7 +175,7 @@ class PasswordCheckSchema(BaseModel): }, }, ) -async def change_password(data: ChangePasswordForm): +async def change_password(data: ChangePasswordBody): """logged in user changes password""" 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 becc73c7729..3b7b02b7da0 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py @@ -5,8 +5,8 @@ from aiohttp import web from aiohttp.test_utils import TestClient from simcore_service_webserver.db_models import UserRole, UserStatus +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.settings import LoginOptions, get_plugin_options from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage from yarl import URL @@ -71,13 +71,12 @@ async def log_client_in( # creates user directly in db assert client.app db: AsyncpgStorage = get_plugin_storage(client.app) - cfg: LoginOptions = get_plugin_options(client.app) user = await create_fake_user(db, user_data) # login url = client.app.router["auth_login"].url_for() - r = await client.post( + reponse = await client.post( str(url), json={ "email": user["email"], @@ -86,7 +85,7 @@ async def log_client_in( ) if enable_check: - await assert_status(r, web.HTTPOk, cfg.MSG_LOGGED_IN) + await assert_status(reponse, web.HTTPOk, MSG_LOGGED_IN) return user diff --git a/packages/service-library/src/servicelib/aiohttp/requests_validation.py b/packages/service-library/src/servicelib/aiohttp/requests_validation.py index 396b6b32cd8..526a4b9c1fa 100644 --- a/packages/service-library/src/servicelib/aiohttp/requests_validation.py +++ b/packages/service-library/src/servicelib/aiohttp/requests_validation.py @@ -142,10 +142,14 @@ async def parse_request_body_as( resource_name=request.rel_url.path, use_error_v1=use_enveloped_error_v1, ): - try: - body = await request.json() - except json.decoder.JSONDecodeError as err: - raise web.HTTPBadRequest(reason=f"Invalid json in body: {err}") + if not request.can_read_body: + # requests w/o body e.g. when model-schema is fully optional + body = {} + else: + try: + body = await request.json() + except json.decoder.JSONDecodeError as err: + raise web.HTTPBadRequest(reason=f"Invalid json in body: {err}") if hasattr(model_schema, "parse_obj"): # NOTE: model_schema can be 'list[T]' or 'dict[T]' which raise TypeError diff --git a/services/storage/src/simcore_service_storage/s3.py b/services/storage/src/simcore_service_storage/s3.py index 990f7a65592..21d34c990c0 100644 --- a/services/storage/src/simcore_service_storage/s3.py +++ b/services/storage/src/simcore_service_storage/s3.py @@ -63,8 +63,10 @@ def setup_s3(app: web.Application): log.debug("Setting up %s ...", __name__) - app.cleanup_ctx.append(setup_s3_client) - app.cleanup_ctx.append(setup_s3_bucket) + if setup_s3_client not in app.cleanup_ctx: + app.cleanup_ctx.append(setup_s3_client) + if setup_s3_bucket not in app.cleanup_ctx: + app.cleanup_ctx.append(setup_s3_bucket) def get_s3_client(app: web.Application) -> StorageS3Client: diff --git a/services/web/server/VERSION b/services/web/server/VERSION index ac454c6a1fc..26acbf080be 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.12.0 +0.12.2 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 88242a31019..c039db82ef2 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.12.0 +current_version = 0.12.2 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/_constants.py b/services/web/server/src/simcore_service_webserver/_constants.py index e02b40fa999..8c8ceb5adca 100644 --- a/services/web/server/src/simcore_service_webserver/_constants.py +++ b/services/web/server/src/simcore_service_webserver/_constants.py @@ -6,6 +6,7 @@ from servicelib.aiohttp.application_keys import ( APP_CONFIG_KEY, APP_DB_ENGINE_KEY, + APP_FIRE_AND_FORGET_TASKS_KEY, APP_JSONSCHEMA_SPECS_KEY, APP_OPENAPI_SPECS_KEY, APP_SETTINGS_KEY, 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 f7c700f8d4d..bd0067379fb 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 @@ -1,24 +1,24 @@ openapi: 3.0.0 info: title: osparc-simcore web API - version: 0.12.0 + version: 0.12.2 description: API designed for the front-end app contact: name: IT'IS Foundation email: support@simcore.io license: name: MIT - url: 'https://github.com/ITISFoundation/osparc-simcore/blob/master/LICENSE' + url: "https://github.com/ITISFoundation/osparc-simcore/blob/master/LICENSE" servers: - description: API server url: /v0 - description: Development server - url: 'http://{host}:{port}/{basePath}' + url: "http://{host}:{port}/{basePath}" variables: host: default: localhost port: - default: '8001' + default: "8001" basePath: enum: - v0 @@ -60,7 +60,7 @@ paths: summary: readiness probe for operationId: healthcheck_readiness_probe responses: - '200': + "200": description: Service information content: application/json: @@ -89,7 +89,7 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /health: get: tags: @@ -97,14 +97,14 @@ paths: summary: liveliness probe operationId: healthcheck_liveness_probe responses: - '200': + "200": description: Service information content: application/json: schema: - $ref: '#/paths/~1/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /status: get: tags: @@ -112,7 +112,7 @@ paths: summary: checks status of self and connected services operationId: get_app_status responses: - '200': + "200": description: returns app status check /status/diagnostics: get: @@ -120,9 +120,9 @@ paths: - maintenance operationId: get_app_diagnostics responses: - '200': + "200": description: returns app diagnostics report - '/status/{service_name}': + "/status/{service_name}": get: tags: - maintenance @@ -134,7 +134,7 @@ paths: schema: type: string responses: - '200': + "200": description: returns status of connected service /config: get: @@ -143,7 +143,7 @@ paths: tags: - configuration responses: - '200': + "200": description: configuration details content: application/json: @@ -166,7 +166,7 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/register: post: operationId: auth_register @@ -198,14 +198,14 @@ paths: invitation: 33c451d4-17b7-4e65-9880-694559b8ffc2 required: true responses: - '200': + "200": description: User has been succesfully registered. content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/verify-phone-number: post: summary: user tries to verify phone number for 2 Factor Authentication when registering @@ -225,19 +225,19 @@ paths: type: string phone: type: string - description: 'Phone number E.164, needed on the deployments with 2FA' + description: "Phone number E.164, needed on the deployments with 2FA" example: email: foo@mymail.com - phone: '+41123456789' + phone: "+41123456789" responses: - '202': - description: 'Accepted, SMS sent' + "202": + description: "Accepted, SMS sent" content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/validate-code-register: post: summary: user enters 2 Factor Authentication code when registering @@ -258,22 +258,22 @@ paths: type: string phone: type: string - description: 'Phone number E.164, needed on the deployments with 2FA' + description: "Phone number E.164, needed on the deployments with 2FA" code: type: string example: email: foo@mymail.com - phone: '+41123456789' - code: '1234' + phone: "+41123456789" + code: "1234" responses: - '200': + "200": description: Succesfully validated content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/login: post: summary: user logs in @@ -294,7 +294,7 @@ paths: email: foo@mymail.com password: my secret responses: - '200': + "200": description: Succesfully logged in content: application/json: @@ -316,7 +316,7 @@ paths: - INFO - ERROR message: - description: 'log message. If logger is USER, then it MUST be human readable' + description: "log message. If logger is USER, then it MUST be human readable" type: string logger: description: name of the logger receiving this message @@ -324,26 +324,26 @@ paths: required: - message example: - message: 'Hi there, Mr user' + message: "Hi there, Mr user" level: INFO logger: user-logger error: nullable: true default: null - '202': - description: 'Accepted, but 2 Factor Authentication required' + "202": + description: "Accepted, but 2 Factor Authentication required" content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/validate-code-login: post: summary: user enters 2 Factor Authentication code when login in tags: - authentication - operationId: auth_validate_2fa_login + operationId: auth_login_2fa requestBody: content: application/json: @@ -359,16 +359,16 @@ paths: type: string example: email: foo@mymail.com - code: '1234' + code: "1234" responses: - '200': + "200": description: Succesfully logged in content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/logout: post: tags: @@ -384,14 +384,14 @@ paths: type: string example: 5ac57685-c40f-448f-8711-70be1936fd63 responses: - '200': + "200": description: Succesfully logged out content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/reset-password: post: summary: a non logged-in user requests a password reset @@ -411,19 +411,19 @@ paths: example: email: foo@mymail.com responses: - '200': + "200": description: confirmation email sent to user content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' - '503': + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" + "503": description: failed to send confirmation email content: application/json: schema: - $ref: '#/components/responses/DefaultErrorResponse/content/application~1json/schema' - '/auth/reset-password/{code}': + $ref: "#/components/responses/DefaultErrorResponse/content/application~1json/schema" + "/auth/reset-password/{code}": post: tags: - authentication @@ -452,20 +452,20 @@ paths: password: my secret confirm: my secret responses: - '200': + "200": description: password was successfully changed content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' - '401': + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" + "401": description: unauthorized reset due to invalid token code content: application/json: schema: - $ref: '#/components/responses/DefaultErrorResponse/content/application~1json/schema' + $ref: "#/components/responses/DefaultErrorResponse/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/change-email: post: summary: logged in user changes email @@ -485,26 +485,26 @@ paths: example: email: foo@mymail.com responses: - '200': + "200": description: confirmation sent to new email to complete operation content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' - '401': + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" + "401": description: unauthorized user. Login required content: application/json: schema: - $ref: '#/components/responses/DefaultErrorResponse/content/application~1json/schema' - '503': + $ref: "#/components/responses/DefaultErrorResponse/content/application~1json/schema" + "503": description: unable to send confirmation email content: application/json: schema: - $ref: '#/components/responses/DefaultErrorResponse/content/application~1json/schema' + $ref: "#/components/responses/DefaultErrorResponse/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /auth/change-password: post: summary: logged in user changes password @@ -532,33 +532,33 @@ paths: new: my new secret confirm: my new secret responses: - '200': + "200": description: password was successfully changed content: application/json: schema: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema' - '401': + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema" + "401": description: unauthorized user. Login required content: application/json: schema: - $ref: '#/components/responses/DefaultErrorResponse/content/application~1json/schema' - '409': + $ref: "#/components/responses/DefaultErrorResponse/content/application~1json/schema" + "409": description: mismatch between new and confirmation passwords content: application/json: schema: - $ref: '#/components/responses/DefaultErrorResponse/content/application~1json/schema' - '422': + $ref: "#/components/responses/DefaultErrorResponse/content/application~1json/schema" + "422": description: current password is invalid content: application/json: schema: - $ref: '#/components/responses/DefaultErrorResponse/content/application~1json/schema' + $ref: "#/components/responses/DefaultErrorResponse/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' - '/auth/confirmation/{code}': + $ref: "#/components/responses/DefaultErrorResponse" + "/auth/confirmation/{code}": get: summary: email link sent to user to confirm an action tags: @@ -580,7 +580,7 @@ paths: - authentication operationId: list_api_keys responses: - '200': + "200": description: returns the display names of API keys content: application/json: @@ -588,9 +588,9 @@ paths: type: array items: type: string - '401': + "401": description: requires login to list keys - '403': + "403": description: not enough permissions to list keys post: summary: creates API keys to access public API @@ -612,7 +612,7 @@ paths: type: number description: expiration delta in seconds responses: - '200': + "200": description: Authorization granted returning API key content: application/json: @@ -625,11 +625,11 @@ paths: type: string api_secret: type: string - '400': + "400": description: key name requested is invalid - '401': + "401": description: requires login to create a key - '403': + "403": description: not enough permissions to create a key delete: summary: deletes API key by name @@ -641,13 +641,13 @@ paths: content: application/json: schema: - $ref: '#/paths/~1auth~1api-keys/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1auth~1api-keys/post/requestBody/content/application~1json/schema" responses: - '204': + "204": description: api key successfully deleted - '401': + "401": description: requires login to delete a key - '403': + "403": description: not enough permissions to delete a key /me: get: @@ -655,7 +655,7 @@ paths: tags: - user responses: - '200': + "200": description: current user profile content: application/json: @@ -666,7 +666,7 @@ paths: properties: data: allOf: - - $ref: '#/paths/~1me/put/requestBody/content/application~1json/schema/allOf/0' + - $ref: "#/paths/~1me/put/requestBody/content/application~1json/schema/allOf/0" - type: object properties: id: @@ -677,18 +677,18 @@ paths: role: type: string groups: - $ref: '#/paths/~1groups/get/responses/200/content/application~1json/schema/properties/data' + $ref: "#/paths/~1groups/get/responses/200/content/application~1json/schema/properties/data" gravatar_id: type: string expirationDate: type: string format: date - description: 'If user has a trial account, it sets the expiration date, otherwise None' + description: "If user has a trial account, it sets the expiration date, otherwise None" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" put: operationId: update_my_profile tags: @@ -708,10 +708,10 @@ paths: first_name: Pedro last_name: Crespo responses: - '204': + "204": description: updated profile default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /me/tokens: get: summary: List tokens @@ -719,7 +719,7 @@ paths: tags: - user responses: - '200': + "200": description: list of tokens content: application/json: @@ -731,12 +731,12 @@ paths: data: type: array items: - $ref: '#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema/properties/data' + $ref: "#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema/properties/data" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" post: summary: Create tokens operationId: create_tokens @@ -771,15 +771,15 @@ paths: nullable: true default: null responses: - '201': + "201": description: token created content: application/json: schema: - $ref: '#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' - '/me/tokens/{service}': + $ref: "#/components/responses/DefaultErrorResponse" + "/me/tokens/{service}": parameters: - name: service in: path @@ -792,19 +792,19 @@ paths: tags: - user responses: - '200': + "200": description: got detailed token content: application/json: schema: - $ref: '#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema" put: summary: Updates token operationId: update_token tags: - user responses: - '204': + "204": description: token has been successfully updated delete: summary: Delete token @@ -812,7 +812,7 @@ paths: tags: - user responses: - '204': + "204": description: token has been successfully deleted /groups: get: @@ -821,7 +821,7 @@ paths: tags: - group responses: - '200': + "200": description: list of the groups I belonged to content: application/json: @@ -834,18 +834,18 @@ paths: type: object properties: me: - $ref: '#/paths/~1groups/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1groups/post/requestBody/content/application~1json/schema" organizations: type: array items: - $ref: '#/paths/~1groups/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1groups/post/requestBody/content/application~1json/schema" all: - $ref: '#/paths/~1groups/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1groups/post/requestBody/content/application~1json/schema" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" post: summary: Create a new group operationId: create_group @@ -873,27 +873,27 @@ paths: type: string format: uri accessRights: - $ref: '#/paths/~1groups~1%7Bgid%7D~1users~1%7Buid%7D/patch/requestBody/content/application~1json/schema/properties/accessRights' + $ref: "#/paths/~1groups~1%7Bgid%7D~1users~1%7Buid%7D/patch/requestBody/content/application~1json/schema/properties/accessRights" required: - gid - label - description - accessRights example: - - gid: '27' + - gid: "27" label: A user description: A very special user - thumbnail: 'https://user-images.githubusercontent.com/32800795/61083844-ff48fb00-a42c-11e9-8e63-fa2d709c8baf.png' - - gid: '1' + thumbnail: "https://user-images.githubusercontent.com/32800795/61083844-ff48fb00-a42c-11e9-8e63-fa2d709c8baf.png" + - gid: "1" label: ITIS Foundation description: The Foundation for Research on Information Technologies in Society - thumbnail: 'https://user-images.githubusercontent.com/32800795/61083844-ff48fb00-a42c-11e9-8e63-fa2d709c8baf.png' - - gid: '0' + thumbnail: "https://user-images.githubusercontent.com/32800795/61083844-ff48fb00-a42c-11e9-8e63-fa2d709c8baf.png" + - gid: "0" label: All description: Open to all users - thumbnail: 'https://user-images.githubusercontent.com/32800795/61083844-ff48fb00-a42c-11e9-8e63-fa2d709c8baf.png' + thumbnail: "https://user-images.githubusercontent.com/32800795/61083844-ff48fb00-a42c-11e9-8e63-fa2d709c8baf.png" responses: - '201': + "201": description: group created content: application/json: @@ -903,13 +903,13 @@ paths: - data properties: data: - $ref: '#/paths/~1groups/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1groups/post/requestBody/content/application~1json/schema" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/groups/{gid}': + $ref: "#/components/responses/DefaultErrorResponse" + "/groups/{gid}": parameters: - name: gid in: path @@ -922,14 +922,14 @@ paths: summary: Gets one group details operationId: get_group responses: - '200': + "200": description: got group content: application/json: schema: - $ref: '#/paths/~1groups/post/responses/201/content/application~1json/schema' + $ref: "#/paths/~1groups/post/responses/201/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" patch: summary: Update one group operationId: update_group @@ -941,27 +941,27 @@ paths: content: application/json: schema: - $ref: '#/paths/~1groups/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1groups/post/requestBody/content/application~1json/schema" responses: - '200': + "200": description: the modified group content: application/json: schema: - $ref: '#/paths/~1groups/post/responses/201/content/application~1json/schema' + $ref: "#/paths/~1groups/post/responses/201/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: tags: - group summary: Deletes one group operationId: delete_group responses: - '204': + "204": description: group has been successfully deleted default: - $ref: '#/components/responses/DefaultErrorResponse' - '/groups/{gid}/users': + $ref: "#/components/responses/DefaultErrorResponse" + "/groups/{gid}/users": parameters: - name: gid in: path @@ -974,7 +974,7 @@ paths: summary: Gets list of users in group operationId: get_group_users responses: - '200': + "200": description: got list of users and their respective rights content: application/json: @@ -986,12 +986,12 @@ paths: data: type: array items: - $ref: '#/paths/~1groups~1%7Bgid%7D~1users~1%7Buid%7D/get/responses/200/content/application~1json/schema/properties/data' + $ref: "#/paths/~1groups~1%7Bgid%7D~1users~1%7Buid%7D/get/responses/200/content/application~1json/schema/properties/data" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" post: tags: - group @@ -1020,11 +1020,11 @@ paths: format: email description: the user email responses: - '204': + "204": description: user successfully added default: - $ref: '#/components/responses/DefaultErrorResponse' - '/groups/{gid}/users/{uid}': + $ref: "#/components/responses/DefaultErrorResponse" + "/groups/{gid}/users/{uid}": parameters: - name: gid in: path @@ -1042,7 +1042,7 @@ paths: summary: Gets specific user in group operationId: get_group_user responses: - '200': + "200": description: got user content: application/json: @@ -1080,14 +1080,14 @@ paths: last_name: Smith login: mr.smith@matrix.com gravatar_id: a1af5c6ecc38e81f29695f01d6ceb540 - id: '1' - gid: '3' - - $ref: '#/paths/~1groups~1%7Bgid%7D~1users~1%7Buid%7D/patch/requestBody/content/application~1json/schema/properties/accessRights' + id: "1" + gid: "3" + - $ref: "#/paths/~1groups~1%7Bgid%7D~1users~1%7Buid%7D/patch/requestBody/content/application~1json/schema/properties/accessRights" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" patch: tags: - group @@ -1128,25 +1128,25 @@ paths: required: - accessRights responses: - '200': + "200": description: modified user content: application/json: schema: - $ref: '#/paths/~1groups~1%7Bgid%7D~1users~1%7Buid%7D/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1groups~1%7Bgid%7D~1users~1%7Buid%7D/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: tags: - group summary: Delete specific user in group operationId: delete_group_user responses: - '204': + "204": description: successfully removed user default: - $ref: '#/components/responses/DefaultErrorResponse' - '/groups/{gid}/classifiers': + $ref: "#/components/responses/DefaultErrorResponse" + "/groups/{gid}/classifiers": get: parameters: - name: gid @@ -1168,11 +1168,11 @@ paths: summary: Gets classifiers bundle for this group operationId: get_group_classifiers responses: - '200': + "200": description: got a bundle with all information about classifiers default: - $ref: '#/components/responses/DefaultErrorResponse' - '/groups/sparc/classifiers/scicrunch-resources/{rrid}': + $ref: "#/components/responses/DefaultErrorResponse" + "/groups/sparc/classifiers/scicrunch-resources/{rrid}": parameters: - name: rrid in: path @@ -1182,32 +1182,32 @@ paths: get: tags: - group - summary: 'Returns information on a valid RRID (https://www.force11.org/group/resource-identification-initiative)' + summary: "Returns information on a valid RRID (https://www.force11.org/group/resource-identification-initiative)" operationId: get_scicrunch_resource responses: - '200': + "200": description: Got information of a valid RRID - '400': + "400": description: Invalid RRID - '503': + "503": description: scircrunch.org service is not reachable default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" post: tags: - group summary: Adds new RRID to classifiers operationId: add_scicrunch_resource responses: - '200': + "200": description: Got information of a valid RRID - '400': + "400": description: Invalid RRID - '503': + "503": description: scircrunch.org service is not reachable default: - $ref: '#/components/responses/DefaultErrorResponse' - '/groups/sparc/classifiers/scicrunch-resources:search': + $ref: "#/components/responses/DefaultErrorResponse" + "/groups/sparc/classifiers/scicrunch-resources:search": get: parameters: - name: guess_name @@ -1220,12 +1220,12 @@ paths: summary: Returns a list of related resource provided a search name operationId: search_scicrunch_resources responses: - '200': + "200": description: Got information of a valid RRID - '503': + "503": description: scircrunch.org service is not reachable default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /storage/locations: get: summary: Get available storage locations @@ -1233,7 +1233,7 @@ paths: - storage operationId: get_storage_locations responses: - '200': + "200": description: List of availabe storage locations content: application/json: @@ -1250,8 +1250,8 @@ paths: filename: simcore.s3 id: 0 default: - $ref: '#/components/responses/DefaultErrorResponse' - '/storage/locations/{location_id}:sync': + $ref: "#/components/responses/DefaultErrorResponse" + "/storage/locations/{location_id}:sync": post: summary: Manually triggers the synchronisation of the file meta data table in the database tags: @@ -1276,8 +1276,8 @@ paths: type: boolean default: false responses: - '200': - description: 'An object containing added, changed and removed paths' + "200": + description: "An object containing added, changed and removed paths" content: application/json: schema: @@ -1303,8 +1303,8 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/storage/locations/{location_id}/files/metadata': + $ref: "#/components/responses/DefaultErrorResponse" + "/storage/locations/{location_id}/files/metadata": get: summary: Get list of file meta data tags: @@ -1317,17 +1317,17 @@ paths: schema: type: string responses: - '200': + "200": description: list of file meta-datas content: application/json: schema: type: array items: - $ref: '#/paths/~1storage~1locations~1%7Blocation_id%7D~1files~1%7Bfile_id%7D~1metadata/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1storage~1locations~1%7Blocation_id%7D~1files~1%7Bfile_id%7D~1metadata/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' - '/storage/locations/{location_id}/files/{file_id}': + $ref: "#/components/responses/DefaultErrorResponse" + "/storage/locations/{location_id}/files/{file_id}": get: summary: Returns download link for requested file tags: @@ -1345,7 +1345,7 @@ paths: schema: type: string responses: - '200': + "200": description: Returns presigned link content: application/json: @@ -1380,7 +1380,7 @@ paths: format: int64 minimum: 0 responses: - '200': + "200": description: Return upload object content: application/json: @@ -1448,9 +1448,9 @@ paths: schema: type: string responses: - '204': - description: '' - '/storage/locations/{location_id}/files/{file_id}:complete': + "204": + description: "" + "/storage/locations/{location_id}/files/{file_id}:complete": post: summary: Asks the server to complete the upload operationId: complete_upload_file @@ -1487,7 +1487,7 @@ paths: e_tag: type: string responses: - '202': + "202": description: Completion of upload is accepted content: application/json: @@ -1521,8 +1521,8 @@ paths: path.future_id: $response.body.data.links.state query.user_id: $request.query.user_id default: - $ref: '#/components/responses/DefaultErrorResponse' - '/storage/locations/{location_id}/files/{file_id}:abort': + $ref: "#/components/responses/DefaultErrorResponse" + "/storage/locations/{location_id}/files/{file_id}:abort": post: summary: Asks the server to abort the upload and revert to the last valid version if any operationId: abort_upload_file @@ -1538,12 +1538,12 @@ paths: schema: type: string responses: - '204': + "204": description: Abort OK default: - $ref: '#/components/responses/DefaultErrorResponse' - '/storage/locations/{location_id}/files/{file_id}:complete/futures/{future_id}': - post: + $ref: "#/components/responses/DefaultErrorResponse" + ? "/storage/locations/{location_id}/files/{file_id}:complete/futures/{future_id}" + : post: summary: Check for upload completion operationId: is_completed_upload_file parameters: @@ -1563,7 +1563,7 @@ paths: schema: type: string responses: - '200': + "200": description: returns state of upload completion content: application/json: @@ -1590,8 +1590,8 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/storage/locations/{location_id}/files/{file_id}/metadata': + $ref: "#/components/responses/DefaultErrorResponse" + "/storage/locations/{location_id}/files/{file_id}/metadata": get: summary: Get File Metadata tags: @@ -1609,7 +1609,7 @@ paths: schema: type: string responses: - '200': + "200": description: Returns file metadata content: application/json: @@ -1638,16 +1638,16 @@ paths: type: string example: file_uuid: simcore-testing/105/1000/3 - location_id: '0' + location_id: "0" project_name: futurology node_name: alpha file_name: example.txt - file_id: 'N:package:e263da07-2d89-45a6-8b0f-61061b913873' - created_at: '2019-06-19T12:29:03.308611Z' - last_modified: '2019-06-19T12:29:03.78852Z' + file_id: "N:package:e263da07-2d89-45a6-8b0f-61061b913873" + created_at: "2019-06-19T12:29:03.308611Z" + last_modified: "2019-06-19T12:29:03.78852Z" file_size: 73 entity_tag: a87ff679a2f3e71d9181a67b7542122c - '/storage/locations/{location_id}/datasets/{dataset_id}/metadata': + "/storage/locations/{location_id}/datasets/{dataset_id}/metadata": get: summary: Get Files Metadata tags: @@ -1665,15 +1665,15 @@ paths: schema: type: string responses: - '200': + "200": description: list of file meta-datas content: application/json: schema: - $ref: '#/paths/~1storage~1locations~1%7Blocation_id%7D~1files~1metadata/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1storage~1locations~1%7Blocation_id%7D~1files~1metadata/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' - '/storage/locations/{location_id}/datasets': + $ref: "#/components/responses/DefaultErrorResponse" + "/storage/locations/{location_id}/datasets": get: summary: Get datasets metadata tags: @@ -1686,7 +1686,7 @@ paths: schema: type: string responses: - '200': + "200": description: list of dataset meta-datas content: application/json: @@ -1700,11 +1700,11 @@ paths: display_name: type: string example: - dataset_uuid: 'N:id-aaaa' + dataset_uuid: "N:id-aaaa" display_name: simcore-testing default: - $ref: '#/components/responses/DefaultErrorResponse' - '/computations/{project_id}': + $ref: "#/components/responses/DefaultErrorResponse" + "/computations/{project_id}": get: description: Returns the last computation data tags: @@ -1719,7 +1719,7 @@ paths: type: string example: 123e4567-e89b-12d3-a456-426655440000 responses: - '200': + "200": description: Succesffully retrieved computation content: application/json: @@ -1741,15 +1741,15 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/computations/{project_id}:start': + $ref: "#/components/responses/DefaultErrorResponse" + "/computations/{project_id}:start": post: description: Starts the pipeline(s) of a given (meta) project tags: - computations operationId: start_computation parameters: - - $ref: '#/paths/~1computations~1%7Bproject_id%7D/get/parameters/0' + - $ref: "#/paths/~1computations~1%7Bproject_id%7D/get/parameters/0" requestBody: required: false content: @@ -1763,7 +1763,7 @@ paths: description: if true will force re-running all dependent nodes cluster_id: type: integer - description: 'the computation shall use the cluster described by its id, 0 is the default cluster' + description: "the computation shall use the cluster described by its id, 0 is the default cluster" default: 0 minimum: 0 subgraph: @@ -1774,7 +1774,7 @@ paths: type: string format: uuid responses: - '201': + "201": description: Successfully started the pipeline content: application/json: @@ -1800,20 +1800,20 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/computations/{project_id}:stop': + $ref: "#/components/responses/DefaultErrorResponse" + "/computations/{project_id}:stop": post: description: Stops (all) pipeline(s) of a given (meta) project tags: - computations operationId: stop_computation parameters: - - $ref: '#/paths/~1computations~1%7Bproject_id%7D/get/parameters/0' + - $ref: "#/paths/~1computations~1%7Bproject_id%7D/get/parameters/0" responses: - '204': + "204": description: Succesffully stopped the pipeline default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /projects: get: tags: @@ -1854,7 +1854,7 @@ paths: required: false description: maximum number of items to return responses: - '200': + "200": description: list of projects content: application/json: @@ -1866,12 +1866,12 @@ paths: data: type: array items: - $ref: '#/paths/~1projects~1active/get/responses/200/content/application~1json/schema/properties/data' + $ref: "#/paths/~1projects~1active/get/responses/200/content/application~1json/schema/properties/data" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" post: tags: - project @@ -1882,19 +1882,19 @@ paths: in: query schema: type: string - description: 'Option to create a project from existing template or study: from_study={study_uuid}' + description: "Option to create a project from existing template or study: from_study={study_uuid}" - name: as_template in: query schema: type: boolean default: false - description: 'Option to create a template from existing project: as_template=true' + description: "Option to create a template from existing project: as_template=true" - name: copy_data in: query schema: type: boolean default: true - description: 'Option to copy data when creating from an existing template or as a template, defaults to True' + description: "Option to copy data when creating from an existing template or as a template, defaults to True" - name: hidden in: query schema: @@ -1964,24 +1964,24 @@ paths: type: string description: project creation date pattern: '\d{4}-(12|11|10|0?[1-9])-(31|30|[0-2]?\d)T(2[0-3]|1\d|0?[0-9])(:(\d|[0-5]\d)){2}(\.\d{3})?Z' - example: '2018-07-01T11:13:43Z' + example: "2018-07-01T11:13:43Z" lastChangeDate: type: string description: last save date pattern: '\d{4}-(12|11|10|0?[1-9])-(31|30|[0-2]?\d)T(2[0-3]|1\d|0?[0-9])(:(\d|[0-5]\d)){2}(\.\d{3})?Z' - example: '2018-07-01T11:13:43Z' + example: "2018-07-01T11:13:43Z" thumbnail: type: string minLength: 0 maxLength: 2083 format: uri description: url of the latest screenshot of the project - example: 'https://placeimg.com/171/96/tech/grayscale/?0.jpg' + example: "https://placeimg.com/171/96/tech/grayscale/?0.jpg" workbench: type: object x-patternProperties: - '^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}$': - type: object + ? "^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}$" + : type: object additionalProperties: false required: - key @@ -2020,26 +2020,26 @@ paths: type: string description: url of the latest screenshot of the node example: - - 'https://placeimg.com/171/96/tech/grayscale/?0.jpg' + - "https://placeimg.com/171/96/tech/grayscale/?0.jpg" runHash: description: the hex digest of the resolved inputs +outputs hash at the time when the last outputs were generated type: - string - - 'null' + - "null" example: - a4337bc45a8fc544c03f52dc550cd6e1e87021bc896588bd79e901e2 inputs: type: object description: values of input properties patternProperties: - '^[-_a-zA-Z0-9]+$': + "^[-_a-zA-Z0-9]+$": oneOf: - type: - integer - boolean - string - number - - 'null' + - "null" - type: object additionalProperties: false required: @@ -2051,7 +2051,7 @@ paths: format: uuid output: type: string - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" - type: object additionalProperties: false required: @@ -2088,7 +2088,7 @@ paths: type: object description: values of input unit patternProperties: - '^[-_a-zA-Z0-9]+$': + "^[-_a-zA-Z0-9]+$": type: string example: - kilo-meter @@ -2099,7 +2099,7 @@ paths: description: map with key - access level pairs type: object patternProperties: - '^[-_a-zA-Z0-9]+$': + "^[-_a-zA-Z0-9]+$": type: string enum: - Invisible @@ -2121,14 +2121,14 @@ paths: default: {} type: object patternProperties: - '^[-_a-zA-Z0-9]+$': + "^[-_a-zA-Z0-9]+$": oneOf: - type: - integer - boolean - string - number - - 'null' + - "null" - type: object additionalProperties: false required: @@ -2175,7 +2175,7 @@ paths: - nodeUuid2 parent: type: - - 'null' + - "null" - string format: uuid description: Parent's (group-nodes') node ID s. @@ -2187,18 +2187,18 @@ paths: additionalProperties: false required: - x - - 'y' + - "y" properties: x: type: integer description: The x position example: - - '12' - 'y': + - "12" + "y": type: integer description: The y position example: - - '15' + - "15" deprecated: true state: title: NodeState @@ -2237,10 +2237,10 @@ paths: additionalProperties: false bootOptions: title: Boot Options - description: 'Some services provide alternative parameters to be injected at boot time. The user selection should be stored here, and it will overwrite the services''s defaults.' + description: "Some services provide alternative parameters to be injected at boot time. The user selection should be stored here, and it will overwrite the services's defaults." type: object patternProperties: - '[a-zA-Z][a-azA-Z0-9_]*': + "[a-zA-Z][a-azA-Z0-9_]*": type: string additionalProperties: true ui: @@ -2250,8 +2250,8 @@ paths: workbench: type: object x-patternProperties: - '^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}$': - type: object + ? "^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}$" + : type: object additionalProperties: false required: - position @@ -2261,18 +2261,18 @@ paths: additionalProperties: false required: - x - - 'y' + - "y" properties: x: type: integer description: The x position example: - - '12' - 'y': + - "12" + "y": type: integer description: The y position example: - - '15' + - "15" marker: type: object additionalProperties: false @@ -2283,14 +2283,14 @@ paths: type: string description: Marker's color example: - - '#FF0000' - - '#0000FF' + - "#FF0000" + - "#0000FF" additionalProperties: true slideshow: type: object x-patternProperties: - '^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}$': - type: object + ? "^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}$" + : type: object additionalProperties: false required: - position @@ -2304,11 +2304,11 @@ paths: instructions: type: - string - - 'null' + - "null" description: Instructions about what to do in this step example: - This is a **sleeper** - - 'Please, select the config file defined [in this link](asdf)' + - "Please, select the config file defined [in this link](asdf)" additionalProperties: true currentNodeId: type: string @@ -2316,8 +2316,8 @@ paths: annotations: type: object x-patternProperties: - '^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}$': - type: object + ? "^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}$" + : type: object additionalProperties: false required: - type @@ -2334,8 +2334,8 @@ paths: type: string description: Annotation's color example: - - '#FF0000' - - '#0000FF' + - "#FF0000" + - "#0000FF" attributes: type: object description: svg attributes @@ -2349,7 +2349,7 @@ paths: description: Contains the reference to the project classifiers items: type: string - example: 'some:id:to:a:classifier' + example: "some:id:to:a:classifier" dev: type: object description: object used for development purposes only @@ -2376,7 +2376,7 @@ paths: type: boolean owner: title: Owner - description: 'If locked, the user that owns the lock' + description: "If locked, the user that owns the lock" allOf: - title: Owner type: object @@ -2450,7 +2450,7 @@ paths: title: Quality description: Object containing Quality Assessment related data responses: - '202': + "202": description: project created content: application/json: @@ -2486,7 +2486,7 @@ paths: parameters: task_id: $response.body#/data/task_id default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /projects/active: get: tags: @@ -2494,7 +2494,7 @@ paths: summary: Gets active project operationId: get_active_project responses: - '200': + "200": description: returns active project content: application/json: @@ -2505,17 +2505,17 @@ paths: properties: data: allOf: - - $ref: '#/paths/~1projects/post/requestBody/content/application~1json/schema' + - $ref: "#/paths/~1projects/post/requestBody/content/application~1json/schema" - type: object properties: state: - $ref: '#/paths/~1projects~1%7Bproject_id%7D~1state/get/responses/200/content/application~1json/schema/properties/data' + $ref: "#/paths/~1projects~1%7Bproject_id%7D~1state/get/responses/200/content/application~1json/schema/properties/data" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}": parameters: - name: project_id in: path @@ -2528,14 +2528,14 @@ paths: summary: Gets given project operationId: get_project responses: - '200': + "200": description: got detailed project content: application/json: schema: - $ref: '#/paths/~1projects~1active/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1projects~1active/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" put: tags: - project @@ -2551,25 +2551,25 @@ paths: content: application/json: schema: - $ref: '#/paths/~1projects/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1projects/post/requestBody/content/application~1json/schema" responses: - '200': + "200": description: got detailed project content: application/json: schema: - $ref: '#/paths/~1projects~1active/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1projects~1active/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: tags: - project summary: Delete given project operationId: delete_project responses: - '204': + "204": description: project has been successfully deleted - '/projects/{project_id}:open': + "/projects/{project_id}:open": parameters: - name: project_id in: path @@ -2590,15 +2590,15 @@ paths: type: string example: 5ac57685-c40f-448f-8711-70be1936fd63 responses: - '200': + "200": description: project successfuly opened content: application/json: schema: - $ref: '#/paths/~1projects~1active/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1projects~1active/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/state': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/state": parameters: - name: project_id in: path @@ -2611,7 +2611,7 @@ paths: summary: returns the state of a project operationId: get_project_state responses: - '200': + "200": description: returns the project current state content: application/json: @@ -2648,8 +2648,8 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}:xport': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}:xport": parameters: - name: project_id in: path @@ -2662,7 +2662,7 @@ paths: summary: creates an archive of the project and downloads it operationId: export_project responses: - '200': + "200": description: creates an archive from a project file content: application/zip: @@ -2670,8 +2670,8 @@ paths: type: string format: binary default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}:duplicate': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}:duplicate": parameters: - name: project_id in: path @@ -2684,7 +2684,7 @@ paths: summary: duplicates an existing project operationId: duplicate_project responses: - '200': + "200": description: project was duplicated correctly content: application/json: @@ -2694,8 +2694,8 @@ paths: uuid: type: string default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects:import': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects:import": post: tags: - exporter @@ -2711,7 +2711,7 @@ paths: type: string format: binary responses: - '200': + "200": description: creates a new project from an archive content: application/json: @@ -2721,8 +2721,8 @@ paths: uuid: type: string default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}:close': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}:close": parameters: - name: project_id in: path @@ -2740,13 +2740,13 @@ paths: content: application/json: schema: - $ref: '#/paths/~1projects~1%7Bproject_id%7D%3Aopen/post/requestBody/content/application~1json/schema' + $ref: "#/paths/~1projects~1%7Bproject_id%7D%3Aopen/post/requestBody/content/application~1json/schema" responses: - '204': + "204": description: project succesfuly closed default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/nodes': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/nodes": parameters: - name: project_id in: path @@ -2783,7 +2783,7 @@ paths: service_key: simcore/services/dynamic/3d-viewer service_version: 1.4.0 responses: - '201': + "201": description: created content: application/json: @@ -2805,8 +2805,8 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/nodes/{node_id}': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/nodes/{node_id}": parameters: - name: project_id in: path @@ -2824,7 +2824,7 @@ paths: description: Gets node status operationId: get_node responses: - '200': + "200": description: OK service exists and runs. Returns node details. content: application/json: @@ -2886,7 +2886,7 @@ paths: description: different base path where current service is mounted otherwise defaults to root type: string example: /x/E1O2E-LAH - default: '' + default: "" service_state: description: | the service state * 'pending' - The service is waiting for resources to start * 'pulling' - The service is being pulled from the registry * 'starting' - The service is starting * 'running' - The service is running * 'complete' - The service completed * 'failed' - The service failed to start @@ -2905,23 +2905,23 @@ paths: user_id: description: the user that started the service type: string - example: '123' + example: "123" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: tags: - project description: Stops and removes a node from the project operationId: delete_node responses: - '204': + "204": description: node has been successfully deleted from project default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/nodes/{node_id}:retrieve': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/nodes/{node_id}:retrieve": parameters: - name: project_id in: path @@ -2951,7 +2951,7 @@ paths: items: type: string responses: - '200': + "200": description: Returns the amount of transferred bytes when pulling data via nodeports content: application/json: @@ -2966,8 +2966,8 @@ paths: type: integer description: amount of transferred bytes default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/nodes/{node_id}:start': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/nodes/{node_id}:start": parameters: - name: project_id in: path @@ -2985,11 +2985,11 @@ paths: description: Starts a project dynamic service operationId: start_node responses: - '204': + "204": description: started service (needs to be long running though) default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/nodes/{node_id}:stop': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/nodes/{node_id}:stop": parameters: - name: project_id in: path @@ -3007,11 +3007,11 @@ paths: description: Stops a project node operationId: stop_node responses: - '204': + "204": description: stopped service default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/nodes/{node_id}:restart': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/nodes/{node_id}:restart": parameters: - name: project_id in: path @@ -3029,11 +3029,11 @@ paths: description: Restarts containers started by the dynamic-sidecar operationId: restart_node responses: - '204': + "204": description: Restarts containers started by the dynamic-sidecar default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/nodes/{node_id}/resources': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/nodes/{node_id}/resources": parameters: - name: project_id in: path @@ -3051,7 +3051,7 @@ paths: description: Returns the node resources operationId: get_node_resources responses: - '200': + "200": description: Returns the node resources. content: application/json: @@ -3061,12 +3061,12 @@ paths: - data properties: data: - $ref: '#/paths/~1projects~1%7Bproject_id%7D~1nodes~1%7Bnode_id%7D~1resources/put/requestBody/content/application~1json/schema' + $ref: "#/paths/~1projects~1%7Bproject_id%7D~1nodes~1%7Bnode_id%7D~1resources/put/requestBody/content/application~1json/schema" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" put: tags: - project @@ -3095,15 +3095,15 @@ paths: - type: number - type: string responses: - '200': + "200": description: Returns the udpated node resources. content: application/json: schema: - $ref: '#/paths/~1projects~1%7Bproject_id%7D~1nodes~1%7Bnode_id%7D~1resources/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1projects~1%7Bproject_id%7D~1nodes~1%7Bnode_id%7D~1resources/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_id}/inputs': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_id}/inputs": get: tags: - project @@ -3119,12 +3119,12 @@ paths: name: project_id in: path responses: - '200': + "200": description: Successful Response content: application/json: schema: - title: 'Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectInputGet]]' + title: "Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectInputGet]]" type: object properties: data: @@ -3188,13 +3188,13 @@ paths: description: Value assigned to this i/o port required: true responses: - '200': + "200": description: Successful Response content: application/json: schema: - $ref: '#/paths/~1projects~1%7Bproject_id%7D~1inputs/get/responses/200/content/application~1json/schema' - '/projects/{project_id}/outputs': + $ref: "#/paths/~1projects~1%7Bproject_id%7D~1inputs/get/responses/200/content/application~1json/schema" + "/projects/{project_id}/outputs": get: tags: - project @@ -3210,12 +3210,12 @@ paths: name: project_id in: path responses: - '200': + "200": description: Successful Response content: application/json: schema: - title: 'Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectOutputGet]]' + title: "Envelope[dict[uuid.UUID, simcore_service_webserver.projects.projects_ports_handlers.ProjectOutputGet]]" type: object properties: data: @@ -3242,7 +3242,7 @@ paths: type: string error: title: Error - '/projects/{project_id}/metadata/ports': + "/projects/{project_id}/metadata/ports": get: tags: - project @@ -3258,12 +3258,12 @@ paths: name: project_id in: path responses: - '200': + "200": description: Successful Response content: application/json: schema: - title: 'Envelope[list[simcore_service_webserver.projects.projects_ports_handlers.ProjectMetadataPortGet]]' + title: "Envelope[list[simcore_service_webserver.projects.projects_ports_handlers.ProjectMetadataPortGet]]" type: object properties: data: @@ -3290,24 +3290,24 @@ paths: content_schema: title: Content Schema type: object - description: 'jsonschema for the port''s value. SEE https://json-schema.org/understanding-json-schema/' + description: "jsonschema for the port's value. SEE https://json-schema.org/understanding-json-schema/" error: title: Error - '/nodes/{nodeInstanceUUID}/outputUi/{outputKey}': + "/nodes/{nodeInstanceUUID}/outputUi/{outputKey}": get: tags: - node description: get a json description of the ui for presenting the output within the mainUi and a list of open api json schema objects describing the possible json payloads and responses for the api calls available at this endpoint operationId: get_node_output_ui parameters: - - $ref: '#/paths/~1nodes~1%7BnodeInstanceUUID%7D~1iframe/get/parameters/0' + - $ref: "#/paths/~1nodes~1%7BnodeInstanceUUID%7D~1iframe/get/parameters/0" - in: path name: outputKey required: true schema: type: string responses: - '200': + "200": description: Service Information content: application/json: @@ -3352,15 +3352,15 @@ paths: description: Error code type: integer example: 404 - '/nodes/{nodeInstanceUUID}/outputUi/{outputKey}/{apiCall}': + "/nodes/{nodeInstanceUUID}/outputUi/{outputKey}/{apiCall}": post: tags: - node summary: send data back to the output api ... protocol depends on the definition operationId: send_to_node_output_api parameters: - - $ref: '#/paths/~1nodes~1%7BnodeInstanceUUID%7D~1iframe/get/parameters/0' - - $ref: '#/paths/~1nodes~1%7BnodeInstanceUUID%7D~1outputUi~1%7BoutputKey%7D/get/parameters/1' + - $ref: "#/paths/~1nodes~1%7BnodeInstanceUUID%7D~1iframe/get/parameters/0" + - $ref: "#/paths/~1nodes~1%7BnodeInstanceUUID%7D~1outputUi~1%7BoutputKey%7D/get/parameters/1" - in: path name: apiCall required: true @@ -3424,7 +3424,7 @@ paths: label: type: string thumbnail: - description: 'data url - https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs' + description: "data url - https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs" type: string - type: object - type: array @@ -3438,7 +3438,7 @@ paths: folder: type: boolean - type: object - '/nodes/{nodeInstanceUUID}/iframe': + "/nodes/{nodeInstanceUUID}/iframe": get: tags: - node @@ -3453,7 +3453,7 @@ paths: responses: default: description: any response appropriate in the iframe context - '/projects/{study_uuid}/tags/{tag_id}': + "/projects/{study_uuid}/tags/{tag_id}": parameters: - name: tag_id in: path @@ -3471,29 +3471,29 @@ paths: summary: Links an existing label with an existing study operationId: add_tag responses: - '200': + "200": description: The tag has been successfully linked to the study content: application/json: schema: - $ref: '#/paths/~1projects~1active/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1projects~1active/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: tags: - project summary: Removes an existing link between a label and a study operationId: remove_tag responses: - '200': + "200": description: The tag has been successfully removed from the study content: application/json: schema: - $ref: '#/paths/~1projects~1active/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1projects~1active/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' - '/projects/{project_uuid}/checkpoint/{ref_id}/iterations': + $ref: "#/components/responses/DefaultErrorResponse" + "/projects/{project_uuid}/checkpoint/{ref_id}/iterations": get: tags: - meta-projects @@ -3541,12 +3541,12 @@ paths: name: limit in: query responses: - '200': + "200": description: Successful Response content: application/json: schema: - title: 'Page[IterationItem]' + title: "Page[IterationItem]" required: - _meta - _links @@ -3634,7 +3634,7 @@ paths: name: title: Name type: string - description: 'Iteration''s resource name [AIP-122](https://google.aip.dev/122)' + description: "Iteration's resource name [AIP-122](https://google.aip.dev/122)" parent: title: Parent allOf: @@ -3669,9 +3669,9 @@ paths: minLength: 1 type: string format: uri - '404': + "404": description: This project has no iterations.Only meta-project have iterations and they must be explicitly created. - '422': + "422": description: Validation Error content: application/json: @@ -3701,7 +3701,7 @@ paths: type: title: Error Type type: string - '/projects/{project_uuid}/checkpoint/{ref_id}/iterations/{iter_id}': + "/projects/{project_uuid}/checkpoint/{ref_id}/iterations/{iter_id}": get: tags: - meta-projects @@ -3732,9 +3732,9 @@ paths: type: integer in: path responses: - '200': + "200": description: Successful Response - '/projects/{project_uuid}/checkpoint/{ref_id}/iterations/-/results': + "/projects/{project_uuid}/checkpoint/{ref_id}/iterations/-/results": get: tags: - meta-projects @@ -3782,12 +3782,12 @@ paths: name: limit in: query responses: - '200': + "200": description: Successful Response content: application/json: schema: - title: 'Page[IterationResultItem]' + title: "Page[IterationResultItem]" required: - _meta - _links @@ -3795,23 +3795,23 @@ paths: type: object properties: _meta: - $ref: '#/paths/~1projects~1%7Bproject_uuid%7D~1checkpoint~1%7Bref_id%7D~1iterations/get/responses/200/content/application~1json/schema/properties/_meta' + $ref: "#/paths/~1projects~1%7Bproject_uuid%7D~1checkpoint~1%7Bref_id%7D~1iterations/get/responses/200/content/application~1json/schema/properties/_meta" _links: - $ref: '#/paths/~1projects~1%7Bproject_uuid%7D~1checkpoint~1%7Bref_id%7D~1iterations/get/responses/200/content/application~1json/schema/properties/_links' + $ref: "#/paths/~1projects~1%7Bproject_uuid%7D~1checkpoint~1%7Bref_id%7D~1iterations/get/responses/200/content/application~1json/schema/properties/_links" data: title: Data type: array items: - $ref: '#/paths/~1projects~1%7Bproject_uuid%7D~1checkpoint~1%7Bref_id%7D~1iterations/get/responses/200/content/application~1json/schema/properties/data/items' - '404': + $ref: "#/paths/~1projects~1%7Bproject_uuid%7D~1checkpoint~1%7Bref_id%7D~1iterations/get/responses/200/content/application~1json/schema/properties/data/items" + "404": description: This project has no iterations.Only meta-project have iterations and they must be explicitly created. - '422': + "422": description: Validation Error content: application/json: schema: - $ref: '#/paths/~1projects~1%7Bproject_uuid%7D~1checkpoint~1%7Bref_id%7D~1iterations/get/responses/422/content/application~1json/schema' - '/projects/{project_uuid}/checkpoint/{ref_id}/iterations/{iter_id}/results': + $ref: "#/paths/~1projects~1%7Bproject_uuid%7D~1checkpoint~1%7Bref_id%7D~1iterations/get/responses/422/content/application~1json/schema" + "/projects/{project_uuid}/checkpoint/{ref_id}/iterations/{iter_id}/results": get: tags: - meta-projects @@ -3842,7 +3842,7 @@ paths: name: iter_id in: path responses: - '200': + "200": description: Successful Response /repos/projects: get: @@ -3874,12 +3874,12 @@ paths: name: limit in: query responses: - '200': + "200": description: Successful Response content: application/json: schema: - title: 'Page[Repo]' + title: "Page[Repo]" required: - _meta - _links @@ -3971,7 +3971,7 @@ paths: minLength: 1 type: string format: uri - '422': + "422": description: Validation Error content: application/json: @@ -4001,7 +4001,7 @@ paths: type: title: Error Type type: string - '/repos/projects/{project_uuid}/checkpoints': + "/repos/projects/{project_uuid}/checkpoints": get: tags: - repository @@ -4040,12 +4040,12 @@ paths: name: limit in: query responses: - '200': + "200": description: Successful Response content: application/json: schema: - title: 'Page[Checkpoint]' + title: "Page[Checkpoint]" required: - _meta - _links @@ -4053,20 +4053,20 @@ paths: type: object properties: _meta: - $ref: '#/paths/~1repos~1projects/get/responses/200/content/application~1json/schema/properties/_meta' + $ref: "#/paths/~1repos~1projects/get/responses/200/content/application~1json/schema/properties/_meta" _links: - $ref: '#/paths/~1repos~1projects/get/responses/200/content/application~1json/schema/properties/_links' + $ref: "#/paths/~1repos~1projects/get/responses/200/content/application~1json/schema/properties/_links" data: title: Data type: array items: - $ref: '#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema/properties/data' - '422': + $ref: "#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema/properties/data" + "422": description: Validation Error content: application/json: schema: - $ref: '#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema' + $ref: "#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema" post: tags: - repository @@ -4099,12 +4099,12 @@ paths: type: string required: true responses: - '201': + "201": description: Successful Response content: application/json: schema: - title: 'Envelope[Checkpoint]' + title: "Envelope[Checkpoint]" type: object properties: data: @@ -4159,13 +4159,13 @@ paths: message: title: Message type: string - '422': + "422": description: Validation Error content: application/json: schema: - $ref: '#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema' - '/repos/projects/{project_uuid}/checkpoints/HEAD': + $ref: "#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema" + "/repos/projects/{project_uuid}/checkpoints/HEAD": get: tags: - repository @@ -4183,19 +4183,19 @@ paths: name: project_uuid in: path responses: - '200': + "200": description: Successful Response content: application/json: schema: - $ref: '#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema' - '422': + $ref: "#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema" + "422": description: Validation Error content: application/json: schema: - $ref: '#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema' - '/repos/projects/{project_uuid}/checkpoints/{ref_id}': + $ref: "#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema" + "/repos/projects/{project_uuid}/checkpoints/{ref_id}": get: tags: - repository @@ -4203,7 +4203,7 @@ paths: description: Set ref_id=HEAD to return current commit operationId: simcore_service_webserver.version_control_handlers._get_checkpoint_handler parameters: - - description: 'A repository ref (commit, tag or branch)' + - description: "A repository ref (commit, tag or branch)" required: true schema: title: Ref Id @@ -4211,7 +4211,7 @@ paths: - type: string format: uuid - type: string - description: 'A repository ref (commit, tag or branch)' + description: "A repository ref (commit, tag or branch)" name: ref_id in: path - description: Project unique identifier @@ -4224,25 +4224,25 @@ paths: name: project_uuid in: path responses: - '200': + "200": description: Successful Response content: application/json: schema: - $ref: '#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema' - '422': + $ref: "#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema" + "422": description: Validation Error content: application/json: schema: - $ref: '#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema' + $ref: "#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema" patch: tags: - repository summary: Update Checkpoint Annotations operationId: simcore_service_webserver.version_control_handlers._update_checkpoint_annotations_handler parameters: - - description: 'A repository ref (commit, tag or branch)' + - description: "A repository ref (commit, tag or branch)" required: true schema: title: Ref Id @@ -4250,7 +4250,7 @@ paths: - type: string format: uuid - type: string - description: 'A repository ref (commit, tag or branch)' + description: "A repository ref (commit, tag or branch)" name: ref_id in: path - description: Project unique identifier @@ -4277,19 +4277,19 @@ paths: type: string required: true responses: - '200': + "200": description: Successful Response content: application/json: schema: - $ref: '#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema' - '422': + $ref: "#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema" + "422": description: Validation Error content: application/json: schema: - $ref: '#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema' - '/repos/projects/{project_uuid}/checkpoints/{ref_id}:checkout': + $ref: "#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema" + "/repos/projects/{project_uuid}/checkpoints/{ref_id}:checkout": post: tags: - repository @@ -4299,7 +4299,7 @@ paths: the check out operationId: simcore_service_webserver.version_control_handlers._checkout_handler parameters: - - description: 'A repository ref (commit, tag or branch)' + - description: "A repository ref (commit, tag or branch)" required: true schema: title: Ref Id @@ -4307,7 +4307,7 @@ paths: - type: string format: uuid - type: string - description: 'A repository ref (commit, tag or branch)' + description: "A repository ref (commit, tag or branch)" name: ref_id in: path - description: Project unique identifier @@ -4320,19 +4320,19 @@ paths: name: project_uuid in: path responses: - '200': + "200": description: Successful Response content: application/json: schema: - $ref: '#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema' - '422': + $ref: "#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema" + "422": description: Validation Error content: application/json: schema: - $ref: '#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema' - '/repos/projects/{project_uuid}/checkpoints/{ref_id}/workbench/view': + $ref: "#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema" + "/repos/projects/{project_uuid}/checkpoints/{ref_id}/workbench/view": get: tags: - repository @@ -4340,7 +4340,7 @@ paths: description: Returns a view of the workbench for a given project's version operationId: simcore_service_webserver.version_control_handlers._view_project_workbench_handler parameters: - - description: 'A repository ref (commit, tag or branch)' + - description: "A repository ref (commit, tag or branch)" required: true schema: title: Ref Id @@ -4348,7 +4348,7 @@ paths: - type: string format: uuid - type: string - description: 'A repository ref (commit, tag or branch)' + description: "A repository ref (commit, tag or branch)" name: ref_id in: path - description: Project unique identifier @@ -4361,12 +4361,12 @@ paths: name: project_uuid in: path responses: - '200': + "200": description: Successful Response content: application/json: schema: - title: 'Envelope[WorkbenchView]' + title: "Envelope[WorkbenchView]" type: object properties: data: @@ -4409,21 +4409,21 @@ paths: default: {} description: A view (i.e. read-only and visual) of the project's workbench error: - $ref: '#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema/properties/error' - '422': + $ref: "#/paths/~1repos~1projects~1%7Bproject_uuid%7D~1checkpoints/post/responses/201/content/application~1json/schema/properties/error" + "422": description: Validation Error content: application/json: schema: - $ref: '#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema' + $ref: "#/paths/~1repos~1projects/get/responses/422/content/application~1json/schema" /activity/status: get: operationId: get_status tags: - activity responses: - '200': - description: 'Object containing queuing, CPU and Memory usage/limits information of services' + "200": + description: "Object containing queuing, CPU and Memory usage/limits information of services" content: application/json: schema: @@ -4456,7 +4456,7 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /tags: get: tags: @@ -4464,7 +4464,7 @@ paths: summary: List all tags for the current user operationId: list_tags responses: - '200': + "200": description: List of tags content: application/json: @@ -4481,19 +4481,19 @@ paths: tags: type: array items: - $ref: '#/paths/~1tags/post/responses/200/content/application~1json/schema/properties/data' + $ref: "#/paths/~1tags/post/responses/200/content/application~1json/schema/properties/data" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" post: tags: - tag summary: Creates a new tag operationId: create_tag responses: - '200': + "200": description: The created tag content: application/json: @@ -4517,7 +4517,7 @@ paths: type: string color: type: string - pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$' + pattern: "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" accessRights: type: object properties: @@ -4531,8 +4531,8 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/tags/{tag_id}': + $ref: "#/components/responses/DefaultErrorResponse" + "/tags/{tag_id}": parameters: - name: tag_id in: path @@ -4545,21 +4545,21 @@ paths: summary: Updates a tag operationId: update_tag responses: - '200': + "200": description: The updated tag content: application/json: schema: - $ref: '#/paths/~1tags/post/responses/200/content/application~1json/schema' + $ref: "#/paths/~1tags/post/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: tags: - tag summary: Deletes an existing tag operationId: delete_tag responses: - '204': + "204": description: The tag has been successfully deleted /publications/service-submission: post: @@ -4582,22 +4582,22 @@ paths: type: string format: binary responses: - '204': + "204": description: Submission has been registered default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /catalog/dags: get: tags: - catalog operationId: list_catalog_dags responses: - '200': + "200": description: List of catalog dags - '422': + "422": description: Validation Error default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" post: tags: - catalog @@ -4610,13 +4610,13 @@ paths: type: object additionalProperties: true responses: - '201': + "201": description: The dag was successfully created - '422': + "422": description: Validation Error default: - $ref: '#/components/responses/DefaultErrorResponse' - '/catalog/dags/{dag_id}': + $ref: "#/components/responses/DefaultErrorResponse" + "/catalog/dags/{dag_id}": parameters: - in: path name: dag_id @@ -4636,21 +4636,21 @@ paths: type: object additionalProperties: true responses: - '200': + "200": description: The dag was replaced in catalog - '422': + "422": description: Validation Error default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: tags: - catalog summary: Deletes an existing dag operationId: delete_catalog_dag responses: - '204': + "204": description: Successfully deleted - '422': + "422": description: Validation Error /catalog/services: get: @@ -4659,11 +4659,11 @@ paths: summary: List Services operationId: list_services_handler responses: - '200': + "200": description: Returns list of services from the catalog default: - $ref: '#/components/responses/DefaultErrorResponse' - '/catalog/services/{service_key}/{service_version}': + $ref: "#/components/responses/DefaultErrorResponse" + "/catalog/services/{service_key}/{service_version}": parameters: - in: path name: service_key @@ -4685,10 +4685,10 @@ paths: summary: Get Service operationId: get_service_handler responses: - '200': + "200": description: Returns service default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" patch: tags: - catalog @@ -4701,11 +4701,11 @@ paths: type: object additionalProperties: true responses: - '200': + "200": description: Returns modified service default: - $ref: '#/components/responses/DefaultErrorResponse' - '/catalog/services/{service_key}/{service_version}/inputs': + $ref: "#/components/responses/DefaultErrorResponse" + "/catalog/services/{service_key}/{service_version}/inputs": get: tags: - catalog @@ -4726,7 +4726,7 @@ paths: title: Service Version type: string responses: - '200': + "200": content: application/json: schema: @@ -4769,7 +4769,7 @@ paths: type: object keyId: description: Unique name identifier for this input - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" title: Keyid type: string label: @@ -4783,15 +4783,15 @@ paths: title: Type type: string unit: - description: 'Units, when it refers to a physical quantity' + description: "Units, when it refers to a physical quantity" title: Unit type: string unitLong: - description: 'Long name of the unit, if available' + description: "Long name of the unit, if available" title: Unitlong type: string unitShort: - description: 'Short name for the unit, if available' + description: "Short name for the unit, if available" title: Unitshort type: string widget: @@ -4864,7 +4864,7 @@ paths: title: ServiceInputApiOut type: object description: Successful Response - '422': + "422": content: application/json: schema: @@ -4895,7 +4895,7 @@ paths: type: object description: Validation Error summary: List Service Inputs - '/catalog/services/{service_key}/{service_version}/inputs/{input_key}': + "/catalog/services/{service_key}/{service_version}/inputs/{input_key}": get: tags: - catalog @@ -4919,28 +4919,28 @@ paths: name: input_key required: true schema: - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" title: Input Key type: string responses: - '200': + "200": content: application/json: schema: - $ref: '#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/200/content/application~1json/schema" description: Successful Response - '422': + "422": content: application/json: schema: - $ref: '#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema' + $ref: "#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema" description: Validation Error summary: Get Service Input - '/catalog/services/{service_key}/{service_version}/inputs:match': + "/catalog/services/{service_key}/{service_version}/inputs:match": get: tags: - catalog - description: 'Filters inputs of this service that match a given service output. Returns compatible input ports of the service, provided an output port of a connected node.' + description: "Filters inputs of this service that match a given service output. Returns compatible input ports of the service, provided an output port of a connected node." operationId: get_compatible_inputs_given_source_output_handler parameters: - in: path @@ -4975,28 +4975,28 @@ paths: name: fromOutput required: true schema: - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" title: Fromoutput type: string responses: - '200': + "200": content: application/json: schema: items: - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" type: string title: Response Get Compatible Inputs Given Source Output Catalog Services Service Key Service Version Inputs Match Get type: array description: Successful Response - '422': + "422": content: application/json: schema: - $ref: '#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema' + $ref: "#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema" description: Validation Error summary: Get Compatible Inputs Given Source Output - '/catalog/services/{service_key}/{service_version}/outputs': + "/catalog/services/{service_key}/{service_version}/outputs": get: tags: - catalog @@ -5017,23 +5017,23 @@ paths: title: Service Version type: string responses: - '200': + "200": content: application/json: schema: items: - $ref: '#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1outputs~1%7Boutput_key%7D/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1outputs~1%7Boutput_key%7D/get/responses/200/content/application~1json/schema" title: Response List Service Outputs Catalog Services Service Key Service Version Outputs Get type: array description: Successful Response - '422': + "422": content: application/json: schema: - $ref: '#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema' + $ref: "#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema" description: Validation Error summary: List Service Outputs - '/catalog/services/{service_key}/{service_version}/outputs/{output_key}': + "/catalog/services/{service_key}/{service_version}/outputs/{output_key}": get: tags: - catalog @@ -5057,11 +5057,11 @@ paths: name: output_key required: true schema: - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" title: Output Key type: string responses: - '200': + "200": content: application/json: schema: @@ -5104,7 +5104,7 @@ paths: type: object keyId: description: Unique name identifier for this input - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" title: Keyid type: string label: @@ -5118,20 +5118,20 @@ paths: title: Type type: string unit: - description: 'Units, when it refers to a physical quantity' + description: "Units, when it refers to a physical quantity" title: Unit type: string unitLong: - description: 'Long name of the unit, if available' + description: "Long name of the unit, if available" title: Unitlong type: string unitShort: - description: 'Short name for the unit, if available' + description: "Short name for the unit, if available" title: Unitshort type: string widget: allOf: - - $ref: '#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/200/content/application~1json/schema/properties/widget/allOf/0' + - $ref: "#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/200/content/application~1json/schema/properties/widget/allOf/0" deprecated: true description: custom widget to use instead of the default one determined from the data-type title: Widget @@ -5144,14 +5144,14 @@ paths: title: ServiceOutputApiOut type: object description: Successful Response - '422': + "422": content: application/json: schema: - $ref: '#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema' + $ref: "#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema" description: Validation Error summary: Get Service Output - '/catalog/services/{service_key}/{service_version}/outputs:match': + "/catalog/services/{service_key}/{service_version}/outputs:match": get: tags: - catalog @@ -5190,28 +5190,28 @@ paths: name: toInput required: true schema: - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" title: Toinput type: string responses: - '200': + "200": content: application/json: schema: items: - pattern: '^[-_a-zA-Z0-9]+$' + pattern: "^[-_a-zA-Z0-9]+$" type: string title: Response Get Compatible Outputs Given Target Input Catalog Services Service Key Service Version Outputs Match Get type: array description: Successful Response - '422': + "422": content: application/json: schema: - $ref: '#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema' + $ref: "#/paths/~1catalog~1services~1%7Bservice_key%7D~1%7Bservice_version%7D~1inputs/get/responses/422/content/application~1json/schema" description: Validation Error summary: Get Compatible Outputs Given Target Input - '/catalog/services/{service_key}/{service_version}/resources': + "/catalog/services/{service_key}/{service_version}/resources": get: tags: - catalog @@ -5233,15 +5233,15 @@ paths: title: Service Version type: string responses: - '200': + "200": description: Successful Response content: application/json: schema: - $ref: '#/paths/~1projects~1%7Bproject_id%7D~1nodes~1%7Bnode_id%7D~1resources/get/responses/200/content/application~1json/schema' + $ref: "#/paths/~1projects~1%7Bproject_id%7D~1nodes~1%7Bnode_id%7D~1resources/get/responses/200/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' - '/clusters:ping': + $ref: "#/components/responses/DefaultErrorResponse" + "/clusters:ping": post: summary: test connectivity with cluster operationId: ping_cluster_handler @@ -5262,18 +5262,18 @@ paths: authentication: description: Dask gateway authentication anyOf: - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/0' - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/1' - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/2' + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/0" + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/1" + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/2" required: - endpoint - authentication additionalProperties: false responses: - '204': + "204": description: connectivity is OK default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /clusters: get: summary: List my clusters @@ -5281,7 +5281,7 @@ paths: tags: - cluster responses: - '200': + "200": description: list of the clusters I have access to content: application/json: @@ -5293,12 +5293,12 @@ paths: data: type: array items: - $ref: '#/paths/~1clusters/post/responses/201/content/application~1json/schema/properties/data' + $ref: "#/paths/~1clusters/post/responses/201/content/application~1json/schema/properties/data" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" post: summary: Create a new cluster operationId: create_cluster_handler @@ -5376,7 +5376,7 @@ paths: - authentication additionalProperties: false responses: - '201': + "201": description: cluster created content: application/json: @@ -5418,15 +5418,15 @@ paths: authentication: description: Dask gateway authentication anyOf: - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/0' - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/1' - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/2' + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/0" + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/1" + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/2" accessRights: type: object description: object containing the GroupID as key and read/write/execution permissions as value x-patternProperties: ^\S+$: - $ref: '#/paths/~1clusters~1%7Bcluster_id%7D/patch/requestBody/content/application~1json/schema/properties/accessRights/x-patternProperties/%5E%5CS%2B%24' + $ref: "#/paths/~1clusters~1%7Bcluster_id%7D/patch/requestBody/content/application~1json/schema/properties/accessRights/x-patternProperties/%5E%5CS%2B%24" required: - id - name @@ -5438,14 +5438,14 @@ paths: - id: 1 name: AWS cluster type: AWS - endpoint: 'https://registry.osparc-development.fake.dev' + endpoint: "https://registry.osparc-development.fake.dev" authentication: type: simple username: someuser password: somepassword owner: 2 accessRights: - '2': + "2": read: true write: true delete: true @@ -5453,8 +5453,8 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' - '/clusters/{cluster_id}': + $ref: "#/components/responses/DefaultErrorResponse" + "/clusters/{cluster_id}": parameters: - name: cluster_id in: path @@ -5467,14 +5467,14 @@ paths: summary: Gets one cluster operationId: get_cluster_handler responses: - '200': + "200": description: got cluster content: application/json: schema: - $ref: '#/paths/~1clusters/post/responses/201/content/application~1json/schema' + $ref: "#/paths/~1clusters/post/responses/201/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" patch: summary: Update one cluster operationId: update_cluster_handler @@ -5513,9 +5513,9 @@ paths: authentication: description: Dask gateway authentication anyOf: - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/0' - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/1' - - $ref: '#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/2' + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/0" + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/1" + - $ref: "#/paths/~1clusters/post/requestBody/content/application~1json/schema/properties/authentication/anyOf/2" accessRights: type: object description: object containing the GroupID as key and read/write/execution permissions as value @@ -5539,25 +5539,25 @@ paths: - delete additionalProperties: false responses: - '200': + "200": description: the modified cluster content: application/json: schema: - $ref: '#/paths/~1clusters/post/responses/201/content/application~1json/schema' + $ref: "#/paths/~1clusters/post/responses/201/content/application~1json/schema" default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: tags: - cluster summary: Deletes one cluster operationId: delete_cluster_handler responses: - '204': + "204": description: cluster has been successfully deleted default: - $ref: '#/components/responses/DefaultErrorResponse' - '/clusters/{cluster_id}:ping': + $ref: "#/components/responses/DefaultErrorResponse" + "/clusters/{cluster_id}:ping": parameters: - name: cluster_id in: path @@ -5570,11 +5570,11 @@ paths: tags: - cluster responses: - '204': + "204": description: connectivity is OK default: - $ref: '#/components/responses/DefaultErrorResponse' - '/clusters/{cluster_id}/details': + $ref: "#/components/responses/DefaultErrorResponse" + "/clusters/{cluster_id}/details": parameters: - name: cluster_id in: path @@ -5587,7 +5587,7 @@ paths: summary: Gets one cluster details operationId: get_cluster_details_handler responses: - '200': + "200": description: got cluster content: application/json: @@ -5618,22 +5618,22 @@ paths: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /tasks: get: operationId: list_tasks tags: - tasks responses: - '200': + "200": description: Returns the list of active tasks (running and/or done) content: application/json: schema: type: array items: - $ref: '#/paths/~1projects/post/responses/202/content/application~1json/schema' - '/tasks/{task_id}': + $ref: "#/paths/~1projects/post/responses/202/content/application~1json/schema" + "/tasks/{task_id}": parameters: - name: task_id in: path @@ -5645,7 +5645,7 @@ paths: tags: - tasks responses: - '200': + "200": description: Returns the task status content: application/json: @@ -5670,23 +5670,23 @@ paths: started: type: string pattern: '\d{4}-(12|11|10|0?[1-9])-(31|30|[0-2]?\d)T(2[0-3]|1\d|0?[0-9])(:(\d|[0-5]\d)){2}(\.\d{3})?Z' - example: '2018-07-01T11:13:43Z' + example: "2018-07-01T11:13:43Z" error: nullable: true default: null default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" delete: operationId: cancel_and_delete_task description: Aborts and remove the task tags: - tasks responses: - '204': + "204": description: Task was successfully aborted default: - $ref: '#/components/responses/DefaultErrorResponse' - '/tasks/{task_id}/result': + $ref: "#/components/responses/DefaultErrorResponse" + "/tasks/{task_id}/result": parameters: - name: task_id in: path @@ -5701,7 +5701,7 @@ paths: 2XX: description: Retrieve the task result and returns directly its HTTP code default: - $ref: '#/components/responses/DefaultErrorResponse' + $ref: "#/components/responses/DefaultErrorResponse" /viewers: get: operationId: list_viewers @@ -5714,14 +5714,14 @@ paths: description: Filters list to viewers supporting that filetype type: string responses: - '200': + "200": content: application/json: schema: properties: data: items: - $ref: '#/paths/~1viewers~1default/get/responses/200/content/application~1json/schema/properties/data' + $ref: "#/paths/~1viewers~1default/get/responses/200/content/application~1json/schema/properties/data" type: array required: - data @@ -5744,7 +5744,7 @@ paths: description: Filters list to viewers supporting that filetype type: string responses: - '200': + "200": content: application/json: schema: @@ -5756,7 +5756,7 @@ paths: type: string description: Identifier for the file type view_url: - description: 'Base url to execute viewer. Needs appending file_size,[file_name] and download_link as query parameters' + description: "Base url to execute viewer. Needs appending file_size,[file_name] and download_link as query parameters" format: uri maxLength: 2083 minLength: 1 @@ -5803,7 +5803,7 @@ components: description: log messages type: array items: - $ref: '#/paths/~1auth~1login/post/responses/200/content/application~1json/schema/properties/data' + $ref: "#/paths/~1auth~1login/post/responses/200/content/application~1json/schema/properties/data" errors: description: errors metadata type: array diff --git a/services/web/server/src/simcore_service_webserver/email.py b/services/web/server/src/simcore_service_webserver/email.py index 50bc6bde6f7..e636fac8e7d 100644 --- a/services/web/server/src/simcore_service_webserver/email.py +++ b/services/web/server/src/simcore_service_webserver/email.py @@ -1,30 +1,15 @@ """ Subsystem that renders and sends emails """ -# TODO: move login/utils.py email functionality here! -# from email.mime.text import MIMEText -# import aiosmtplib -# import jinja2 TODO: check - - -from __future__ import annotations - import logging -from typing import TYPE_CHECKING import aiohttp_jinja2 import jinja_app_loader from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from ._constants import APP_SETTINGS_KEY from ._resources import resources -if TYPE_CHECKING: - # SEE https://stackoverflow.com/questions/39740632/python-type-hinting-without-cyclic-imports - # SEE https://peps.python.org/pep-0563/ - from .application_settings import ApplicationSettings - log = logging.getLogger(__name__) @@ -32,21 +17,13 @@ __name__, ModuleCategory.ADDON, settings_name="WEBSERVER_EMAIL", logger=log ) def setup_email(app: web.Application): - settings: ApplicationSettings | None = app.get(APP_SETTINGS_KEY) templates_dir = resources.get_path("templates") if not templates_dir.exists(): - log.error("Cannot find email templates in '%s'", templates_dir) - return False + raise FileNotFoundError( + f"Cannot find email templates directory '{templates_dir}'" + ) # SEE https://github.com/aio-libs/aiohttp-jinja2 - env = aiohttp_jinja2.setup( - app, - loader=jinja_app_loader.Loader(), # jinja2.FileSystemLoader(templates_dir) - auto_reload=settings - and settings.SC_BOOT_MODE - and settings.SC_BOOT_MODE.is_devel_mode(), - ) + env = aiohttp_jinja2.setup(app, loader=jinja_app_loader.Loader()) assert env # nosec - - return env is not None diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa.py b/services/web/server/src/simcore_service_webserver/login/_2fa.py index e8a38b7ebb5..22e68d3704d 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa.py +++ b/services/web/server/src/simcore_service_webserver/login/_2fa.py @@ -19,6 +19,7 @@ from twilio.rest import Client from ..redis import get_redis_validation_code_client +from .utils_email import get_template_path, render_and_send_mail log = logging.getLogger(__name__) @@ -39,7 +40,7 @@ def _generage_2fa_code() -> str: @log_decorator(log, level=logging.DEBUG) -async def set_2fa_code( +async def create_2fa_code( app: web.Application, user_email: str, *, @@ -110,6 +111,34 @@ def _sender(): await asyncio.get_event_loop().run_in_executor(None, _sender) +# +# EMAIL +# + + +@log_decorator(log, level=logging.DEBUG) +async def send_email_code( + request: web.Request, + user_email: str, + support_email: str, + code: str, + user_name: str = "user", +): + email_template_path = await get_template_path(request, "new_2fa_code.jinja2") + await render_and_send_mail( + request, + from_=support_email, + to=user_email, + template=email_template_path, + context={ + "host": request.host, + "code": code, + "name": user_name.capitalize(), + "support_email": support_email, + }, + ) + + # # HELPERS # diff --git a/services/web/server/src/simcore_service_webserver/login/_constants.py b/services/web/server/src/simcore_service_webserver/login/_constants.py new file mode 100644 index 00000000000..3b15ae848d1 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_constants.py @@ -0,0 +1,34 @@ +from typing import Final + +MSG_2FA_CODE_SENT: Final[str] = "Code sent by SMS to {phone_number}" +MSG_ACTIVATED: Final[str] = "Your account is activated" +MSG_ACTIVATION_REQUIRED: Final[ + str +] = "You have to activate your account via email, before you can login" +MSG_AUTH_FAILED: Final[str] = "Authorization failed" +MSG_CANT_SEND_MAIL: Final[str] = "Can't send email, try a little later" +MSG_CHANGE_EMAIL_REQUESTED: Final[ + str +] = "Please, click on the verification link we sent to your new email address" +MSG_EMAIL_CHANGED: Final[str] = "Your email is changed" +MSG_EMAIL_EXISTS: Final[str] = "This email is already registered" +MSG_EMAIL_SENT: Final[ + str +] = "An email has been sent to {email} with further instructions" +MSG_LOGGED_IN: Final[str] = "You are logged in" +MSG_LOGGED_OUT: Final[str] = "You are logged out" +MSG_OFTEN_RESET_PASSWORD: Final[str] = ( + "You can not request of restoring your password so often. Please, use" + " the link we sent you recently" +) +MSG_PASSWORD_CHANGED: Final[str] = "Your password is changed" +MSG_PASSWORD_MISMATCH: Final[str] = "Password and confirmation do not match" +MSG_UNKNOWN_EMAIL: Final[str] = "This email is not registered" +MSG_USER_BANNED: Final[ + str +] = "This user does not have anymore access. Please contact support for further details: {support_email}" +MSG_USER_EXPIRED: Final[ + str +] = "This account has expired and does not have anymore access. Please contact support for further details: {support_email}" +MSG_WRONG_2FA_CODE: Final[str] = "Invalid code (wrong or expired)" +MSG_WRONG_PASSWORD: Final[str] = "Wrong password" diff --git a/services/web/server/src/simcore_service_webserver/login/_models.py b/services/web/server/src/simcore_service_webserver/login/_models.py new file mode 100644 index 00000000000..2ac7b94f11a --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_models.py @@ -0,0 +1,32 @@ +from typing import Any, Callable + +from pydantic import BaseModel, Extra, SecretStr + +from ._constants import MSG_PASSWORD_MISMATCH + + +class InputSchema(BaseModel): + class Config: + allow_population_by_field_name = False + extra = Extra.forbid + allow_mutations = False + + +def create_password_match_validator( + reference_field: str, +) -> Callable[[SecretStr, dict[str, Any]], SecretStr]: + def _check(v: SecretStr, values: dict[str, Any]): + if ( + v is not None + and reference_field in values + and v.get_secret_value() != values[reference_field].get_secret_value() + ): + raise ValueError(MSG_PASSWORD_MISMATCH) + return v + + return _check + + +check_confirm_password_match = create_password_match_validator( + reference_field="password" +) diff --git a/services/web/server/src/simcore_service_webserver/login/_registration.py b/services/web/server/src/simcore_service_webserver/login/_registration.py index 0b2c13017fc..3f7d540df5f 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration.py @@ -17,22 +17,22 @@ Json, PositiveInt, ValidationError, - parse_obj_as, parse_raw_as, validator, ) from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from yarl import URL -from ..db_models import UserStatus from ._confirmation import ( ConfirmationAction, get_expiration_date, is_confirmation_expired, validate_confirmation_code, ) +from ._constants import MSG_EMAIL_EXISTS from .settings import LoginOptions from .storage import AsyncpgStorage, ConfirmationTokenDict +from .utils import CONFIRMATION_PENDING log = logging.getLogger(__name__) @@ -74,62 +74,49 @@ def ensure_enum(cls, v): } -async def check_registration( +async def check_other_registrations( email: str, - password: str, - confirm: Optional[str], db: AsyncpgStorage, cfg: LoginOptions, ) -> None: - # email : required & formats - # password: required & secure[min length, ...] - - if email is None or password is None: - raise web.HTTPBadRequest( - reason="Both email and password are required", - content_type=MIMETYPE_APPLICATION_JSON, - ) - - if confirm and password != confirm: - raise web.HTTPConflict( - reason=cfg.MSG_PASSWORD_MISMATCH, content_type=MIMETYPE_APPLICATION_JSON - ) - - try: - parse_obj_as(EmailStr, email) - except ValidationError as err: - raise web.HTTPUnprocessableEntity( - reason="Invalid email", content_type=MIMETYPE_APPLICATION_JSON - ) from err - - # NOTE: Extra requirements on passwords - # SEE https://github.com/ITISFoundation/osparc-simcore/issues/2480 if user := await db.get_user({"email": email}): - # Resets pending confirmation if re-registers? - if user["status"] == UserStatus.CONFIRMATION_PENDING.value: - _confirmation: ConfirmationTokenDict = await db.get_confirmation( - {"user": user, "action": ConfirmationAction.REGISTRATION.value} + # An account already registered with this email + # + # RULE 'drop_previous_registration': any unconfirmed account w/o confirmation or + # w/ an expired confirmation will get deleted and its account (i.e. email) + # can be overtaken by this new registration + # + if user["status"] == CONFIRMATION_PENDING: + _confirmation = await db.get_confirmation( + filter_dict={ + "user": user, + "action": ConfirmationAction.REGISTRATION.value, + } ) + drop_previous_registration = not _confirmation or is_confirmation_expired( + cfg, _confirmation + ) + if drop_previous_registration: + if not _confirmation: + await db.delete_user(user=user) + else: + await db.delete_confirmation_and_user( + user=user, confirmation=_confirmation + ) - if is_confirmation_expired(cfg, _confirmation): - await db.delete_confirmation(_confirmation) - await db.delete_user(user) log.warning( - "Time to confirm a registration is overdue. Used expired token [%s]." - "Deleted token from confirmations table and %s from users table.", - _confirmation, + "Re-registration of %s with expired %s" + "Deleting user and proceeding to a new registration", f"{user=}", + f"{_confirmation=}", ) return - # If the email is already taken, return a 409 - HTTPConflict raise web.HTTPConflict( - reason=cfg.MSG_EMAIL_EXISTS, content_type=MIMETYPE_APPLICATION_JSON + reason=MSG_EMAIL_EXISTS, content_type=MIMETYPE_APPLICATION_JSON ) - log.debug("Registration data validated") - async def create_invitation_token( db: AsyncpgStorage, diff --git a/services/web/server/src/simcore_service_webserver/login/_security.py b/services/web/server/src/simcore_service_webserver/login/_security.py new file mode 100644 index 00000000000..7d1b6f1a40c --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_security.py @@ -0,0 +1,41 @@ +""" Utils that extends on security_api plugin + +""" +import logging +from typing import Any + +from aiohttp import web +from servicelib.logging_utils import log_context + +from ..security_api import remember +from ._constants import MSG_LOGGED_IN +from .utils import flash_response + +log = logging.getLogger(__name__) + + +async def login_granted_response( + request: web.Request, *, user: dict[str, Any] +) -> web.Response: + """ + Grants authorization for user creating a responses with an auth cookie + + NOTE: All handlers with @login_required needs this cookie! + + Uses security API + """ + email = user["email"] + with log_context( + log, + logging.INFO, + "login of user_id=%s with %s", + f"{user.get('id')}", + f"{email=}", + ): + response = flash_response(MSG_LOGGED_IN, "INFO") + await remember( + request=request, + response=response, + identity=email, + ) + return response diff --git a/services/web/server/src/simcore_service_webserver/login/_sql.py b/services/web/server/src/simcore_service_webserver/login/_sql.py index fe5b23e7570..20a5779acc8 100644 --- a/services/web/server/src/simcore_service_webserver/login/_sql.py +++ b/services/web/server/src/simcore_service_webserver/login/_sql.py @@ -1,7 +1,5 @@ from logging import getLogger -# TODO: Possible SQL injection vector through string-based query construction. - log = getLogger(__name__) LOG_TPL = "%s <--%s" diff --git a/services/web/server/src/simcore_service_webserver/login/api_keys_handlers.py b/services/web/server/src/simcore_service_webserver/login/api_keys_handlers.py index 86f8d1b26eb..4394f803b79 100644 --- a/services/web/server/src/simcore_service_webserver/login/api_keys_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/api_keys_handlers.py @@ -7,6 +7,7 @@ import simcore_postgres_database.webserver_models as orm import sqlalchemy as sa from aiohttp import web +from aiohttp.web import RouteTableDef from aiopg.sa.result import ResultProxy from models_library.basic_types import IdInt from pydantic import BaseModel, Field @@ -137,7 +138,11 @@ async def delete_api_key(self, name: str): # +routes = RouteTableDef() + + @login_required +@routes.get("/v0/auth/api-keys", name="list_api_keys") async def list_api_keys(request: web.Request): """ GET /auth/api-keys @@ -150,6 +155,7 @@ async def list_api_keys(request: web.Request): @login_required +@routes.post("/v0/auth/api-keys", name="create_api_key") async def create_api_key(request: web.Request): """ POST /auth/api-keys @@ -175,6 +181,7 @@ async def create_api_key(request: web.Request): @login_required +@routes.delete("/v0/auth/api-keys", name="delete_api_key") async def delete_api_key(request: web.Request): """ DELETE /auth/api-keys diff --git a/services/web/server/src/simcore_service_webserver/login/handlers.py b/services/web/server/src/simcore_service_webserver/login/handlers.py index e9800f7af2b..4f1e284eb2f 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers.py @@ -1,29 +1,35 @@ import logging -from typing import Any +from typing import Final, Optional from aiohttp import web -from servicelib.aiohttp.rest_utils import extract_and_validate +from aiohttp.web import RouteTableDef +from pydantic import EmailStr, Field, SecretStr +from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.error_codes import create_error_code from servicelib.logging_utils import log_context from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.models.users import UserRole from ..products import Product, get_current_product -from ..security_api import check_password, forget, remember +from ..security_api import check_password, forget from ._2fa import ( + create_2fa_code, delete_2fa_code, get_2fa_code, mask_phone_number, send_sms_code, - set_2fa_code, ) -from .decorators import RQT_USERID_KEY, login_required -from .settings import ( - LoginOptions, - LoginSettings, - get_plugin_options, - get_plugin_settings, +from ._constants import ( + MSG_2FA_CODE_SENT, + MSG_LOGGED_OUT, + MSG_UNKNOWN_EMAIL, + MSG_WRONG_2FA_CODE, + MSG_WRONG_PASSWORD, ) +from ._models import InputSchema +from ._security import login_granted_response +from .decorators import RQT_USERID_KEY, login_required +from .settings import LoginSettings, get_plugin_settings from .storage import AsyncpgStorage, get_plugin_storage from .utils import ( ACTIVE, @@ -36,155 +42,167 @@ log = logging.getLogger(__name__) -# These string is used by the frontend to determine what page to display to the user for next step -LOGIN_CODE_PHONE_NUMBER_REQUIRED = "PHONE_NUMBER_REQUIRED" -LOGIN_CODE_SMS_CODE_REQUIRED = "SMS_CODE_REQUIRED" +routes = RouteTableDef() +# Login Accepted Response Codes: +# - These string codes are used to identify next step in the login (e.g. login_2fa or register_phone?) +# - The frontend uses them alwo to determine what page/form has to display to the user for next step +_PHONE_NUMBER_REQUIRED = "PHONE_NUMBER_REQUIRED" +_SMS_CODE_REQUIRED = "SMS_CODE_REQUIRED" -async def _authorize_login( - request: web.Request, user: dict[str, Any], cfg: LoginOptions -): - email = user["email"] - with log_context( - log, - logging.INFO, - "login of user_id=%s with %s", - f"{user.get('id')}", - f"{email=}", - ): - rsp = flash_response(cfg.MSG_LOGGED_IN, "INFO") - await remember( - request=request, - response=rsp, - identity=email, - ) - return rsp +class LoginBody(InputSchema): + email: EmailStr + password: SecretStr -async def login(request: web.Request): - _, _, body = await extract_and_validate(request) +@routes.post("/v0/auth/login", name="auth_login") +async def login(request: web.Request): settings: LoginSettings = get_plugin_settings(request.app) db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) product: Product = get_current_product(request) - email = body.email - password = body.password - - user = await db.get_user({"email": email}) + login_ = await parse_request_body_as(LoginBody, request) + user = await db.get_user({"email": login_.email}) if not user: raise web.HTTPUnauthorized( - reason=cfg.MSG_UNKNOWN_EMAIL, content_type=MIMETYPE_APPLICATION_JSON + reason=MSG_UNKNOWN_EMAIL, content_type=MIMETYPE_APPLICATION_JSON ) - validate_user_status(user, cfg, product.support_email) + validate_user_status(user=user, support_email=product.support_email) - if not check_password(password, user["password_hash"]): + if not check_password(login_.password.get_secret_value(), user["password_hash"]): raise web.HTTPUnauthorized( - reason=cfg.MSG_WRONG_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON + reason=MSG_WRONG_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON ) assert user["status"] == ACTIVE, "db corrupted. Invalid status" # nosec - assert user["email"] == email, "db corrupted. Invalid email" # nosec - - if settings.LOGIN_2FA_REQUIRED and UserRole(user["role"]) <= UserRole.USER: - if not user["phone"]: - rsp = envelope_response( - { - "code": LOGIN_CODE_PHONE_NUMBER_REQUIRED, - "reason": "To login, please register first a phone number", - }, - status=web.HTTPAccepted.status_code, - ) - return rsp - - assert user["phone"] # nosec - assert settings.LOGIN_2FA_REQUIRED and settings.LOGIN_TWILIO # nosec - assert settings.LOGIN_2FA_REQUIRED and product.twilio_messaging_sid # nosec - - try: - code = await set_2fa_code(request.app, user["email"]) - await send_sms_code( - phone_number=user["phone"], - code=code, - twilo_auth=settings.LOGIN_TWILIO, - twilio_messaging_sid=product.twilio_messaging_sid, - twilio_alpha_numeric_sender=product.twilio_alpha_numeric_sender_id, - user_name=user["name"], - ) - - rsp = envelope_response( - { - "code": LOGIN_CODE_SMS_CODE_REQUIRED, - "reason": cfg.MSG_2FA_CODE_SENT.format( - phone_number=mask_phone_number(user["phone"]) - ), - "next_url": f"{request.app.router['auth_validate_2fa_login'].url_for()}", - }, - status=web.HTTPAccepted.status_code, - ) - return rsp - - except Exception as e: - error_code = create_error_code(e) - log.exception( - "2FA login unexpectedly failed [%s]", - f"{error_code}", - extra={"error_code": error_code}, - ) - raise web.HTTPServiceUnavailable( - reason=f"Currently we cannot validate 2FA code, please try again later ({error_code})", - content_type=MIMETYPE_APPLICATION_JSON, - ) from e - - rsp = await _authorize_login(request, user, cfg) - return rsp + assert user["email"] == login_.email, "db corrupted. Invalid email" # nosec + + # Some roles have login privileges + has_privileges: Final[bool] = UserRole.USER < UserRole(user["role"]) + if has_privileges or not settings.LOGIN_2FA_REQUIRED: + response = await login_granted_response(request, user=user) + return response + + # no phone + if not user["phone"]: + response = envelope_response( + { + "code": _PHONE_NUMBER_REQUIRED, + "reason": "To login, please register first a phone number", + "next_url": f"{request.app.router['auth_verify_2fa_phone'].url_for()}", + }, + status=web.HTTPAccepted.status_code, + ) + return response + + # create 2FA + assert user["phone"] # nosec + assert settings.LOGIN_2FA_REQUIRED and settings.LOGIN_TWILIO # nosec + assert settings.LOGIN_2FA_REQUIRED and product.twilio_messaging_sid # nosec + + try: + code = await create_2fa_code(request.app, user["email"]) + await send_sms_code( + phone_number=user["phone"], + code=code, + twilo_auth=settings.LOGIN_TWILIO, + twilio_messaging_sid=product.twilio_messaging_sid, + twilio_alpha_numeric_sender=product.twilio_alpha_numeric_sender_id, + user_name=user["name"], + ) + + response = envelope_response( + { + "code": _SMS_CODE_REQUIRED, + "reason": MSG_2FA_CODE_SENT.format( + phone_number=mask_phone_number(user["phone"]) + ), + "next_url": f"{request.app.router['auth_login_2fa'].url_for()}", + }, + status=web.HTTPAccepted.status_code, + ) + return response + + except Exception as e: + error_code = create_error_code(e) + log.exception( + "Unexpectedly failed while setting up 2FA code and sending SMS[%s]", + f"{error_code}", + extra={"error_code": error_code}, + ) + raise web.HTTPServiceUnavailable( + reason=f"Currently we cannot use 2FA, please try again later ({error_code})", + content_type=MIMETYPE_APPLICATION_JSON, + ) from e +class Login2faBody(InputSchema): + email: EmailStr + code: SecretStr + + +@routes.post("/v0/auth/validate-code-login", name="auth_login_2fa") async def login_2fa(request: web.Request): """2FA login (from-end requests after login -> LOGIN_CODE_SMS_CODE_REQUIRED )""" - _, _, body = await extract_and_validate(request) + settings: LoginSettings = get_plugin_settings(request.app) db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) - email = body.email - code = body.code + if not settings.LOGIN_2FA_REQUIRED: + raise web.HTTPServiceUnavailable( + reason="2FA login is not available", + content_type=MIMETYPE_APPLICATION_JSON, + ) + + login_2fa_ = await parse_request_body_as(Login2faBody, request) # NOTE that the 2fa code is not generated until the email/password of # the standard login (handler above) is not completed - if code != await get_2fa_code(request.app, email): + if login_2fa_.code.get_secret_value() != await get_2fa_code( + request.app, login_2fa_.email + ): raise web.HTTPUnauthorized( - reason=cfg.MSG_WRONG_2FA_CODE, content_type=MIMETYPE_APPLICATION_JSON + reason=MSG_WRONG_2FA_CODE, content_type=MIMETYPE_APPLICATION_JSON ) - user = await db.get_user({"email": email}) + user = await db.get_user({"email": login_2fa_.email}) - # dispose since used - await delete_2fa_code(request.app, email) + # NOTE: a priviledge user should not have called this entrypoint + assert UserRole(user["role"]) <= UserRole.USER # nosec - rsp = await _authorize_login(request, user, cfg) - return rsp + # dispose since code was used + await delete_2fa_code(request.app, login_2fa_.email) + + response = await login_granted_response(request, user=user) + return response +class LogoutBody(InputSchema): + client_session_id: Optional[str] = Field( + None, example="5ac57685-c40f-448f-8711-70be1936fd63" + ) + + +@routes.post("/v0/auth/logout", name="auth_logout") @login_required async def logout(request: web.Request) -> web.Response: - cfg: LoginOptions = get_plugin_options(request.app) - - response = flash_response(cfg.MSG_LOGGED_OUT, "INFO") user_id = request.get(RQT_USERID_KEY, -1) - client_session_id = None - if request.can_read_body: - body = await request.json() - client_session_id = body.get("client_session_id", None) + + logout_ = await parse_request_body_as(LogoutBody, request) # Keep log message: https://github.com/ITISFoundation/osparc-simcore/issues/3200 with log_context( - log, logging.INFO, "logout of %s for %s", f"{user_id=}", f"{client_session_id=}" + log, + logging.INFO, + "logout of %s for %s", + f"{user_id=}", + f"{logout_.client_session_id=}", ): - await notify_user_logout(request.app, user_id, client_session_id) + response = flash_response(MSG_LOGGED_OUT, "INFO") + await notify_user_logout(request.app, user_id, logout_.client_session_id) await forget(request, response) - return 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/handlers_change.py index e07bfa9d63c..9e2b7bd72a7 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_change.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_change.py @@ -1,7 +1,9 @@ import logging from aiohttp import web -from servicelib.aiohttp.rest_utils import extract_and_validate +from aiohttp.web import RouteTableDef +from pydantic import EmailStr, SecretStr, validator +from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from ..products import Product, get_current_product @@ -9,6 +11,16 @@ from ..utils import HOUR from ..utils_rate_limiting import global_rate_limit_route from ._confirmation import is_confirmation_allowed, make_confirmation_link +from ._constants import ( + MSG_CANT_SEND_MAIL, + MSG_CHANGE_EMAIL_REQUESTED, + MSG_EMAIL_SENT, + MSG_OFTEN_RESET_PASSWORD, + MSG_PASSWORD_CHANGED, + MSG_UNKNOWN_EMAIL, + MSG_WRONG_PASSWORD, +) +from ._models import InputSchema, create_password_match_validator from .decorators import RQT_USERID_KEY, login_required from .settings import LoginOptions, get_plugin_options from .storage import AsyncpgStorage, get_plugin_storage @@ -24,7 +36,15 @@ log = logging.getLogger(__name__) +routes = RouteTableDef() + + +class ResetPasswordBody(InputSchema): + email: str + + @global_rate_limit_route(number_of_requests=10, interval_seconds=HOUR) +@routes.post("/v0/auth/reset-password", name="auth_reset_password") async def reset_password(request: web.Request): """ 1. confirm user exists @@ -38,29 +58,28 @@ async def reset_password(request: web.Request): - Support contact information - Who requested the reset? """ - _, _, body = await extract_and_validate(request) db: AsyncpgStorage = get_plugin_storage(request.app) cfg: LoginOptions = get_plugin_options(request.app) product: Product = get_current_product(request) - email = body.email + request_body = await parse_request_body_as(ResetPasswordBody, request) - user = await db.get_user({"email": email}) + user = await db.get_user({"email": request_body.email}) try: if not user: raise web.HTTPUnprocessableEntity( - reason=cfg.MSG_UNKNOWN_EMAIL, content_type=MIMETYPE_APPLICATION_JSON + reason=MSG_UNKNOWN_EMAIL, content_type=MIMETYPE_APPLICATION_JSON ) # 422 - validate_user_status(user, cfg, product.support_email) + validate_user_status(user=user, support_email=product.support_email) assert user["status"] == ACTIVE # nosec - assert user["email"] == email # nosec + assert user["email"] == request_body.email # nosec if not await is_confirmation_allowed(cfg, db, user, action=RESET_PASSWORD): raise web.HTTPUnauthorized( - reason=cfg.MSG_OFTEN_RESET_PASSWORD, + reason=MSG_OFTEN_RESET_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON, ) # 401 @@ -69,7 +88,7 @@ async def reset_password(request: web.Request): await render_and_send_mail( request, from_=product.support_email, - to=email, + to=request_body.email, template=await get_template_path( request, "reset_password_email_failed.jinja2" ), @@ -80,9 +99,7 @@ async def reset_password(request: web.Request): ) except Exception as err_mail: # pylint: disable=broad-except log.exception("Cannot send email") - raise web.HTTPServiceUnavailable( - reason=cfg.MSG_CANT_SEND_MAIL - ) from err_mail + raise web.HTTPServiceUnavailable(reason=MSG_CANT_SEND_MAIL) from err_mail else: confirmation = await db.create_confirmation(user["id"], action=RESET_PASSWORD) link = make_confirmation_link(request, confirmation) @@ -91,7 +108,7 @@ async def reset_password(request: web.Request): await render_and_send_mail( request, from_=product.support_email, - to=email, + to=request_body.email, template=await get_template_path( request, "reset_password_email.jinja2" ), @@ -103,29 +120,31 @@ async def reset_password(request: web.Request): except Exception as err: # pylint: disable=broad-except log.exception("Can not send email") await db.delete_confirmation(confirmation) - raise web.HTTPServiceUnavailable(reason=cfg.MSG_CANT_SEND_MAIL) from err + raise web.HTTPServiceUnavailable(reason=MSG_CANT_SEND_MAIL) from err - response = flash_response(cfg.MSG_EMAIL_SENT.format(email=email), "INFO") + response = flash_response(MSG_EMAIL_SENT.format(email=request_body.email), "INFO") return response +class ChangeEmailBody(InputSchema): + email: EmailStr + + +@routes.post("/v0/auth/change-email", name="auth_change_email") @login_required async def change_email(request: web.Request): - _, _, body = await extract_and_validate(request) - db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) product: Product = get_current_product(request) - email = body.email + request_body = await parse_request_body_as(ChangeEmailBody, request) user = await db.get_user({"id": request[RQT_USERID_KEY]}) assert user # nosec - if user["email"] == email: + if user["email"] == request_body.email: return flash_response("Email changed") - other = await db.get_user({"email": email}) + other = await db.get_user({"email": request_body.email}) if other: raise web.HTTPUnprocessableEntity(reason="This email cannot be used") @@ -135,13 +154,15 @@ async def change_email(request: web.Request): await db.delete_confirmation(confirmation) # create new confirmation to ensure email is actually valid - confirmation = await db.create_confirmation(user["id"], CHANGE_EMAIL, email) + confirmation = await db.create_confirmation( + user["id"], CHANGE_EMAIL, request_body.email + ) link = make_confirmation_link(request, confirmation) try: await render_and_send_mail( request, from_=product.support_email, - to=email, + to=request_body.email, template=await get_template_path(request, "change_email_email.jinja2"), context={ "host": request.host, @@ -151,38 +172,40 @@ async def change_email(request: web.Request): except Exception as err: # pylint: disable=broad-except log.error("Can not send email") await db.delete_confirmation(confirmation) - raise web.HTTPServiceUnavailable(reason=cfg.MSG_CANT_SEND_MAIL) from err + raise web.HTTPServiceUnavailable(reason=MSG_CANT_SEND_MAIL) from err - response = flash_response(cfg.MSG_CHANGE_EMAIL_REQUESTED) + response = flash_response(MSG_CHANGE_EMAIL_REQUESTED) return response +class ChangePasswordBody(InputSchema): + current: SecretStr + new: SecretStr + confirm: SecretStr + + _password_confirm_match = validator("confirm", allow_reuse=True)( + create_password_match_validator(reference_field="new") + ) + + +@routes.post("/v0/auth/change-password", name="auth_change_password") @login_required async def change_password(request: web.Request): db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) + passwords = await parse_request_body_as(ChangePasswordBody, request) user = await db.get_user({"id": request[RQT_USERID_KEY]}) assert user # nosec - _, _, body = await extract_and_validate(request) - - cur_password = body.current - new_password = body.new - confirm = body.confirm - - if not check_password(cur_password, user["password_hash"]): + if not check_password(passwords.current.get_secret_value(), user["password_hash"]): raise web.HTTPUnprocessableEntity( - reason=cfg.MSG_WRONG_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON + reason=MSG_WRONG_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON ) # 422 - if new_password != confirm: - raise web.HTTPConflict( - reason=cfg.MSG_PASSWORD_MISMATCH, content_type=MIMETYPE_APPLICATION_JSON - ) # 409 - - await db.update_user(user, {"password_hash": encrypt_password(new_password)}) + await db.update_user( + user, {"password_hash": encrypt_password(passwords.new.get_secret_value())} + ) - response = flash_response(cfg.MSG_PASSWORD_CHANGED) + response = flash_response(MSG_PASSWORD_CHANGED) return response diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py index f0392cfb28c..a2b0f486d19 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py @@ -2,18 +2,26 @@ from typing import Optional from aiohttp import web -from pydantic import EmailStr, parse_obj_as -from servicelib.aiohttp.rest_utils import extract_and_validate -from servicelib.logging_utils import log_context +from aiohttp.web import RouteTableDef +from pydantic import BaseModel, EmailStr, Field, SecretStr, parse_obj_as, validator +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) +from servicelib.error_codes import create_error_code from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.errors import UniqueViolation from yarl import URL -from ..security_api import encrypt_password, remember +from ..security_api import encrypt_password from ..utils import MINUTE +from ..utils_aiohttp import create_redirect_response from ..utils_rate_limiting import global_rate_limit_route from ._2fa import delete_2fa_code, get_2fa_code from ._confirmation import validate_confirmation_code +from ._constants import MSG_PASSWORD_CHANGED +from ._models import InputSchema, check_confirm_password_match +from ._security import login_granted_response from .settings import ( LoginOptions, LoginSettings, @@ -26,6 +34,14 @@ log = logging.getLogger(__name__) +routes = RouteTableDef() + + +class _PathParam(BaseModel): + code: SecretStr + + +@routes.get("/auth/confirmation/{code}", name="auth_confirmation") async def email_confirmation(request: web.Request): """Handles email confirmation by checking a code passed as query parameter @@ -42,68 +58,87 @@ async def email_confirmation(request: web.Request): - show the reset-password page - use the token to submit a POST /v0/auth/confirmation/{code} and finalize reset action """ - params, _, _ = await extract_and_validate(request) - db: AsyncpgStorage = get_plugin_storage(request.app) cfg: LoginOptions = get_plugin_options(request.app) - code = params["code"] + path_params = parse_request_path_parameters_as(_PathParam, request) confirmation: Optional[ConfirmationTokenDict] = await validate_confirmation_code( - code, db, cfg + path_params.code.get_secret_value(), db=db, cfg=cfg ) - redirect_url = URL(cfg.LOGIN_REDIRECT) + redirect_to_login_url = URL(cfg.LOGIN_REDIRECT) if confirmation and (action := confirmation["action"]): - if action == REGISTRATION: - user = await db.get_user({"id": confirmation["user_id"]}) - await db.update_user(user, {"status": ACTIVE}) - await db.delete_confirmation(confirmation) - redirect_url = redirect_url.with_fragment("?registered=true") - log.debug( - "%s registered -> %s", - f"{user=}", - f"{redirect_url=}", - ) + try: + user_id = confirmation["user_id"] + if action == REGISTRATION: + # activate user and consume confirmation token + await db.delete_confirmation_and_update_user( + user_id=user_id, + updates={"status": ACTIVE}, + confirmation=confirmation, + ) + + redirect_to_login_url = redirect_to_login_url.with_fragment( + "?registered=true" + ) + + elif action == CHANGE_EMAIL: + # update and consume confirmation token + await db.delete_confirmation_and_update_user( + user_id=user_id, + updates={"email": parse_obj_as(EmailStr, confirmation["data"])}, + confirmation=confirmation, + ) + + elif action == RESET_PASSWORD: + # + # NOTE: By using fragments (instead of queries or path parameters), + # the browser does NOT reloads page + # + redirect_to_login_url = redirect_to_login_url.with_fragment( + f"reset-password?code={path_params.code}" + ) - elif action == CHANGE_EMAIL: - # - # TODO: compose error and send to front-end using fragments in the redirection - # But first we need to implement this refactoring https://github.com/ITISFoundation/osparc-simcore/issues/1975 - # - user_update = {"email": parse_obj_as(EmailStr, confirmation["data"])} - user = await db.get_user({"id": confirmation["user_id"]}) - await db.update_user(user, user_update) - await db.delete_confirmation(confirmation) log.debug( - "%s updated %s", - f"{user=}", - f"{user_update}", + "Confirms %s of %s with %s -> %s", + action, + f"{user_id=}", + f"{confirmation=}", + f"{redirect_to_login_url=}", ) - elif action == RESET_PASSWORD: - # NOTE: By using fragments (instead of queries or path parameters), the browser does NOT reloads page - redirect_url = redirect_url.with_fragment("reset-password?code=%s" % code) - log.debug( - "Reset password requested %s. %s", - f"{confirmation=}", - f"{redirect_url=}", + except Exception as err: # pylint: disable=broad-except + error_code = create_error_code(err) + log.exception( + "Failed during email_confirmation [%s]", + f"{error_code}", + extra={"error_code": error_code}, ) + raise create_redirect_response( + request.app, + page="error", + message=f"Sorry, we cannot confirm your {action}." + "Please try again in a few moments ({error_code})", + status_code=web.HTTPServiceUnavailable.status_code, + ) from err + + raise web.HTTPFound(location=redirect_to_login_url) + - raise web.HTTPFound(location=redirect_url) +class PhoneConfirmationBody(InputSchema): + email: EmailStr + phone: str = Field( + ..., description="Phone number E.164, needed on the deployments with 2FA" + ) + code: SecretStr @global_rate_limit_route(number_of_requests=5, interval_seconds=MINUTE) +@routes.post("/auth/validate-code-register", name="auth_validate_2fa_register") async def phone_confirmation(request: web.Request): - _, _, body = await extract_and_validate(request) - settings: LoginSettings = get_plugin_settings(request.app) db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) - - email = body.email - phone = body.phone - code = body.code if not settings.LOGIN_2FA_REQUIRED: raise web.HTTPServiceUnavailable( @@ -111,13 +146,18 @@ async def phone_confirmation(request: web.Request): content_type=MIMETYPE_APPLICATION_JSON, ) - if (expected := await get_2fa_code(request.app, email)) and code == expected: - await delete_2fa_code(request.app, email) + request_body = await parse_request_body_as(PhoneConfirmationBody, request) - # db + if ( + expected := await 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) + + # updates confirmed phone number try: - user = await db.get_user({"email": email}) - await db.update_user(user, {"phone": phone}) + user = await db.get_user({"email": request_body.email}) + await db.update_user(user, {"phone": request_body.phone}) except UniqueViolation as err: raise web.HTTPUnauthorized( @@ -125,51 +165,52 @@ async def phone_confirmation(request: web.Request): content_type=MIMETYPE_APPLICATION_JSON, ) from err - # login - with log_context( - log, - logging.INFO, - "login after phone_confirmation of user_id=%s with %s", - f"{user.get('id')}", - f"{email=}", - ): - identity = user["email"] - response = flash_response(cfg.MSG_LOGGED_IN, "INFO") - await remember(request, response, identity) - return response - - # unauthorized + response = await login_granted_response(request, user=user) + return response + + # fails because of invalid or no code raise web.HTTPUnauthorized( reason="Invalid 2FA code", content_type=MIMETYPE_APPLICATION_JSON ) +class ResetPasswordConfirmation(InputSchema): + password: SecretStr + confirm: SecretStr + + _password_confirm_match = validator("confirm", allow_reuse=True)( + check_confirm_password_match + ) + + +@routes.post("/auth/reset-password/{code}", name="auth_reset_password_allowed") async def reset_password_allowed(request: web.Request): """Changes password using a token code without being logged in""" - params, _, body = await extract_and_validate(request) - db: AsyncpgStorage = get_plugin_storage(request.app) cfg: LoginOptions = get_plugin_options(request.app) - code = params["code"] - password = body.password - confirm = body.confirm + path_params = parse_request_path_parameters_as(_PathParam, request) + request_body = await parse_request_body_as(ResetPasswordConfirmation, request) - if password != confirm: - raise web.HTTPConflict( - reason=cfg.MSG_PASSWORD_MISMATCH, content_type=MIMETYPE_APPLICATION_JSON - ) # 409 - - confirmation = await validate_confirmation_code(code, db, cfg) + confirmation = await validate_confirmation_code( + path_params.code.get_secret_value(), db, cfg + ) if confirmation: user = await db.get_user({"id": confirmation["user_id"]}) assert user # nosec - await db.update_user(user, {"password_hash": encrypt_password(password)}) + await db.update_user( + user, + { + "password_hash": encrypt_password( + request_body.password.get_secret_value() + ) + }, + ) await db.delete_confirmation(confirmation) - response = flash_response(cfg.MSG_PASSWORD_CHANGED) + response = flash_response(MSG_PASSWORD_CHANGED) return response raise web.HTTPUnauthorized( diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index c1c60c0d5f1..a6d1a1118fd 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 @@ -1,19 +1,25 @@ import logging from datetime import datetime, timedelta +from typing import Optional from aiohttp import web -from servicelib.aiohttp.rest_utils import extract_and_validate +from aiohttp.web import RouteTableDef +from pydantic import EmailStr, Field, SecretStr, validator +from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.error_codes import create_error_code from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from ..groups_api import auto_add_user_to_groups from ..products import Product, get_current_product -from ..security_api import encrypt_password, remember +from ..security_api import encrypt_password from ..utils import MINUTE from ..utils_rate_limiting import global_rate_limit_route -from ._2fa import mask_phone_number, send_sms_code, set_2fa_code +from ._2fa import create_2fa_code, mask_phone_number, send_sms_code from ._confirmation import make_confirmation_link -from ._registration import check_and_consume_invitation, check_registration +from ._constants import MSG_2FA_CODE_SENT, MSG_CANT_SEND_MAIL +from ._models import InputSchema, check_confirm_password_match +from ._registration import check_and_consume_invitation, check_other_registrations +from ._security import login_granted_response from .settings import ( LoginOptions, LoginSettings, @@ -39,6 +45,33 @@ def _get_user_name(email: str) -> str: return username +routes = RouteTableDef() + + +class RegisterBody(InputSchema): + email: EmailStr + password: SecretStr + confirm: Optional[SecretStr] = Field(None, description="Password confirmation") + invitation: Optional[str] = Field(None, description="Invitation code") + + _password_confirm_match = validator("confirm", allow_reuse=True)( + check_confirm_password_match + ) + + class Config: + schema_extra = { + "examples": [ + { + "email": "foo@mymail.com", + "password": "my secret", # NOSONAR + "confirm": "my secret", # optional + "invitation": "33c451d4-17b7-4e65-9880-694559b8ffc2", # optional only active + } + ] + } + + +@routes.post("/v0/auth/register", name="auth_register") async def register(request: web.Request): """ Starts user's registration by providing an email, password and @@ -46,39 +79,36 @@ async def register(request: web.Request): An email with a link to 'email_confirmation' is sent to complete registration """ - _, _, body = await extract_and_validate(request) - settings: LoginSettings = get_plugin_settings(request.app) + product: Product = get_current_product(request) db: AsyncpgStorage = get_plugin_storage(request.app) cfg: LoginOptions = get_plugin_options(request.app) - product: Product = get_current_product(request) - email = body.email - username = _get_user_name(email) - password = body.password - confirm = body.confirm if hasattr(body, "confirm") else None + registration = await parse_request_body_as(RegisterBody, request) + + await check_other_registrations(email=registration.email, db=db, cfg=cfg) - expires_at = None + expires_at = None # = does not expire if settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: - try: - invitation_code = body.invitation - except AttributeError as e: + # Only requests with INVITATION can register user + # to either a permanent or to a trial account + invitation_code = registration.invitation + if invitation_code is None: raise web.HTTPBadRequest( reason="invitation field is required", content_type=MIMETYPE_APPLICATION_JSON, - ) from e + ) invitation = await check_and_consume_invitation(invitation_code, db=db, cfg=cfg) if invitation.trial_account_days: expires_at = datetime.utcnow() + timedelta(invitation.trial_account_days) - await check_registration(email, password, confirm, db, cfg) - + username = _get_user_name(registration.email) user: dict = await db.create_user( { "name": username, - "email": email, - "password_hash": encrypt_password(password), + "email": registration.email, + "password_hash": encrypt_password(registration.password.get_secret_value()), "status": ( CONFIRMATION_PENDING if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED @@ -90,63 +120,82 @@ async def register(request: web.Request): } ) + # NOTE: PC->SAN: should this go here or when user is actually logged in? await auto_add_user_to_groups(request.app, user["id"]) - if not settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: + if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: + # Confirmation required: send confirmation email + _confirmation: ConfirmationTokenDict = await db.create_confirmation( + user["id"], REGISTRATION + ) + + try: + email_confirmation_url = make_confirmation_link(request, _confirmation) + email_template_path = await get_template_path( + request, "registration_email.jinja2" + ) + await render_and_send_mail( + request, + from_=product.support_email, + to=registration.email, + template=email_template_path, + context={ + "host": request.host, + "link": email_confirmation_url, # SEE email_confirmation handler (action=REGISTRATION) + "name": username, + "support_email": product.support_email, + }, + ) + except Exception as err: # pylint: disable=broad-except + error_code = create_error_code(err) + log.exception( + "Failed while sending confirmation email to %s, %s [%s]", + f"{user=}", + f"{_confirmation=}", + f"{error_code}", + extra={"error_code": error_code}, + ) + + await db.delete_confirmation_and_user(user, _confirmation) + + raise web.HTTPServiceUnavailable( + reason=f"{MSG_CANT_SEND_MAIL} [{error_code}]" + ) from err + + else: + response = flash_response( + "You are registered successfully! To activate your account, please, " + f"click on the verification link in the email we sent you to {registration.email}.", + "INFO", + ) + return response + else: + # No confirmation required: authorize login + assert not settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED # nosec assert not settings.LOGIN_2FA_REQUIRED # nosec - # user is logged in - identity = body.email - response = flash_response(cfg.MSG_LOGGED_IN, "INFO") - await remember(request, response, identity) + response = await login_granted_response(request=request, user=user) return response - confirmation_: ConfirmationTokenDict = await db.create_confirmation( - user["id"], REGISTRATION - ) - link = make_confirmation_link(request, confirmation_) - try: - await render_and_send_mail( - request, - from_=product.support_email, - to=email, - template=await get_template_path(request, "registration_email.jinja2"), - context={ - "host": request.host, - "link": link, - "name": username, - "support_email": product.support_email, - }, - ) - except Exception as err: # pylint: disable=broad-except - log.exception("Can not send email") - await db.delete_confirmation(confirmation_) - await db.delete_user(user) - raise web.HTTPServiceUnavailable(reason=cfg.MSG_CANT_SEND_MAIL) from err - - response = flash_response( - "You are registered successfully! To activate your account, please, " - "click on the verification link in the email we sent you.", - "INFO", + +class RegisterPhoneBody(InputSchema): + email: EmailStr + phone: str = Field( + ..., description="Phone number E.164, needed on the deployments with 2FA" ) - return response @global_rate_limit_route(number_of_requests=5, interval_seconds=MINUTE) +@routes.post("/auth/verify-phone-number", name="auth_verify_2fa_phone") async def register_phone(request: web.Request): """ Submits phone registration - sends a code - registration is completed requesting to 'phone_confirmation' route with the code received """ - _, _, body = await extract_and_validate(request) - settings: LoginSettings = get_plugin_settings(request.app) + product: Product = get_current_product(request) db: AsyncpgStorage = get_plugin_storage(request.app) - cfg: LoginOptions = get_plugin_options(request.app) - - email = body.email - phone = body.phone if not settings.LOGIN_2FA_REQUIRED: raise web.HTTPServiceUnavailable( @@ -154,8 +203,9 @@ async def register_phone(request: web.Request): content_type=MIMETYPE_APPLICATION_JSON, ) + registration = await parse_request_body_as(RegisterPhoneBody, request) + try: - product: Product = get_current_product(request) assert settings.LOGIN_2FA_REQUIRED and settings.LOGIN_TWILIO # nosec if not product.twilio_messaging_sid: raise ValueError( @@ -163,25 +213,27 @@ async def register_phone(request: web.Request): "Update product's twilio_messaging_sid in database." ) - if await db.get_user({"phone": phone}): + if await db.get_user({"phone": registration.phone}): raise web.HTTPUnauthorized( reason="Cannot register this phone number because it is already assigned to an active user", content_type=MIMETYPE_APPLICATION_JSON, ) - code = await set_2fa_code(request.app, email) + code = await create_2fa_code(request.app, registration.email) await send_sms_code( - phone_number=phone, + phone_number=registration.phone, code=code, twilo_auth=settings.LOGIN_TWILIO, twilio_messaging_sid=product.twilio_messaging_sid, twilio_alpha_numeric_sender=product.twilio_alpha_numeric_sender_id, - user_name=_get_user_name(email), + user_name=_get_user_name(registration.email), ) response = flash_response( - cfg.MSG_2FA_CODE_SENT.format(phone_number=mask_phone_number(phone)), + MSG_2FA_CODE_SENT.format( + phone_number=mask_phone_number(registration.phone) + ), status=web.HTTPAccepted.status_code, ) return response @@ -189,15 +241,16 @@ async def register_phone(request: web.Request): except web.HTTPException: raise - except Exception as e: # Unexpected errors -> 503 - error_code = create_error_code(e) + except Exception as err: # pylint: disable=broad-except + # Unhandled errors -> 503 + error_code = create_error_code(err) log.exception( - "Phone registration unexpectedly failed [%s]", + "Phone registration failed [%s]", f"{error_code}", extra={"error_code": error_code}, ) raise web.HTTPServiceUnavailable( - reason=f"Currently our system cannot register phone numbers ({error_code})", + reason=f"Currently we cannot register phone numbers ({error_code})", content_type=MIMETYPE_APPLICATION_JSON, - ) from e + ) from err diff --git a/services/web/server/src/simcore_service_webserver/login/plugin.py b/services/web/server/src/simcore_service_webserver/login/plugin.py index 18410add29a..2c996db0629 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -26,7 +26,7 @@ async def _setup_login_storage_ctx(app: web.Application): - # TODO: ensure pool only init once! + assert APP_LOGIN_STORAGE_KEY not in app # nosec settings: PostgresSettings = get_db_plugin_settings(app) pool: asyncpg.pool.Pool = await asyncpg.create_pool( @@ -49,7 +49,7 @@ async def _setup_login_storage_ctx(app: web.Application): def setup_login_storage(app: web.Application): - if app.get(APP_LOGIN_STORAGE_KEY) is None: + if _setup_login_storage_ctx not in app.cleanup_ctx: app.cleanup_ctx.append(_setup_login_storage_ctx) @@ -67,7 +67,6 @@ def _setup_login_options(app: web.Application): "simcore_service_webserver.login", ModuleCategory.ADDON, settings_name="WEBSERVER_LOGIN", - depends=[f"simcore_service_webserver.{mod}" for mod in ("rest", "db")], logger=log, ) def setup_login(app: web.Application): diff --git a/services/web/server/src/simcore_service_webserver/login/routes.py b/services/web/server/src/simcore_service_webserver/login/routes.py index 6478ff79914..f82c4ef4a8e 100644 --- a/services/web/server/src/simcore_service_webserver/login/routes.py +++ b/services/web/server/src/simcore_service_webserver/login/routes.py @@ -36,7 +36,7 @@ def include_path(tuple_object): "auth_verify_2fa_phone": register_handlers.register_phone, "auth_validate_2fa_register": confirmation_handlers.phone_confirmation, "auth_login": login_handlers.login, - "auth_validate_2fa_login": login_handlers.login_2fa, + "auth_login_2fa": login_handlers.login_2fa, "auth_logout": login_handlers.logout, "auth_reset_password": change_handlers.reset_password, "auth_reset_password_allowed": confirmation_handlers.reset_password_allowed, diff --git a/services/web/server/src/simcore_service_webserver/login/settings.py b/services/web/server/src/simcore_service_webserver/login/settings.py index e39ba3d8db7..085e86eb839 100644 --- a/services/web/server/src/simcore_service_webserver/login/settings.py +++ b/services/web/server/src/simcore_service_webserver/login/settings.py @@ -65,7 +65,10 @@ def login_2fa_needs_sms_service(cls, v, values): class LoginOptions(BaseModel): - """These options are NOT directly exposed to the env vars due to security reasons.""" + """These options are NOT directly exposed to the env vars due to security reasons. + + NOTE: This is legacy from first version and should not be extended anymore + """ PASSWORD_LEN: tuple[PositiveInt, PositiveInt] = (6, 30) LOGIN_REDIRECT: str = "/" @@ -90,35 +93,6 @@ def get_confirmation_lifetime( value = getattr(self, f"{action.upper()}_CONFIRMATION_LIFETIME") return timedelta(days=value) - MSG_LOGGED_IN: str = "You are logged in" - MSG_LOGGED_OUT: str = "You are logged out" - MSG_2FA_CODE_SENT: str = "Code sent by SMS to {phone_number}" - MSG_WRONG_2FA_CODE: str = "Invalid code (wrong or expired)" - MSG_ACTIVATED: str = "Your account is activated" - MSG_UNKNOWN_EMAIL: str = "This email is not registered" - MSG_WRONG_PASSWORD: str = "Wrong password" - MSG_PASSWORD_MISMATCH: str = "Password and confirmation do not match" - MSG_USER_BANNED: str = "This user does not have anymore access. Please contact support for further details: {support_email}" - MSG_USER_EXPIRED: str = "This account has expired and does not have anymore access. Please contact support for further details: {support_email}" - MSG_ACTIVATION_REQUIRED: str = ( - "You have to activate your account via email, before you can login" - ) - MSG_EMAIL_EXISTS: str = "This email is already registered" - MSG_OFTEN_RESET_PASSWORD: str = ( - "You can not request of restoring your password so often. Please, use" - " the link we sent you recently" - ) - - MSG_CANT_SEND_MAIL: str = "Can't send email, try a little later" - MSG_PASSWORDS_NOT_MATCH: str = "Passwords must match" - MSG_PASSWORD_CHANGED: str = "Your password is changed" - MSG_CHANGE_EMAIL_REQUESTED: str = ( - "Please, click on the verification link we sent to your new email address" - ) - MSG_EMAIL_CHANGED: str = "Your email is changed" - MSG_AUTH_FAILED: str = "Authorization failed" - MSG_EMAIL_SENT: str = "An email has been sent to {email} with further instructions" - def get_plugin_settings(app: web.Application) -> LoginSettings: settings = app[APP_SETTINGS_KEY].WEBSERVER_LOGIN diff --git a/services/web/server/src/simcore_service_webserver/login/storage.py b/services/web/server/src/simcore_service_webserver/login/storage.py index 5b17e0fea98..09d15f21192 100644 --- a/services/web/server/src/simcore_service_webserver/login/storage.py +++ b/services/web/server/src/simcore_service_webserver/login/storage.py @@ -41,8 +41,11 @@ def __init__( self.user_tbl = user_table_name self.confirm_tbl = confirmation_table_name + # + # CRUD user + # + async def get_user(self, with_data) -> asyncpg.Record: - # FIXME: these can throw!!!! async with self.pool.acquire() as conn: data = await _sql.find_one(conn, self.user_tbl, with_data) return data @@ -63,6 +66,9 @@ async def delete_user(self, user): async with self.pool.acquire() as conn: await _sql.delete(conn, self.user_tbl, {"id": user["id"]}) + # + # CRUD confirmation + # async def create_confirmation( self, user_id, action: ActionLiteralStr, data=None ) -> ConfirmationTokenDict: @@ -89,14 +95,37 @@ async def get_confirmation(self, filter_dict) -> Optional[ConfirmationTokenDict] 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) - return ( - ConfirmationTokenDict(**confirmation) if confirmation else confirmation - ) + return ConfirmationTokenDict(**confirmation) if confirmation else None async def delete_confirmation(self, confirmation: ConfirmationTokenDict): async with self.pool.acquire() as conn: await _sql.delete(conn, self.confirm_tbl, {"code": confirmation["code"]}) + # + # Transactions that guarantee atomicity. This avoids + # inconsistent states of confirmation and users rows + # + + async def delete_confirmation_and_user( + self, user, confirmation: ConfirmationTokenDict + ): + async with self.pool.acquire() as conn: + async with conn.transaction(): + await _sql.delete( + conn, self.confirm_tbl, {"code": confirmation["code"]} + ) + await _sql.delete(conn, self.user_tbl, {"id": user["id"]}) + + async def delete_confirmation_and_update_user( + self, user_id, updates, confirmation: ConfirmationTokenDict + ): + async with self.pool.acquire() as conn: + async with conn.transaction(): + await _sql.delete( + conn, self.confirm_tbl, {"code": confirmation["code"]} + ) + await _sql.update(conn, self.user_tbl, {"id": user_id}, updates) + def get_plugin_storage(app: web.Application) -> AsyncpgStorage: storage = app.get(APP_LOGIN_STORAGE_KEY) 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 2c7593c1855..18d859a81a6 100644 --- a/services/web/server/src/simcore_service_webserver/login/utils.py +++ b/services/web/server/src/simcore_service_webserver/login/utils.py @@ -14,14 +14,13 @@ from simcore_postgres_database.models.users import UserRole from ..db_models import ConfirmationAction, UserRole, UserStatus -from .settings import LoginOptions +from ._constants import MSG_ACTIVATION_REQUIRED, MSG_USER_BANNED, MSG_USER_EXPIRED log = logging.getLogger(__name__) def _to_names(enum_cls, names): """ensures names are in enum be retrieving each of them""" - # FIXME: with asyncpg need to user NAMES return [getattr(enum_cls, att).name for att in names.split()] @@ -41,24 +40,24 @@ def _to_names(enum_cls, names): ) -def validate_user_status(user: dict, cfg: LoginOptions, support_email: str): +def validate_user_status(*, user: dict, support_email: str): user_status: str = user["status"] if user_status == BANNED or user["role"] == ANONYMOUS: raise web.HTTPUnauthorized( - reason=cfg.MSG_USER_BANNED.format(support_email=support_email), + reason=MSG_USER_BANNED.format(support_email=support_email), content_type=MIMETYPE_APPLICATION_JSON, ) # 401 if user_status == EXPIRED: raise web.HTTPUnauthorized( - reason=cfg.MSG_USER_EXPIRED.format(support_email=support_email), + reason=MSG_USER_EXPIRED.format(support_email=support_email), content_type=MIMETYPE_APPLICATION_JSON, ) # 401 if user_status == CONFIRMATION_PENDING: raise web.HTTPUnauthorized( - reason=cfg.MSG_ACTIVATION_REQUIRED, + reason=MSG_ACTIVATION_REQUIRED, content_type=MIMETYPE_APPLICATION_JSON, ) # 401 @@ -78,9 +77,7 @@ async def notify_user_logout( def encrypt_password(password: str) -> str: - # TODO: add settings sha256_crypt.using(**settings).hash(secret) - # see https://passlib.readthedocs.io/en/stable/lib/passlib.hash.sha256_crypt.html - # + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/3375 return passlib.hash.sha256_crypt.using(rounds=1000).hash(password) @@ -105,17 +102,17 @@ def get_client_ip(request: web.Request) -> str: def flash_response( message: str, level: str = "INFO", *, status: int = web.HTTPOk.status_code ) -> web.Response: - rsp = envelope_response( + response = envelope_response( attr.asdict(LogMessageType(message, level)), status=status, ) - return rsp + return response def envelope_response( data: Any, *, status: int = web.HTTPOk.status_code ) -> web.Response: - rsp = web.json_response( + response = web.json_response( { "data": data, "error": None, @@ -123,4 +120,4 @@ def envelope_response( dumps=json_dumps, status=status, ) - return rsp + return response diff --git a/services/web/server/src/simcore_service_webserver/login/utils_email.py b/services/web/server/src/simcore_service_webserver/login/utils_email.py index 5d11edd410a..1174e58b2de 100644 --- a/services/web/server/src/simcore_service_webserver/login/utils_email.py +++ b/services/web/server/src/simcore_service_webserver/login/utils_email.py @@ -21,8 +21,7 @@ log = logging.getLogger(__name__) -async def _send_mail(app: web.Application, msg: Union[MIMEText, MIMEMultipart]): - cfg: LoginOptions = get_plugin_options(app) +async def _send_mail(*, message: Union[MIMEText, MIMEMultipart], cfg: LoginOptions): log.debug("Email configuration %s", cfg) smtp_args = dict( hostname=cfg.SMTP_HOST, @@ -49,7 +48,7 @@ async def _send_mail(app: web.Application, msg: Union[MIMEText, MIMEMultipart]): if cfg.SMTP_USERNAME and cfg.SMTP_PASSWORD: log.info("Attempting a login into the email server ...") await smtp.login(cfg.SMTP_USERNAME, cfg.SMTP_PASSWORD.get_secret_value()) - await smtp.send_message(msg) + await smtp.send_message(message) await smtp.quit() else: async with aiosmtplib.SMTP(**smtp_args) as smtp: @@ -58,18 +57,23 @@ async def _send_mail(app: web.Application, msg: Union[MIMEText, MIMEMultipart]): await smtp.login( cfg.SMTP_USERNAME, cfg.SMTP_PASSWORD.get_secret_value() ) - await smtp.send_message(msg) + await smtp.send_message(message) async def _compose_mail( - app: web.Application, *, sender: str, recipient: str, subject: str, body: str + *, + cfg: LoginOptions, + sender: str, + recipient: str, + subject: str, + body: str, ) -> None: msg = MIMEText(body, "html") msg["Subject"] = subject msg["From"] = sender msg["To"] = recipient - await _send_mail(app, msg) + await _send_mail(cfg=cfg, message=msg) class AttachmentTuple(NamedTuple): @@ -78,8 +82,8 @@ class AttachmentTuple(NamedTuple): async def _compose_multipart_mail( - app: web.Application, *, + cfg: LoginOptions, sender: str, recipient: str, subject: str, @@ -110,7 +114,7 @@ async def _compose_multipart_mail( encoders.encode_base64(part) msg.attach(part) - await _send_mail(app, msg) + await _send_mail(cfg=cfg, message=msg) def themed(dirname, template) -> Path: @@ -123,6 +127,7 @@ async def get_template_path(request: web.Request, filename: str) -> Path: async def render_and_send_mail( request: web.Request, + *, from_: str, to: str, template: Path, @@ -136,9 +141,10 @@ async def render_and_send_mail( # subject, body = page.split("\n", 1) + cfg: LoginOptions = get_plugin_options(request.app) if attachments: await _compose_multipart_mail( - request.app, + cfg=cfg, sender=from_, recipient=to, subject=subject.strip(), @@ -147,7 +153,7 @@ async def render_and_send_mail( ) else: await _compose_mail( - request.app, + cfg=cfg, sender=from_, recipient=to, subject=subject.strip(), diff --git a/services/web/server/src/simcore_service_webserver/rest.py b/services/web/server/src/simcore_service_webserver/rest.py index 4a350fdc2a8..02a631d4fb6 100644 --- a/services/web/server/src/simcore_service_webserver/rest.py +++ b/services/web/server/src/simcore_service_webserver/rest.py @@ -6,7 +6,6 @@ """ import logging -from typing import Tuple from aiohttp import web from aiohttp_swagger import setup_swagger @@ -23,6 +22,7 @@ from .rest_healthcheck import HealthCheck from .rest_settings import RestSettings, get_plugin_settings from .rest_utils import get_openapi_specs_path, load_openapi_specs +from .security import setup_security log = logging.getLogger(__name__) @@ -30,12 +30,14 @@ @app_module_setup( __name__, ModuleCategory.ADDON, - depends=["simcore_service_webserver.security"], settings_name="WEBSERVER_REST", logger=log, ) def setup_rest(app: web.Application): settings: RestSettings = get_plugin_settings(app) + + setup_security(app) + is_diagnostics_enabled: bool = ( app[APP_SETTINGS_KEY].WEBSERVER_DIAGNOSTICS is not None ) @@ -91,4 +93,4 @@ def setup_rest(app: web.Application): ) -__all__: Tuple[str, ...] = ("setup_rest",) +__all__: tuple[str, ...] = ("setup_rest",) diff --git a/services/web/server/src/simcore_service_webserver/session.py b/services/web/server/src/simcore_service_webserver/session.py index 53907ae7c33..f6308d1ee48 100644 --- a/services/web/server/src/simcore_service_webserver/session.py +++ b/services/web/server/src/simcore_service_webserver/session.py @@ -1,20 +1,5 @@ -""" user's session submodule +""" user's session plugin - - stores user-specific data into a session object - - session object has a dict-like interface - - installs middleware in ``aiohttp.web.Application`` that attaches to - a session object to ``request``. Usage: - ``` - async def my_handler(request) - session = await get_session(request) - ``` - - data sessions stored in encripted cookies. - - client tx/rx session's data everytime (middleware?) - - This way, we can scale in theory server-side w/o issues - - TODO: test and demo statement above - - based in aiotthp_session library : http://aiohttp-session.readthedocs.io/en/latest/ - - TODO: check storing JSON-ed data into redis-service, keeping into cookie only redis key (random UUID). Pros/cons analysis. """ import base64 import logging @@ -30,33 +15,51 @@ async def my_handler(request) logger = logging.getLogger(__name__) -def generate_key(): +def generate_fernet_secret_key() -> bytes: # secret_key must be 32 url-safe base64-encoded bytes fernet_key = fernet.Fernet.generate_key() secret_key = base64.urlsafe_b64decode(fernet_key) return secret_key +# alias +get_session = aiohttp_session.get_session + + @app_module_setup( __name__, ModuleCategory.ADDON, settings_name="WEBSERVER_SESSION", logger=logger ) def setup_session(app: web.Application): """ Inits and registers a session middleware in aiohttp.web.Application + + - stores user-specific data into a session object + - session object has a dict-like interface + - installs middleware in ``aiohttp.web.Application`` that attaches to + a session object to ``request``. Usage: + ``` + async def my_handler(request) + session = await get_session(request) + ``` + - data sessions stored in encripted cookies. + - client tx/rx session's data everytime (middleware?) + - This way, we can scale in theory server-side w/o issues + - based in aiotthp_session library : http://aiohttp-session.readthedocs.io/en/latest/ + """ settings: SessionSettings = get_plugin_settings(app) # EncryptedCookieStorage urlsafe_b64decode inside if passes bytes - storage = EncryptedCookieStorage( + encrypted_cookie_sessions = EncryptedCookieStorage( secret_key=settings.SESSION_SECRET_KEY.get_secret_value(), cookie_name="osparc.WEBAPI_SESSION", ) - aiohttp_session.setup(app, storage) + aiohttp_session.setup(app=app, storage=encrypted_cookie_sessions) -# alias -get_session = aiohttp_session.get_session - - -__all__ = ("setup_session", "get_session") +__all__: tuple[str, ...] = ( + "generate_fernet_secret_key", + "get_session", + "setup_session", +) diff --git a/services/web/server/src/simcore_service_webserver/socketio/server.py b/services/web/server/src/simcore_service_webserver/socketio/server.py index 6ec40a6e365..4ef5c88e0b5 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/server.py +++ b/services/web/server/src/simcore_service_webserver/socketio/server.py @@ -49,6 +49,7 @@ def setup_socketio_server(app: web.Application): sio.attach(app) app[APP_CLIENT_SOCKET_SERVER_KEY] = sio - app.cleanup_ctx.append(_socketio_server_cleanup_ctx) + if _socketio_server_cleanup_ctx not in app.cleanup_ctx: + app.cleanup_ctx.append(_socketio_server_cleanup_ctx) return get_socket_server(app) diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/handlers_redirects.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/handlers_redirects.py index e29e554ddc0..cc40cc5fb28 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/handlers_redirects.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/handlers_redirects.py @@ -11,10 +11,9 @@ from models_library.services import KEY_RE, VERSION_RE from pydantic import BaseModel, HttpUrl, ValidationError, constr, validator from pydantic.types import PositiveInt -from yarl import URL -from .._constants import INDEX_RESOURCE_NAME from ..products import get_product_name +from ..utils_aiohttp import create_redirect_response from ._core import StudyDispatcherError, ViewerInfo, validate_requested_viewer from ._projects import acquire_project_with_viewer from ._users import UserInfo, acquire_user, ensure_authentication @@ -22,34 +21,6 @@ log = logging.getLogger(__name__) -def create_redirect_response( - app: web.Application, page: str, **parameters -) -> web.HTTPFound: - """ - Returns a redirect response to the front-end with information on page and parameters embedded in the fragment. - - For instance, - https://osparc.io/#/error?message=Sorry%2C%20I%20could%20not%20find%20this%20&status_code=404 - results from - - page=error - and parameters - - message="Sorry, I could not find this" - - status_code=404 - - Front-end can then render this data either in an error or a view page - """ - # TODO: Uniform encoding in front-end fragments https://github.com/ITISFoundation/osparc-simcore/issues/1975 - log.debug("page: '%s' parameters: '%s'", page, parameters) - - page = page.strip(" /") - assert page in ("view", "error") # nosec - fragment_path = str(URL.build(path=f"/{page}").with_query(parameters)) - redirect_url = ( - app.router[INDEX_RESOURCE_NAME].url_for().with_fragment(fragment_path) - ) - return web.HTTPFound(location=redirect_url) - - # HANDLERS -------------------------------- class ViewerQueryParams(BaseModel): file_type: str diff --git a/services/web/server/src/simcore_service_webserver/templates/common/new_2fa_code.jinja2 b/services/web/server/src/simcore_service_webserver/templates/common/new_2fa_code.jinja2 new file mode 100644 index 00000000000..b83e2d7dd70 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/templates/common/new_2fa_code.jinja2 @@ -0,0 +1,21 @@ +{{ code }} is your security code for {{ host }} + +

Your security code for {{ host }}

+ +

+ Hi {{ name }}

+ Sign in to {{ host }} with this one-time security code: +

+ +

+ {{ code }} +

+ +

+ If you did not sign in, please contact {{ support_email }}. +

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 f7e56666565..3df6ec364be 100644 --- a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py +++ b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py @@ -1,15 +1,20 @@ import io -from typing import Any, Callable, Dict, Type +import logging +from typing import Any, Callable, Literal from aiohttp import web from aiohttp.web_exceptions import HTTPError, HTTPException from aiohttp.web_routedef import RouteDef, RouteTableDef from models_library.generics import Envelope from servicelib.json_serialization import json_dumps +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from yarl import URL +from ._constants import INDEX_RESOURCE_NAME from .rest_constants import RESPONSE_MODEL_POLICY +log = logging.getLogger(__name__) + def rename_routes_as_handler_function(routes: RouteTableDef, *, prefix: str): route: RouteDef @@ -28,7 +33,7 @@ def get_routes_view(routes: RouteTableDef) -> str: def create_url_for_function(request: web.Request) -> Callable: app = request.app - def url_for(route_name: str, **params: Dict[str, Any]) -> str: + def url_for(route_name: str, **params: dict[str, Any]) -> str: """Reverse URL constructing using named resources""" try: rel_url: URL = app.router[route_name].url_for( @@ -55,11 +60,9 @@ def url_for(route_name: str, **params: Dict[str, Any]) -> str: def envelope_json_response( - obj: Any, status_cls: Type[HTTPException] = web.HTTPOk + obj: Any, status_cls: type[HTTPException] = web.HTTPOk ) -> web.Response: - # TODO: replace all envelope functionality form packages/service-library/src/servicelib/aiohttp/rest_responses.py - # TODO: Remove middleware to envelope handler responses at packages/service-library/src/servicelib/aiohttp/rest_middlewares.py: envelope_middleware_factory and use instead this - # TODO: review error_middleware_factory + # NOTE: see https://github.com/ITISFoundation/osparc-simcore/issues/3646 if issubclass(status_cls, HTTPError): enveloped = Envelope[Any](error=obj) else: @@ -67,6 +70,35 @@ def envelope_json_response( return web.Response( text=json_dumps(enveloped.dict(**RESPONSE_MODEL_POLICY)), - content_type="application/json", + content_type=MIMETYPE_APPLICATION_JSON, status=status_cls.status_code, ) + + +def create_redirect_response( + app: web.Application, page: Literal["view", "error"], **parameters +) -> web.HTTPFound: + """ + Returns a redirect response to the front-end with information on page + and parameters embedded in the fragment. + + For instance, + https://osparc.io/#/error?message=Sorry%2C%20I%20could%20not%20find%20this%20&status_code=404 + results from + - page=error + and parameters + - message="Sorry, I could not find this" + - status_code=404 + + Front-end can then render this data either in an error or a view page + """ + log.debug("page: '%s' parameters: '%s'", page, parameters) + assert page in ("view", "error") # nosec + + # NOTE: uniform encoding in front-end using url fragments + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/1975 + fragment_path = f"{URL.build(path=f'/{page}').with_query(parameters)}" + redirect_url = ( + app.router[INDEX_RESOURCE_NAME].url_for().with_fragment(fragment_path) + ) + return web.HTTPFound(location=redirect_url) diff --git a/services/web/server/tests/unit/isolated/test_templates.py b/services/web/server/tests/unit/isolated/test_templates.py index bacf8762018..3bfe9d3d297 100644 --- a/services/web/server/tests/unit/isolated/test_templates.py +++ b/services/web/server/tests/unit/isolated/test_templates.py @@ -30,11 +30,14 @@ def app() -> web.Application: ) def test_all_email_templates_include_subject(template_path: Path, app: web.Application): assert template_path.exists() - subject, body = template_path.read_text().split("\n", 1) - assert ( - re.match(r"[a-zA-Z0-9\-_\s]+", subject) or "{{subject}}" in subject + subject, content = template_path.read_text().split("\n", 1) + + assert re.match( + r"[\{\}a-zA-Z0-9\-_\s]+", subject ), f"Template {template_path} must start with a subject line, got {subject}" + assert content + @pytest.mark.skip(reason="DEV") def test_render_string_from_tmp_file( diff --git a/services/web/server/tests/unit/with_dbs/01/test_login_change_password.py b/services/web/server/tests/unit/with_dbs/01/test_login_change_password.py deleted file mode 100644 index 0e06f4c8810..00000000000 --- a/services/web/server/tests/unit/with_dbs/01/test_login_change_password.py +++ /dev/null @@ -1,107 +0,0 @@ -# pylint: disable=redefined-outer-name -# pylint: disable=unused-argument -# pylint: disable=unused-variable - - -import pytest -from aiohttp import web -from aiohttp.test_utils import TestClient -from pytest_simcore.helpers.utils_assert import assert_status -from pytest_simcore.helpers.utils_login import LoggedUser -from simcore_service_webserver.login.settings import LoginOptions, get_plugin_options - -NEW_PASSWORD = "NewPassword1*&^" - - -@pytest.fixture -def cfg(client: TestClient) -> LoginOptions: - cfg = get_plugin_options(client.app) - assert cfg - return cfg - - -async def test_unauthorized_to_change_password(client: TestClient): - url = client.app.router["auth_change_password"].url_for() - rsp = await client.post( - f"{url}", - json={ - "current": " fake", - "new": NEW_PASSWORD, - "confirm": NEW_PASSWORD, - }, - ) - assert rsp.status == 401 - await assert_status(rsp, web.HTTPUnauthorized) - - -async def test_wrong_current_password(client: TestClient, cfg: LoginOptions): - url = client.app.router["auth_change_password"].url_for() - - async with LoggedUser(client): - rsp = await client.post( - f"{url}", - json={ - "current": "wrongpassword", - "new": NEW_PASSWORD, - "confirm": NEW_PASSWORD, - }, - ) - assert rsp.url.path == url.path - assert rsp.status == 422 - assert cfg.MSG_WRONG_PASSWORD in await rsp.text() - await assert_status(rsp, web.HTTPUnprocessableEntity, cfg.MSG_WRONG_PASSWORD) - - -async def test_wrong_confirm_pass(client: TestClient, cfg: LoginOptions): - url = client.app.router["auth_change_password"].url_for() - - async with LoggedUser(client) as user: - rsp = await client.post( - f"{url}", - json={ - "current": user["raw_password"], - "new": NEW_PASSWORD, - "confirm": NEW_PASSWORD.upper(), - }, - ) - assert rsp.url.path == url.path - assert rsp.status == 409 - await assert_status(rsp, web.HTTPConflict, cfg.MSG_PASSWORD_MISMATCH) - - -async def test_success(client: TestClient, cfg: LoginOptions): - url_change_password = client.app.router["auth_change_password"].url_for() - url_login = client.app.router["auth_login"].url_for() - url_logout = client.app.router["auth_logout"].url_for() - - async with LoggedUser(client) as user: - # change password - rsp = await client.post( - f"{url_change_password}", - json={ - "current": user["raw_password"], - "new": NEW_PASSWORD, - "confirm": NEW_PASSWORD, - }, - ) - assert rsp.url.path == url_change_password.path - assert rsp.status == 200 - assert cfg.MSG_PASSWORD_CHANGED in await rsp.text() - await assert_status(rsp, web.HTTPOk, cfg.MSG_PASSWORD_CHANGED) - - # logout - rsp = await client.post(f"{url_logout}") - assert rsp.status == 200 - assert rsp.url.path == url_logout.path - - # login with new password - rsp = await client.post( - f"{url_login}", - json={ - "email": user["email"], - "password": NEW_PASSWORD, - }, - ) - assert rsp.status == 200 - assert rsp.url.path == url_login.path - await assert_status(rsp, web.HTTPOk, cfg.MSG_LOGGED_IN) 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 new file mode 100644 index 00000000000..9cb55f94865 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/login/conftest.py @@ -0,0 +1,50 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import pytest +from aiohttp.test_utils import TestClient +from faker import Faker +from simcore_service_webserver.login.settings import LoginOptions, get_plugin_options +from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage + + +@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: + 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 db(client: TestClient) -> AsyncpgStorage: + """login database repository instance""" + assert client.app + db: AsyncpgStorage = get_plugin_storage(client.app) + assert db + return db + + +@pytest.fixture +def login_options(client: TestClient) -> LoginOptions: + """app's login options""" + assert client.app + cfg: LoginOptions = get_plugin_options(client.app) + assert cfg + return cfg diff --git a/services/web/server/tests/unit/with_dbs/03/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py similarity index 65% rename from services/web/server/tests/unit/with_dbs/03/test_login_2fa.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 17bd286a349..0cf064959bd 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -8,24 +8,24 @@ import pytest import sqlalchemy as sa from aiohttp import web -from aiohttp.test_utils import TestClient -from pytest import MonkeyPatch +from aiohttp.test_utils import TestClient, make_mocked_request +from faker import Faker +from pytest import CaptureFixture, MonkeyPatch from pytest_simcore.helpers import utils_login from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_dict import ConfigDict from pytest_simcore.helpers.utils_envs import setenvs_from_dict -from pytest_simcore.helpers.utils_login import parse_link +from pytest_simcore.helpers.utils_login import parse_link, parse_test_marks from simcore_postgres_database.models.products import products from simcore_service_webserver.db_models import UserStatus from simcore_service_webserver.login._2fa import ( + _generage_2fa_code, + create_2fa_code, delete_2fa_code, get_2fa_code, - set_2fa_code, + send_email_code, ) -from simcore_service_webserver.login.settings import LoginOptions, get_plugin_options -from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage - -EMAIL, PASSWORD, PHONE = "tester@test.com", "password", "+12345678912" +from simcore_service_webserver.login.storage import AsyncpgStorage @pytest.fixture @@ -71,22 +71,6 @@ def postgres_db(postgres_db: sa.engine.Engine): return postgres_db -@pytest.fixture -def cfg(client: TestClient) -> LoginOptions: - assert client.app - cfg = get_plugin_options(client.app) - assert cfg - return cfg - - -@pytest.fixture -def db(client: TestClient) -> AsyncpgStorage: - assert client.app - db: AsyncpgStorage = get_plugin_storage(client.app) - assert db - return db - - @pytest.fixture def mocked_twilio_service(mocker) -> dict[str, Mock]: return { @@ -112,13 +96,13 @@ async def test_2fa_code_operations( # set/get/delete email = "foo@bar.com" - code = await set_2fa_code(client.app, email) + code = await create_2fa_code(client.app, email) assert await get_2fa_code(client.app, email) == code await delete_2fa_code(client.app, email) # expired email = "expired@bar.com" - code = await set_2fa_code(client.app, email, expiration_time=1) + code = await create_2fa_code(client.app, email, expiration_time=1) await asyncio.sleep(1.5) assert await get_2fa_code(client.app, email) is None @@ -127,7 +111,10 @@ async def test_2fa_code_operations( async def test_workflow_register_and_login_with_2fa( client: TestClient, db: AsyncpgStorage, - capsys, + capsys: CaptureFixture, + fake_user_email: str, + fake_user_password: str, + fake_user_phone_number: str, mocked_twilio_service: dict[str, Mock], ): assert client.app @@ -136,15 +123,15 @@ async def test_workflow_register_and_login_with_2fa( # 1. submit url = client.app.router["auth_register"].url_for() - rsp = await client.post( + response = await client.post( f"{url}", json={ - "email": EMAIL, - "password": PASSWORD, - "confirm": PASSWORD, + "email": fake_user_email, + "password": fake_user_password, + "confirm": fake_user_password, }, ) - await assert_status(rsp, web.HTTPOk) + await assert_status(response, web.HTTPOk) # check email was sent def _get_confirmation_link_from_email(): @@ -156,11 +143,11 @@ def _get_confirmation_link_from_email(): url = _get_confirmation_link_from_email() # 2. confirmation - rsp = await client.get(url) - assert rsp.status == web.HTTPOk.status_code + response = await client.get(url) + assert response.status == web.HTTPOk.status_code # check email+password registered - user = await db.get_user({"email": EMAIL}) + user = await db.get_user({"email": fake_user_email}) assert user["status"] == UserStatus.ACTIVE.name assert user["phone"] is None @@ -168,84 +155,85 @@ def _get_confirmation_link_from_email(): # 1. submit url = client.app.router["auth_verify_2fa_phone"].url_for() - rsp = await client.post( + response = await client.post( f"{url}", json={ - "email": EMAIL, - "phone": PHONE, + "email": fake_user_email, + "phone": fake_user_phone_number, }, ) - await assert_status(rsp, web.HTTPAccepted) + await assert_status(response, web.HTTPAccepted) # check code generated and SMS sent 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 == PHONE + assert phone == fake_user_phone_number # check phone still NOT in db (TODO: should be in database and unconfirmed) - user = await db.get_user({"email": EMAIL}) + user = await db.get_user({"email": fake_user_email}) assert user["status"] == UserStatus.ACTIVE.name assert user["phone"] is None # 2. confirmation url = client.app.router["auth_validate_2fa_register"].url_for() - rsp = await client.post( + response = await client.post( f"{url}", json={ - "email": EMAIL, - "phone": PHONE, + "email": fake_user_email, + "phone": fake_user_phone_number, "code": received_code, }, ) - await assert_status(rsp, web.HTTPOk) + await assert_status(response, web.HTTPOk) # check user has phone confirmed - user = await db.get_user({"email": EMAIL}) + user = await db.get_user({"email": fake_user_email}) assert user["status"] == UserStatus.ACTIVE.name - assert user["phone"] == PHONE + assert user["phone"] == fake_user_phone_number # login --------------------------------------------------------- # 1. check email/password then send SMS url = client.app.router["auth_login"].url_for() - rsp = await client.post( + response = await client.post( f"{url}", json={ - "email": EMAIL, - "password": PASSWORD, - "confirm": PASSWORD, + "email": fake_user_email, + "password": fake_user_password, }, ) - data, _ = await assert_status(rsp, web.HTTPAccepted) + data, _ = await assert_status(response, web.HTTPAccepted) assert data["code"] == "SMS_CODE_REQUIRED" # 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 == PHONE + assert phone == fake_user_phone_number # 2. check SMS code - url = client.app.router["auth_validate_2fa_login"].url_for() - rsp = await client.post( + url = client.app.router["auth_login_2fa"].url_for() + response = await client.post( f"{url}", json={ - "email": EMAIL, + "email": fake_user_email, "code": received_code, }, ) - await assert_status(rsp, web.HTTPOk) + await assert_status(response, web.HTTPOk) # assert users is successfully registered - user = await db.get_user({"email": EMAIL}) - assert user["email"] == EMAIL - assert user["phone"] == PHONE + user = await db.get_user({"email": fake_user_email}) + assert user["email"] == fake_user_email + assert user["phone"] == fake_user_phone_number assert user["status"] == UserStatus.ACTIVE.value async def test_register_phone_fails_with_used_number( client: TestClient, db: AsyncpgStorage, + fake_user_email: str, + fake_user_phone_number: str, ): """ Tests https://github.com/ITISFoundation/osparc-simcore/issues/3304 @@ -253,17 +241,42 @@ async def test_register_phone_fails_with_used_number( assert client.app # some user ALREADY registered with the same phone - await utils_login.create_fake_user(db, data={"phone": PHONE}) + await utils_login.create_fake_user(db, data={"phone": fake_user_phone_number}) # new registration with same phone # 1. submit url = client.app.router["auth_verify_2fa_phone"].url_for() - rsp = await client.post( + response = await client.post( f"{url}", json={ - "email": EMAIL, - "phone": PHONE, + "email": fake_user_email, + "phone": fake_user_phone_number, }, ) - _, error = await assert_status(rsp, web.HTTPUnauthorized) + _, error = await assert_status(response, web.HTTPUnauthorized) assert "phone" in error["message"] + + +async def test_send_email_code( + client: TestClient, faker: Faker, capsys: CaptureFixture +): + request = make_mocked_request("GET", "/dummy", app=client.app) + + user_email = faker.email() + support_email = faker.email() + code = _generage_2fa_code() + user_name = faker.user_name() + + await send_email_code( + request, + user_email=user_email, + support_email=support_email, + code=code, + user_name=user_name, + ) + + out, _ = capsys.readouterr() + parsed_context = parse_test_marks(out) + assert parsed_context["code"] == f"{code}" + assert parsed_context["name"] == user_name.capitalize() + assert parsed_context["support_email"] == support_email diff --git a/services/web/server/tests/unit/with_dbs/01/test_login_change_email.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py similarity index 50% rename from services/web/server/tests/unit/with_dbs/01/test_login_change_email.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py index 3015a9ab0bf..25aee079a5e 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_login_change_email.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py @@ -5,96 +5,106 @@ import pytest from aiohttp import web from aiohttp.test_utils import TestClient +from pytest import CaptureFixture from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_login import LoggedUser, NewUser, parse_link from simcore_service_webserver._constants import INDEX_RESOURCE_NAME -from simcore_service_webserver.login.settings import LoginOptions, get_plugin_options +from simcore_service_webserver.login._constants import ( + MSG_CHANGE_EMAIL_REQUESTED, + MSG_LOGGED_IN, + MSG_LOGGED_OUT, +) +from simcore_service_webserver.login.settings import LoginOptions from yarl import URL -NEW_EMAIL = "new@mail.com" - @pytest.fixture -def cfg(client: TestClient) -> LoginOptions: - cfg = get_plugin_options(client.app) - assert cfg - return cfg +def new_email(fake_user_email: str) -> str: + return fake_user_email -async def test_unauthorized_to_change_email(client: TestClient): +async def test_unauthorized_to_change_email(client: TestClient, new_email: str): + assert client.app url = client.app.router["auth_change_email"].url_for() - rsp = await client.post( - url, + response = await client.post( + f"{url}", json={ - "email": NEW_EMAIL, + "email": new_email, }, ) - assert rsp.status == 401 - await assert_status(rsp, web.HTTPUnauthorized) + assert response.status == 401 + await assert_status(response, web.HTTPUnauthorized) async def test_change_to_existing_email(client: TestClient): + assert client.app url = client.app.router["auth_change_email"].url_for() async with LoggedUser(client) as user: async with NewUser(app=client.app) as other: - rsp = await client.post( - url, + response = await client.post( + f"{url}", json={ "email": other["email"], }, ) await assert_status( - rsp, web.HTTPUnprocessableEntity, "This email cannot be used" + response, web.HTTPUnprocessableEntity, "This email cannot be used" ) -async def test_change_and_confirm(client: TestClient, cfg: LoginOptions, capsys): +async def test_change_and_confirm( + client: TestClient, + login_options: LoginOptions, + capsys: CaptureFixture, + new_email: str, +): + assert client.app url = client.app.router["auth_change_email"].url_for() index_url = client.app.router[INDEX_RESOURCE_NAME].url_for() login_url = client.app.router["auth_login"].url_for() logout_url = client.app.router["auth_logout"].url_for() - assert index_url.path == URL(cfg.LOGIN_REDIRECT).path + assert index_url.path == URL(login_options.LOGIN_REDIRECT).path async with LoggedUser(client) as user: # request change email - rsp = await client.post( - url, + response = await client.post( + f"{url}", json={ - "email": NEW_EMAIL, + "email": new_email, }, ) - assert rsp.url.path == url.path - await assert_status(rsp, web.HTTPOk, cfg.MSG_CHANGE_EMAIL_REQUESTED) + assert response.url.path == url.path + await assert_status(response, web.HTTPOk, MSG_CHANGE_EMAIL_REQUESTED) # email sent out, err = capsys.readouterr() link = parse_link(out) # try new email but logout first - rsp = await client.post(logout_url) - assert rsp.url.path == logout_url.path - await assert_status(rsp, web.HTTPOk, cfg.MSG_LOGGED_OUT) + response = await client.post(f"{logout_url}") + assert response.url.path == logout_url.path + await assert_status(response, web.HTTPOk, MSG_LOGGED_OUT) # click email's link - rsp = await client.get(link) - txt = await rsp.text() + response = await client.get(link) + txt = await response.text() - assert rsp.url.path == index_url.path + assert response.url.path == index_url.path assert ( "This is a result of disable_static_webserver fixture for product OSPARC" in txt ) - rsp = await client.post( - login_url, + response = await client.post( + f"{login_url}", json={ - "email": NEW_EMAIL, + "email": new_email, "password": user["raw_password"], }, ) - payload = await rsp.json() - assert rsp.url.path == login_url.path - await assert_status(rsp, web.HTTPOk, cfg.MSG_LOGGED_IN) + payload = await response.json() + assert response.url.path == login_url.path + await assert_status(response, web.HTTPOk, MSG_LOGGED_IN) 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 new file mode 100644 index 00000000000..6b29f87c56d --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py @@ -0,0 +1,130 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.utils_assert import assert_status +from pytest_simcore.helpers.utils_login import LoggedUser +from servicelib.aiohttp.rest_responses import unwrap_envelope +from simcore_service_webserver.login._constants import ( + MSG_LOGGED_IN, + MSG_PASSWORD_CHANGED, + MSG_PASSWORD_MISMATCH, + MSG_WRONG_PASSWORD, +) +from simcore_service_webserver.login.settings import LoginOptions + + +@pytest.fixture +def new_password(fake_user_password: str) -> str: + return fake_user_password + + +async def test_unauthorized_to_change_password(client: TestClient, new_password: str): + assert client.app + url = client.app.router["auth_change_password"].url_for() + response = await client.post( + f"{url}", + json={ + "current": " fake", + "new": new_password, + "confirm": new_password, + }, + ) + assert response.status == 401 + await assert_status(response, web.HTTPUnauthorized) + + +async def test_wrong_current_password( + client: TestClient, login_options: LoginOptions, new_password: str +): + assert client.app + url = client.app.router["auth_change_password"].url_for() + + async with LoggedUser(client): + response = await client.post( + f"{url}", + json={ + "current": "wrongpassword", + "new": new_password, + "confirm": new_password, + }, + ) + assert response.url.path == url.path + assert response.status == 422 + assert MSG_WRONG_PASSWORD in await response.text() + await assert_status(response, web.HTTPUnprocessableEntity, MSG_WRONG_PASSWORD) + + +async def test_wrong_confirm_pass(client: TestClient, new_password: str): + assert client.app + url = client.app.router["auth_change_password"].url_for() + + async with LoggedUser(client) as user: + response = await client.post( + f"{url}", + json={ + "current": user["raw_password"], + "new": new_password, + "confirm": new_password.upper(), + }, + ) + assert response.url.path == url.path + assert response.status == web.HTTPUnprocessableEntity.status_code + + data, error = unwrap_envelope(await response.json()) + + assert data is None + assert error == { + "status": 422, + "errors": [ + { + "code": "value_error", + "message": MSG_PASSWORD_MISMATCH, + "resource": "/v0/auth/change-password", + "field": "confirm", + } + ], + } + + +async def test_success(client: TestClient, new_password: str): + assert client.app + url_change_password = client.app.router["auth_change_password"].url_for() + url_login = client.app.router["auth_login"].url_for() + url_logout = client.app.router["auth_logout"].url_for() + + async with LoggedUser(client) as user: + # change password + response = await client.post( + f"{url_change_password}", + json={ + "current": user["raw_password"], + "new": new_password, + "confirm": new_password, + }, + ) + assert response.url.path == url_change_password.path + assert response.status == 200 + assert MSG_PASSWORD_CHANGED in await response.text() + await assert_status(response, web.HTTPOk, MSG_PASSWORD_CHANGED) + + # logout + response = await client.post(f"{url_logout}") + assert response.status == 200 + assert response.url.path == url_logout.path + + # login with new password + response = await client.post( + f"{url_login}", + json={ + "email": user["email"], + "password": new_password, + }, + ) + assert response.status == 200 + assert response.url.path == url_login.path + await assert_status(response, web.HTTPOk, MSG_LOGGED_IN) diff --git a/services/web/server/tests/unit/with_dbs/01/test_login_login.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_login.py similarity index 77% rename from services/web/server/tests/unit/with_dbs/01/test_login_login.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_login.py index 07ca69c1e5a..6c10f74d012 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_login_login.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_login.py @@ -14,18 +14,16 @@ 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.settings import LoginOptions, get_plugin_options +from simcore_service_webserver.login._constants import ( + MSG_ACTIVATION_REQUIRED, + MSG_LOGGED_IN, + MSG_UNKNOWN_EMAIL, + MSG_USER_BANNED, + MSG_USER_EXPIRED, + MSG_WRONG_PASSWORD, +) from simcore_service_webserver.session_settings import get_plugin_settings -EMAIL, PASSWORD = "tester@test.com", "password" - - -@pytest.fixture -def login_options(client: TestClient) -> LoginOptions: - assert client.app - options: LoginOptions = get_plugin_options(client.app) - return options - def test_login_plugin_setup_succeeded(client: TestClient): assert client.app @@ -36,9 +34,7 @@ def test_login_plugin_setup_succeeded(client: TestClient): assert settings -async def test_login_with_unknown_email( - client: TestClient, login_options: LoginOptions -): +async def test_login_with_unknown_email(client: TestClient): assert client.app url = client.app.router["auth_login"].url_for() r = await client.post( @@ -48,19 +44,17 @@ async def test_login_with_unknown_email( assert r.status == web.HTTPUnauthorized.status_code, str(payload) assert r.url.path == url.path - assert login_options.MSG_UNKNOWN_EMAIL in await r.text() + assert MSG_UNKNOWN_EMAIL in await r.text() -async def test_login_with_wrong_password( - client: TestClient, login_options: LoginOptions -): +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}") payload = await r.json() - assert login_options.MSG_WRONG_PASSWORD not in await r.text(), str(payload) + assert MSG_WRONG_PASSWORD not in await r.text(), str(payload) async with NewUser(app=client.app) as user: r = await client.post( @@ -73,15 +67,16 @@ async def test_login_with_wrong_password( payload = await r.json() assert r.status == web.HTTPUnauthorized.status_code, str(payload) assert r.url.path == url.path - assert login_options.MSG_WRONG_PASSWORD in await r.text() + assert MSG_WRONG_PASSWORD in await r.text() -@pytest.mark.parametrize("user_status", (UserStatus.BANNED, UserStatus.EXPIRED)) +@pytest.mark.parametrize( + "user_status,expected_msg", + ((UserStatus.BANNED, MSG_USER_BANNED), (UserStatus.EXPIRED, MSG_USER_EXPIRED)), +) async def test_login_blocked_user( - client: TestClient, login_options: LoginOptions, user_status: UserStatus + client: TestClient, user_status: UserStatus, expected_msg: str ): - expected_msg: str = getattr(login_options, f"MSG_USER_{user_status.name.upper()}") - assert client.app url = client.app.router["auth_login"].url_for() r = await client.post(f"{url}") @@ -99,11 +94,11 @@ async def test_login_blocked_user( assert expected_msg[:-20] in payload["error"]["errors"][0]["message"] -async def test_login_inactive_user(client: TestClient, login_options: LoginOptions): +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}") - assert login_options.MSG_ACTIVATION_REQUIRED not in await r.text() + assert MSG_ACTIVATION_REQUIRED not in await r.text() async with NewUser( {"status": UserStatus.CONFIRMATION_PENDING.name}, app=client.app @@ -113,10 +108,10 @@ async def test_login_inactive_user(client: TestClient, login_options: LoginOptio ) assert r.status == web.HTTPUnauthorized.status_code assert r.url.path == url.path - assert login_options.MSG_ACTIVATION_REQUIRED in await r.text() + assert MSG_ACTIVATION_REQUIRED in await r.text() -async def test_login_successfully(client: TestClient, login_options: LoginOptions): +async def test_login_successfully(client: TestClient): assert client.app url = client.app.router["auth_login"].url_for() @@ -129,14 +124,14 @@ async def test_login_successfully(client: TestClient, login_options: LoginOption assert not error assert data - assert login_options.MSG_LOGGED_IN in data["message"] + assert MSG_LOGGED_IN in data["message"] @pytest.mark.parametrize( "cookie_enabled,expected", [(True, web.HTTPOk), (False, web.HTTPUnauthorized)] ) async def test_proxy_login( - client: TestClient, cookie_enabled: bool, expected: web.HTTPException + client: TestClient, cookie_enabled: bool, expected: type[web.HTTPException] ): assert client.app restricted_url = client.app.router["get_my_profile"].url_for() diff --git a/services/web/server/tests/unit/with_dbs/01/test_login_logout.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_logout.py similarity index 53% rename from services/web/server/tests/unit/with_dbs/01/test_login_logout.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_logout.py index 5ce0681045a..21b6406641c 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_login_logout.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_logout.py @@ -2,22 +2,15 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable -import pytest from aiohttp import web from aiohttp.test_utils import TestClient from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_login import LoggedUser -from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage - - -@pytest.fixture -def db(client: TestClient) -> AsyncpgStorage: - db: AsyncpgStorage = get_plugin_storage(client.app) - assert db - return db +from simcore_service_webserver.login.storage import AsyncpgStorage async def test_logout(client: TestClient, db: AsyncpgStorage): + assert client.app logout_url = client.app.router["auth_logout"].url_for() protected_url = client.app.router["auth_change_email"].url_for() @@ -25,18 +18,18 @@ async def test_logout(client: TestClient, db: AsyncpgStorage): async with LoggedUser(client) as user: # try to access protected page - r = await client.post(protected_url, json={"email": user["email"]}) - assert r.url.path == protected_url.path - await assert_status(r, web.HTTPOk) + response = await client.post(f"{protected_url}", json={"email": user["email"]}) + assert response.url.path == protected_url.path + await assert_status(response, web.HTTPOk) # logout - r = await client.post(logout_url) - assert r.url.path == logout_url.path - await assert_status(r, web.HTTPOk) + response = await client.post(f"{logout_url}") + assert response.url.path == logout_url.path + await assert_status(response, web.HTTPOk) # and try again - r = await client.post(protected_url) - assert r.url.path == protected_url.path - await assert_status(r, web.HTTPUnauthorized) + response = await client.post(f"{protected_url}") + assert response.url.path == protected_url.path + await assert_status(response, web.HTTPUnauthorized) await db.delete_user(user) diff --git a/services/web/server/tests/unit/with_dbs/03/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py similarity index 72% rename from services/web/server/tests/unit/with_dbs/03/test_login_registration.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py index a0fbcbd5ef8..d8070c81325 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -10,26 +10,26 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer from faker import Faker +from pytest import CaptureFixture from pytest_mock import MockerFixture from pytest_simcore.helpers.utils_assert import assert_error, assert_status from pytest_simcore.helpers.utils_login import NewInvitation, NewUser, parse_link from servicelib.aiohttp.rest_responses import unwrap_envelope from simcore_service_webserver.db_models import ConfirmationAction, UserStatus from simcore_service_webserver.login._confirmation import _url_for_confirmation +from simcore_service_webserver.login._constants import ( + MSG_EMAIL_EXISTS, + MSG_LOGGED_IN, + MSG_PASSWORD_MISMATCH, +) from simcore_service_webserver.login._registration import ( InvitationData, get_confirmation_info, ) -from simcore_service_webserver.login.settings import ( - LoginOptions, - LoginSettings, - get_plugin_options, -) -from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage +from simcore_service_webserver.login.settings import LoginOptions, LoginSettings +from simcore_service_webserver.login.storage import AsyncpgStorage from simcore_service_webserver.users_models import ProfileGet -EMAIL, PASSWORD = "tester@test.com", "password" - @pytest.fixture def client( @@ -38,39 +38,62 @@ def client( web_server: TestServer, mock_orphaned_services, ) -> TestClient: - cli = event_loop.run_until_complete(aiohttp_client(web_server)) - return cli + client_ = event_loop.run_until_complete(aiohttp_client(web_server)) + return client_ -@pytest.fixture -def cfg(client: TestClient) -> LoginOptions: +async def test_regiter_entrypoint( + client: TestClient, fake_user_email: str, fake_user_password: str +): assert client.app - cfg = get_plugin_options(client.app) - assert cfg - return cfg - + url = client.app.router["auth_register"].url_for() + response = await client.post( + f"{url}", + json={ + "email": fake_user_email, + "password": fake_user_password, + "confirm": fake_user_password, + }, + ) -@pytest.fixture -def db(client: TestClient) -> AsyncpgStorage: - assert client.app - db: AsyncpgStorage = get_plugin_storage(client.app) - assert db - return db + data, _ = await assert_status(response, web.HTTPOk) + assert fake_user_email in data["message"] -async def test_regitration_availibility(client: TestClient): +async def test_register_body_validation(client: TestClient, fake_user_password: str): assert client.app url = client.app.router["auth_register"].url_for() - r = await client.post( + response = await client.post( f"{url}", json={ - "email": EMAIL, - "password": PASSWORD, - "confirm": PASSWORD, + "email": "not-an-email", + "password": fake_user_password, + "confirm": fake_user_password.upper(), }, ) - await assert_status(r, web.HTTPOk) + assert response.status == web.HTTPUnprocessableEntity.status_code + body = await response.json() + data, error = unwrap_envelope(body) + + assert data is None + assert error == { + "status": 422, + "errors": [ + { + "code": "value_error.email", + "message": "value is not a valid email address", + "resource": "/v0/auth/register", + "field": "email", + }, + { + "code": "value_error", + "message": MSG_PASSWORD_MISMATCH, + "resource": "/v0/auth/register", + "field": "confirm", + }, + ], + } async def test_regitration_is_not_get(client: TestClient): @@ -80,7 +103,9 @@ async def test_regitration_is_not_get(client: TestClient): await assert_error(r, web.HTTPMethodNotAllowed) -async def test_registration_with_existing_email(client: TestClient, cfg: LoginOptions): +async def test_registration_with_existing_email( + client: TestClient, login_options: LoginOptions +): assert client.app url = client.app.router["auth_register"].url_for() async with NewUser(app=client.app) as user: @@ -92,13 +117,13 @@ async def test_registration_with_existing_email(client: TestClient, cfg: LoginOp "confirm": user["raw_password"], }, ) - await assert_error(r, web.HTTPConflict, cfg.MSG_EMAIL_EXISTS) + await assert_error(r, web.HTTPConflict, MSG_EMAIL_EXISTS) @pytest.mark.skip("TODO: Feature still not implemented") async def test_registration_with_expired_confirmation( client: TestClient, - cfg: LoginOptions, + login_options: LoginOptions, db: AsyncpgStorage, mocker: MockerFixture, ): @@ -129,12 +154,12 @@ async def test_registration_with_expired_confirmation( ) await db.delete_confirmation(confirmation) - await assert_error(r, web.HTTPConflict, cfg.MSG_EMAIL_EXISTS) + await assert_error(r, web.HTTPConflict, MSG_EMAIL_EXISTS) async def test_registration_with_invalid_confirmation_code( client: TestClient, - cfg: LoginOptions, + login_options: LoginOptions, db: AsyncpgStorage, mocker: MockerFixture, ): @@ -158,15 +183,17 @@ async def test_registration_with_invalid_confirmation_code( # Invalid code redirect to root without any error to the login page # assert r.ok - assert f"{r.url.relative()}" == cfg.LOGIN_REDIRECT + assert f"{r.url.relative()}" == login_options.LOGIN_REDIRECT assert r.history[0].status == web.HTTPFound.status_code async def test_registration_without_confirmation( client: TestClient, - cfg: LoginOptions, + login_options: LoginOptions, db: AsyncpgStorage, mocker: MockerFixture, + fake_user_email: str, + fake_user_password: str, ): assert client.app mocker.patch( @@ -181,24 +208,31 @@ async def test_registration_without_confirmation( url = client.app.router["auth_register"].url_for() r = await client.post( - f"{url}", json={"email": EMAIL, "password": PASSWORD, "confirm": PASSWORD} + f"{url}", + json={ + "email": fake_user_email, + "password": fake_user_password, + "confirm": fake_user_password, + }, ) data, error = unwrap_envelope(await r.json()) assert r.status == 200, (data, error) - assert cfg.MSG_LOGGED_IN in data["message"] + assert MSG_LOGGED_IN in data["message"] - user = await db.get_user({"email": EMAIL}) + user = await db.get_user({"email": fake_user_email}) assert user await db.delete_user(user) async def test_registration_with_confirmation( client: TestClient, - cfg: LoginOptions, + login_options: LoginOptions, db: AsyncpgStorage, - capsys, - mocker, + capsys: CaptureFixture, + mocker: MockerFixture, + fake_user_email: str, + fake_user_password: str, ): assert client.app mocker.patch( @@ -213,12 +247,17 @@ async def test_registration_with_confirmation( url = client.app.router["auth_register"].url_for() r = await client.post( - f"{url}", json={"email": EMAIL, "password": PASSWORD, "confirm": PASSWORD} + f"{url}", + json={ + "email": fake_user_email, + "password": fake_user_password, + "confirm": fake_user_password, + }, ) data, error = unwrap_envelope(await r.json()) assert r.status == 200, (data, error) - user = await db.get_user({"email": EMAIL}) + user = await db.get_user({"email": fake_user_email}) assert user["status"] == UserStatus.CONFIRMATION_PENDING.name assert "verification link" in data["message"] @@ -237,7 +276,7 @@ async def test_registration_with_confirmation( assert resp.status == 200 # user is active - user = await db.get_user({"email": EMAIL}) + user = await db.get_user({"email": fake_user_email}) assert user["status"] == UserStatus.ACTIVE.name # cleanup @@ -255,12 +294,14 @@ async def test_registration_with_confirmation( ) async def test_registration_with_invitation( client: TestClient, - cfg: LoginOptions, + login_options: LoginOptions, db: AsyncpgStorage, is_invitation_required: bool, has_valid_invitation: bool, expected_response: type[web.HTTPError], mocker: MockerFixture, + fake_user_email: str, + fake_user_password: str, ): assert client.app mocker.patch( @@ -283,16 +324,16 @@ async def test_registration_with_invitation( confirmation = f.confirmation assert confirmation - print(get_confirmation_info(cfg, confirmation)) + print(get_confirmation_info(login_options, confirmation)) url = client.app.router["auth_register"].url_for() r = await client.post( f"{url}", json={ - "email": EMAIL, - "password": PASSWORD, - "confirm": PASSWORD, + "email": fake_user_email, + "password": fake_user_password, + "confirm": fake_user_password, "invitation": confirmation["code"] if has_valid_invitation else "WRONG_CODE", @@ -303,7 +344,11 @@ async def test_registration_with_invitation( # check optional fields in body if not has_valid_invitation and not is_invitation_required: r = await client.post( - f"{url}", json={"email": "new-user" + EMAIL, "password": PASSWORD} + f"{url}", + json={ + "email": "new-user" + fake_user_email, + "password": fake_user_password, + }, ) await assert_status(r, expected_response) @@ -313,9 +358,11 @@ async def test_registration_with_invitation( async def test_registraton_with_invitation_for_trial_account( client: TestClient, - cfg: LoginOptions, + login_options: LoginOptions, faker: Faker, mocker: MockerFixture, + fake_user_email: str, + fake_user_password: str, ): assert client.app mocker.patch( @@ -350,7 +397,7 @@ async def test_registraton_with_invitation_for_trial_account( assert invitation.confirmation # checks that invitation is correct - info = get_confirmation_info(cfg, invitation.confirmation) + info = get_confirmation_info(login_options, invitation.confirmation) print(info) assert info["data"] assert isinstance(info["data"], InvitationData) @@ -360,9 +407,9 @@ async def test_registraton_with_invitation_for_trial_account( r = await client.post( f"{url}", json={ - "email": EMAIL, - "password": PASSWORD, - "confirm": PASSWORD, + "email": fake_user_email, + "password": fake_user_password, + "confirm": fake_user_password, "invitation": invitation.confirmation["code"], }, ) @@ -373,8 +420,8 @@ async def test_registraton_with_invitation_for_trial_account( r = await client.post( f"{url}", json={ - "email": EMAIL, - "password": PASSWORD, + "email": fake_user_email, + "password": fake_user_password, }, ) await assert_status(r, web.HTTPOk) diff --git a/services/web/server/tests/unit/with_dbs/03/test_login_reset_password.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py similarity index 59% rename from services/web/server/tests/unit/with_dbs/03/test_login_reset_password.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py index 8a09c20463d..4e1d823ae1e 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_login_reset_password.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py @@ -7,16 +7,25 @@ import pytest from aiohttp import web from aiohttp.test_utils import TestClient, TestServer +from pytest import CaptureFixture from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_login import NewUser, parse_link, parse_test_marks from simcore_service_webserver.db_models import ConfirmationAction, UserStatus -from simcore_service_webserver.login.settings import LoginOptions, get_plugin_options -from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage +from simcore_service_webserver.login._constants import ( + MSG_ACTIVATION_REQUIRED, + MSG_EMAIL_SENT, + MSG_LOGGED_IN, + MSG_OFTEN_RESET_PASSWORD, + MSG_PASSWORD_CHANGED, + MSG_UNKNOWN_EMAIL, + MSG_USER_BANNED, + MSG_USER_EXPIRED, +) +from simcore_service_webserver.login.settings import LoginOptions +from simcore_service_webserver.login.storage import AsyncpgStorage from simcore_service_webserver.login.utils import get_random_string from yarl import URL -EMAIL, PASSWORD = "tester@test.com", "password" - # # NOTE: theses tests are hitting a 'global_rate_limit_route' decorated entrypoint: 'auth_reset_password' # and might fail with 'HTTPTooManyRequests' error. @@ -38,93 +47,85 @@ def client( return cli -@pytest.fixture -def cfg(client: TestClient) -> LoginOptions: - assert client.app - cfg = get_plugin_options(client.app) - assert cfg - return cfg - - -@pytest.fixture -def db(client: TestClient) -> AsyncpgStorage: - assert client.app - db: AsyncpgStorage = get_plugin_storage(client.app) - assert db - return db - - async def test_unknown_email( client: TestClient, - cfg: LoginOptions, - capsys, + capsys: CaptureFixture, + fake_user_email: str, ): assert client.app reset_url = client.app.router["auth_reset_password"].url_for() - rp = await client.post( + response = await client.post( f"{reset_url}", json={ - "email": EMAIL, + "email": fake_user_email, }, ) - payload = await rp.text() + payload = await response.text() - assert rp.url.path == reset_url.path - await assert_status(rp, web.HTTPOk, cfg.MSG_EMAIL_SENT.format(email=EMAIL)) + assert response.url.path == reset_url.path + await assert_status( + response, web.HTTPOk, MSG_EMAIL_SENT.format(email=fake_user_email) + ) - out, err = capsys.readouterr() - assert parse_test_marks(out)["reason"] == cfg.MSG_UNKNOWN_EMAIL + out, _ = capsys.readouterr() + assert parse_test_marks(out)["reason"] == MSG_UNKNOWN_EMAIL -@pytest.mark.parametrize("user_status", (UserStatus.BANNED, UserStatus.EXPIRED)) +@pytest.mark.parametrize( + "user_status,expected_msg", + ((UserStatus.BANNED, MSG_USER_BANNED), (UserStatus.EXPIRED, MSG_USER_EXPIRED)), +) async def test_blocked_user( - client: TestClient, cfg: LoginOptions, capsys, user_status: UserStatus + client: TestClient, + capsys: CaptureFixture, + user_status: UserStatus, + expected_msg: str, ): assert client.app reset_url = client.app.router["auth_reset_password"].url_for() - expected_msg: str = getattr(cfg, f"MSG_USER_{user_status.name.upper()}") - async with NewUser({"status": user_status.name}, app=client.app) as user: - rp = await client.post( + response = await client.post( f"{reset_url}", json={ "email": user["email"], }, ) - assert rp.url.path == reset_url.path - await assert_status(rp, web.HTTPOk, cfg.MSG_EMAIL_SENT.format(**user)) + assert response.url.path == reset_url.path + await assert_status(response, web.HTTPOk, MSG_EMAIL_SENT.format(**user)) out, _ = capsys.readouterr() # expected_msg contains {support_email} at the end of the string assert expected_msg[:-20] in parse_test_marks(out)["reason"] -async def test_inactive_user(client: TestClient, cfg: LoginOptions, capsys): +async def test_inactive_user(client: TestClient, capsys: CaptureFixture): assert client.app reset_url = client.app.router["auth_reset_password"].url_for() async with NewUser( {"status": UserStatus.CONFIRMATION_PENDING.name}, app=client.app ) as user: - rp = await client.post( + response = await client.post( f"{reset_url}", json={ "email": user["email"], }, ) - assert rp.url.path == reset_url.path - await assert_status(rp, web.HTTPOk, cfg.MSG_EMAIL_SENT.format(**user)) + assert response.url.path == reset_url.path + await assert_status(response, web.HTTPOk, MSG_EMAIL_SENT.format(**user)) - out, err = capsys.readouterr() - assert parse_test_marks(out)["reason"] == cfg.MSG_ACTIVATION_REQUIRED + out, _ = capsys.readouterr() + assert parse_test_marks(out)["reason"] == MSG_ACTIVATION_REQUIRED async def test_too_often( - client: TestClient, cfg: LoginOptions, db: AsyncpgStorage, capsys + client: TestClient, + db: AsyncpgStorage, + capsys: CaptureFixture, ): assert client.app reset_url = client.app.router["auth_reset_password"].url_for() @@ -133,7 +134,7 @@ async def test_too_often( confirmation = await db.create_confirmation( user["id"], ConfirmationAction.RESET_PASSWORD.name ) - rp = await client.post( + response = await client.post( f"{reset_url}", json={ "email": user["email"], @@ -141,37 +142,39 @@ async def test_too_often( ) await db.delete_confirmation(confirmation) - assert rp.url.path == reset_url.path - await assert_status(rp, web.HTTPOk, cfg.MSG_EMAIL_SENT.format(**user)) + assert response.url.path == reset_url.path + await assert_status(response, web.HTTPOk, MSG_EMAIL_SENT.format(**user)) - out, err = capsys.readouterr() - assert parse_test_marks(out)["reason"] == cfg.MSG_OFTEN_RESET_PASSWORD + out, _ = capsys.readouterr() + assert parse_test_marks(out)["reason"] == MSG_OFTEN_RESET_PASSWORD -async def test_reset_and_confirm(client: TestClient, cfg: LoginOptions, capsys): +async def test_reset_and_confirm( + client: TestClient, login_options: LoginOptions, capsys: CaptureFixture +): assert client.app async with NewUser(app=client.app) as user: reset_url = client.app.router["auth_reset_password"].url_for() - rp = await client.post( + response = await client.post( f"{reset_url}", json={ "email": user["email"], }, ) - assert rp.url.path == reset_url.path - await assert_status(rp, web.HTTPOk, cfg.MSG_EMAIL_SENT.format(**user)) + assert response.url.path == reset_url.path + await assert_status(response, web.HTTPOk, MSG_EMAIL_SENT.format(**user)) out, err = capsys.readouterr() confirmation_url = parse_link(out) code = URL(confirmation_url).parts[-1] # emulates user click on email url - rp = await client.get(confirmation_url) - assert rp.status == 200 + response = await client.get(confirmation_url) + assert response.status == 200 assert ( - rp.url.path_qs - == URL(cfg.LOGIN_REDIRECT) + response.url.path_qs + == URL(login_options.LOGIN_REDIRECT) .with_fragment("reset-password?code=%s" % code) .path_qs ) @@ -181,32 +184,31 @@ async def test_reset_and_confirm(client: TestClient, cfg: LoginOptions, capsys): code=code ) new_password = get_random_string(5, 10) - rp = await client.post( + response = await client.post( f"{reset_allowed_url}", json={ "password": new_password, "confirm": new_password, }, ) - payload = await rp.json() - assert rp.status == 200, payload - assert rp.url.path == reset_allowed_url.path - await assert_status(rp, web.HTTPOk, cfg.MSG_PASSWORD_CHANGED) - # TODO: multiple flash messages + payload = await response.json() + assert response.status == 200, payload + assert response.url.path == reset_allowed_url.path + await assert_status(response, web.HTTPOk, MSG_PASSWORD_CHANGED) # Try new password logout_url = client.app.router["auth_logout"].url_for() - rp = await client.post(f"{logout_url}") - assert rp.url.path == logout_url.path - await assert_status(rp, web.HTTPUnauthorized, "Unauthorized") + response = await client.post(f"{logout_url}") + assert response.url.path == logout_url.path + await assert_status(response, web.HTTPUnauthorized, "Unauthorized") login_url = client.app.router["auth_login"].url_for() - rp = await client.post( + response = await client.post( f"{login_url}", json={ "email": user["email"], "password": new_password, }, ) - assert rp.url.path == login_url.path - await assert_status(rp, web.HTTPOk, cfg.MSG_LOGGED_IN) + assert response.url.path == login_url.path + await assert_status(response, web.HTTPOk, MSG_LOGGED_IN) diff --git a/services/web/server/tests/unit/with_dbs/03/test_login_utils_emails.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py similarity index 70% rename from services/web/server/tests/unit/with_dbs/03/test_login_utils_emails.py rename to services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py index 3b2964f2096..f172267368b 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_login_utils_emails.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py @@ -14,8 +14,11 @@ from faker import Faker from json2html import json2html from pytest_mock import MockerFixture +from pytest_simcore.helpers.typing_env import EnvVarsDict from simcore_service_webserver._constants import RQ_PRODUCT_KEY +from simcore_service_webserver.application_settings import setup_settings from simcore_service_webserver.email import setup_email +from simcore_service_webserver.login.plugin import setup_login from simcore_service_webserver.login.utils_email import ( AttachmentTuple, get_template_path, @@ -27,9 +30,9 @@ @pytest.fixture def mocked_send_email(mocker: MockerFixture) -> MagicMock: - async def print_mail(app, msg): + async def print_mail(cfg, message): print("EMAIL----------") - print(msg) + print(message) print("---------------") return mocker.patch( @@ -40,76 +43,82 @@ async def print_mail(app, msg): @pytest.fixture -def app() -> web.Application: +def app(app_environment: EnvVarsDict) -> web.Application: app_ = web.Application() - assert setup_email(app_) + + assert setup_settings(app_) + assert setup_login(app_) # builds LoginOptions needed for _compose_email + assert not setup_email( + app_ + ) # NOTE: it is already init by setup_login, therefore 'setup_email' returns False return app_ -def _create_mocked_request(app_: web.Application, product_name: str): - request = make_mocked_request("GET", "/fake", app=app_) +@pytest.fixture +def http_request(app: web.Application, product_name: str) -> web.Request: + request = make_mocked_request("GET", "/fake", app=app) request[RQ_PRODUCT_KEY] = product_name return request @pytest.mark.parametrize("product_name", FRONTEND_APPS_AVAILABLE) async def test_render_and_send_mail_for_registration( - app: web.Application, faker: Faker, mocked_send_email: MagicMock, product_name: str, + http_request: web.Request, ): - request = _create_mocked_request(app, product_name) email = faker.email() # destination email link = faker.url() # some url link await render_and_send_mail( - request, + http_request, from_=f"no-reply@{product_name}.test", to=email, - template=await get_template_path(request, "registration_email.jinja2"), + template=await get_template_path(http_request, "registration_email.jinja2"), context={ - "host": request.host, + "host": http_request.host, "link": link, "name": email.split("@")[0], }, ) assert mocked_send_email.called - mimetext = mocked_send_email.call_args[0][1] + mimetext = mocked_send_email.call_args[1]["message"] assert mimetext["Subject"] assert mimetext["To"] == email @pytest.mark.parametrize("product_name", FRONTEND_APPS_AVAILABLE) async def test_render_and_send_mail_for_password( - app: web.Application, faker: Faker, mocked_send_email: MagicMock, product_name: str, + http_request: web.Request, ): - request = _create_mocked_request(app, product_name) email = faker.email() # destination email link = faker.url() # some url link await render_and_send_mail( - request, + http_request, from_=f"no-reply@{product_name}.test", to=email, - template=await get_template_path(request, "reset_password_email_failed.jinja2"), + template=await get_template_path( + http_request, "reset_password_email_failed.jinja2" + ), context={ - "host": request.host, + "host": http_request.host, "reason": faker.text(), }, ) await render_and_send_mail( - request, + http_request, from_=f"no-reply@{product_name}.test", to=email, - template=await get_template_path(request, "reset_password_email.jinja2"), + template=await get_template_path(http_request, "reset_password_email.jinja2"), context={ - "host": request.host, + "host": http_request.host, "link": link, }, ) @@ -117,22 +126,21 @@ async def test_render_and_send_mail_for_password( @pytest.mark.parametrize("product_name", FRONTEND_APPS_AVAILABLE) async def test_render_and_send_mail_to_change_email( - app: web.Application, faker: Faker, mocked_send_email: MagicMock, product_name: str, + http_request: web.Request, ): - request = _create_mocked_request(app, product_name) email = faker.email() # destination email link = faker.url() # some url link await render_and_send_mail( - request, + http_request, from_=f"no-reply@{product_name}.test", to=email, - template=await get_template_path(request, "change_email_email.jinja2"), + template=await get_template_path(http_request, "change_email_email.jinja2"), context={ - "host": request.host, + "host": http_request.host, "link": link, }, ) @@ -140,20 +148,19 @@ async def test_render_and_send_mail_to_change_email( @pytest.mark.parametrize("product_name", FRONTEND_APPS_AVAILABLE) async def test_render_and_send_mail_for_submission( - app: web.Application, faker: Faker, mocked_send_email: MagicMock, product_name: str, + http_request: web.Request, ): - request = _create_mocked_request(app, product_name) email = faker.email() # destination email data = {"name": faker.first_name(), "surname": faker.last_name()} # some form await render_and_send_mail( - request, + http_request, from_=f"no-reply@{product_name}.test", to=email, - template=await get_template_path(request, "service_submission.jinja2"), + template=await get_template_path(http_request, "service_submission.jinja2"), context={ "user": email, "data": json2html.convert( @@ -174,22 +181,26 @@ async def test_render_and_send_mail_for_submission( def test_render_string_from_tmp_file( tmp_path: Path, faker: Faker, app: web.Application ): - request = make_mocked_request("GET", "/fake", app=app) + http_request = make_mocked_request("GET", "/fake", app=app) template_path = themed("templates/osparc.io", "registration_email.jinja2") copy_path = tmp_path / template_path.name shutil.copy2(template_path, copy_path) - context = {"host": request.host, "link": faker.url(), "name": faker.first_name()} + context = { + "host": http_request.host, + "link": faker.url(), + "name": faker.first_name(), + } expected_page = render_string( template_name=f"{template_path}", - request=request, + request=http_request, context=context, ) got_page = render_string( template_name=f"{copy_path}", - request=request, + request=http_request, context=context, ) diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index abb223dfb1f..03a7c697aa1 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -9,6 +9,7 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable + import asyncio import sys import textwrap @@ -30,6 +31,7 @@ from pydantic import ByteSize, parse_obj_as from pytest import MonkeyPatch from pytest_mock.plugin import MockerFixture +from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.utils_dict import ConfigDict from pytest_simcore.helpers.utils_login import NewUser from pytest_simcore.helpers.utils_webserver_unit_with_db import MockedStorageSubsystem @@ -48,6 +50,7 @@ delete_user_group, list_user_groups, ) +from simcore_service_webserver.login.settings import LoginOptions CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent @@ -104,7 +107,7 @@ def app_cfg(default_app_cfg: ConfigDict, unused_tcp_port_factory) -> ConfigDict: def app_environment( app_cfg: ConfigDict, monkeypatch_setenv_from_app_config: Callable[[ConfigDict], dict[str, str]], -) -> dict[str, str]: +) -> EnvVarsDict: """overridable fixture that defines the ENV for the webserver application based on legacy application config files. @@ -123,7 +126,7 @@ def app_environment(app_environment: dict[str, str], monkeypatch: MonkeyPatch) - def web_server( event_loop: asyncio.AbstractEventLoop, app_cfg: ConfigDict, - app_environment: dict[str, str], + app_environment: EnvVarsDict, postgres_db: sa.engine.Engine, # tools aiohttp_server: Callable, @@ -542,7 +545,7 @@ async def all_group(client, logged_user) -> dict[str, str]: def _patch_compose_mail(monkeypatch): async def print_mail_to_stdout( - app: web.Application, *, sender: str, recipient: str, subject: str, body: str + cfg: LoginOptions, *, sender: str, recipient: str, subject: str, body: str ): print( f"=== EMAIL FROM: {sender}\n=== EMAIL TO: {recipient}\n=== SUBJECT: {subject}\n=== BODY:\n{body}"