From ca30ee7e285d633a51a8823dc16d34dbde14cc7a Mon Sep 17 00:00:00 2001 From: tarsil Date: Thu, 14 Nov 2024 10:55:57 +0100 Subject: [PATCH 01/24] Fix shell loading default --- .../core/directives/operations/shell/base.py | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/esmerald/core/directives/operations/shell/base.py b/esmerald/core/directives/operations/shell/base.py index f27cf6e0..09f59bce 100644 --- a/esmerald/core/directives/operations/shell/base.py +++ b/esmerald/core/directives/operations/shell/base.py @@ -1,8 +1,12 @@ import select import sys +from collections.abc import Sequence +from typing import Any, Callable import click -from lilya.cli.directives.operations.shell.base import handle_lifespan_events, run_shell +import nest_asyncio +from lilya._internal._events import AyncLifespanContextManager +from lilya.cli.directives.operations.shell.enums import ShellOption from lilya.compat import run_sync from esmerald.core.directives.env import DirectiveEnv @@ -37,3 +41,31 @@ def shell(env: DirectiveEnv, kernel: bool) -> None: ) run_sync(run_shell(env.app, lifespan, kernel)) # type: ignore return None + + +async def run_shell(app: Any, lifespan: Any, kernel: str) -> None: + """Executes the database shell connection""" + + async with lifespan(app): + if kernel == ShellOption.IPYTHON: + from esmerald.core.directives.operations.shell.ipython import get_ipython + + ipython_shell = get_ipython(app=app) + nest_asyncio.apply() + ipython_shell() + else: + from esmerald.core.directives.operations.shell.ptpython import get_ptpython + + ptpython = get_ptpython(app=app) + nest_asyncio.apply() + ptpython() + + +def handle_lifespan_events( + on_startup: Sequence[Callable] | None = None, + on_shutdown: Sequence[Callable] | None = None, + lifespan: Any | None = None, +) -> Any: + if lifespan: + return lifespan + return AyncLifespanContextManager(on_startup=on_startup, on_shutdown=on_shutdown) From cf3c87d65f556d5eac98599d4a7b501af7f83088 Mon Sep 17 00:00:00 2001 From: tarsil Date: Thu, 14 Nov 2024 10:59:59 +0100 Subject: [PATCH 02/24] Fix linting --- esmerald/core/directives/operations/shell/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esmerald/core/directives/operations/shell/base.py b/esmerald/core/directives/operations/shell/base.py index 09f59bce..2a7f9e02 100644 --- a/esmerald/core/directives/operations/shell/base.py +++ b/esmerald/core/directives/operations/shell/base.py @@ -1,7 +1,7 @@ import select import sys from collections.abc import Sequence -from typing import Any, Callable +from typing import Any, Callable, Union import click import nest_asyncio @@ -62,9 +62,9 @@ async def run_shell(app: Any, lifespan: Any, kernel: str) -> None: def handle_lifespan_events( - on_startup: Sequence[Callable] | None = None, - on_shutdown: Sequence[Callable] | None = None, - lifespan: Any | None = None, + on_startup: Union[Sequence[Callable], None] = None, + on_shutdown: Union[Sequence[Callable], None] = None, + lifespan: Union[Any, None] = None, ) -> Any: if lifespan: return lifespan From daa898371ed6b2e82dc9d50bd335101a0c5b419a Mon Sep 17 00:00:00 2001 From: tarsil Date: Fri, 15 Nov 2024 16:11:19 +0100 Subject: [PATCH 03/24] Add O2Auth for OpenAPI and security in general --- esmerald/core/directives/env.py | 4 +- esmerald/openapi/enums.py | 12 + esmerald/openapi/models.py | 11 +- esmerald/openapi/openapi.py | 8 +- .../openapi/schemas/v3_1_0/security_scheme.py | 15 + esmerald/openapi/security/oauth2/base.py | 6 +- esmerald/param_functions.py | 70 +- esmerald/params.py | 1 - esmerald/routing/_internal.py | 53 +- esmerald/security/oauth2/__init__.py | 17 + esmerald/security/oauth2/oauth.py | 713 ++++++++++++++++++ esmerald/security/utils.py | 10 + esmerald/typing.py | 3 +- 13 files changed, 907 insertions(+), 16 deletions(-) create mode 100644 esmerald/security/oauth2/__init__.py create mode 100644 esmerald/security/oauth2/oauth.py diff --git a/esmerald/core/directives/env.py b/esmerald/core/directives/env.py index 8ad3f266..00abdd79 100644 --- a/esmerald/core/directives/env.py +++ b/esmerald/core/directives/env.py @@ -77,9 +77,7 @@ def _get_folders(self, path: Path) -> typing.List[str]: """ return [directory.path for directory in os.scandir(path) if directory.is_dir()] - def _find_app_in_folder( - self, path: Path, cwd: Path - ) -> typing.Union[Scaffold, None]: + def _find_app_in_folder(self, path: Path, cwd: Path) -> typing.Union[Scaffold, None]: """ Iterates inside the folder and looks up to the DISCOVERY_FILES. """ diff --git a/esmerald/openapi/enums.py b/esmerald/openapi/enums.py index 2e88e887..e4498b51 100644 --- a/esmerald/openapi/enums.py +++ b/esmerald/openapi/enums.py @@ -16,12 +16,24 @@ class SecuritySchemeType(BaseEnum): mutualTLS = "mutualTLS" openIdConnect = "openIdConnect" + def __str__(self) -> str: + return self.value + + def __repr__(self) -> str: + return str(self) + class APIKeyIn(BaseEnum): query = "query" header = "header" cookie = "cookie" + def __str__(self) -> str: + return self.value + + def __repr__(self) -> str: + return str(self) + class Header(BaseEnum): authorization = "Authorization" diff --git a/esmerald/openapi/models.py b/esmerald/openapi/models.py index a3db65c6..494b392f 100644 --- a/esmerald/openapi/models.py +++ b/esmerald/openapi/models.py @@ -38,7 +38,7 @@ class APIKey(SecurityScheme): type: Literal["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] = Field( - default=SecuritySchemeType.apiKey, + default=SecuritySchemeType.apiKey.value, alias="type", ) param_in: APIKeyIn = Field(alias="in") @@ -47,7 +47,7 @@ class APIKey(SecurityScheme): class HTTPBase(SecurityScheme): type: Literal["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] = Field( - default=SecuritySchemeType.http, + default=SecuritySchemeType.http.value, alias="type", ) scheme: str @@ -64,14 +64,14 @@ class OAuthFlow(OpenOAuthFlow): class OAuth2(SecurityScheme): type: Literal["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] = Field( - default=SecuritySchemeType.oauth2, alias="type" + default=SecuritySchemeType.oauth2.value, alias="type" ) flows: OAuthFlows class OpenIdConnect(SecurityScheme): type: Literal["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] = Field( - default=SecuritySchemeType.openIdConnect, alias="type" + default=SecuritySchemeType.openIdConnect.value, alias="type" ) openIdConnectUrl: str @@ -81,6 +81,7 @@ class SecurityBase(BaseModel): type: SecuritySchemeType = Field(alias="type") description: Optional[str] = None + scheme_name: Optional[str] = None SecuritySchemeUnion = Union[APIKey, HTTPBase, OAuth2, OpenIdConnect, HTTPBearer] @@ -93,7 +94,7 @@ class Components(BaseModel): examples: Optional[Dict[str, Union[Example, Reference]]] = None requestBodies: Optional[Dict[str, Union[RequestBody, Reference]]] = None headers: Optional[Dict[str, Union[Header, Reference]]] = None - securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = None + securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference, Dict[str, Any]]]] = None links: Optional[Dict[str, Union[Link, Reference]]] = None callbacks: Optional[Dict[str, Union[Dict[str, PathItem], Reference, Any]]] = None pathItems: Optional[Dict[str, Union[PathItem, Reference]]] = None diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index febdf896..bef8eb77 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -49,7 +49,10 @@ ) from esmerald.params import Param from esmerald.routing import gateways, router -from esmerald.routing._internal import convert_annotation_to_pydantic_model +from esmerald.routing._internal import ( + convert_annotation_to_pydantic_model, +) +from esmerald.security.oauth2.oauth import SecurityBase from esmerald.typing import Undefined from esmerald.utils.helpers import is_class_and_subclass, is_union @@ -106,10 +109,11 @@ def get_openapi_security_schemes(schemes: Any) -> Tuple[dict, list]: if inspect.isclass(security_requirement): security_requirement = security_requirement() - if not isinstance(security_requirement, SecurityScheme): + if not isinstance(security_requirement, (SecurityScheme, SecurityBase)): raise ValueError( "Security schemes must subclass from `esmerald.openapi.models.SecurityScheme`" ) + security_definition = security_requirement.model_dump(by_alias=True, exclude_none=True) security_name = security_requirement.scheme_name security_definitions[security_name] = security_definition diff --git a/esmerald/openapi/schemas/v3_1_0/security_scheme.py b/esmerald/openapi/schemas/v3_1_0/security_scheme.py index 007c99a6..ee20999d 100644 --- a/esmerald/openapi/schemas/v3_1_0/security_scheme.py +++ b/esmerald/openapi/schemas/v3_1_0/security_scheme.py @@ -71,6 +71,21 @@ class SecurityScheme(BaseModel): **REQUIRED** for `openIdConnect`. OpenId Connect URL to discover OAuth2 configuration values. This MUST be in the form of a URL. The OpenID Connect standard requires the use of TLS. """ + model: Optional[BaseModel] = None + """ + An optional model to be used for the security scheme. + """ + + scheme_name: Optional[str] = None + """ + An optional name for the security scheme. + """ + + auto_error: bool = False + """ + A flag to indicate if automatic error handling should be enabled. + """ + model_config = ConfigDict( extra="ignore", populate_by_name=True, diff --git a/esmerald/openapi/security/oauth2/base.py b/esmerald/openapi/security/oauth2/base.py index ad4ccaf3..66065e56 100644 --- a/esmerald/openapi/security/oauth2/base.py +++ b/esmerald/openapi/security/oauth2/base.py @@ -3,9 +3,13 @@ from esmerald.openapi.enums import SecuritySchemeType from esmerald.openapi.models import OAuthFlows from esmerald.openapi.security.base import HTTPBase +from esmerald.security.oauth2.oauth import OAuth2 as BaseOAuth2 -class OAuth2(HTTPBase): +class OAuth2(BaseOAuth2): ... + + +class AOAuth2(HTTPBase): """ The OAuth2 scheme. diff --git a/esmerald/param_functions.py b/esmerald/param_functions.py index db449026..8aa1aa75 100644 --- a/esmerald/param_functions.py +++ b/esmerald/param_functions.py @@ -1,6 +1,13 @@ -from typing import Any, Callable, Optional +from typing import Any, Callable, List, Optional, Union +from pydantic.fields import AliasChoices, AliasPath + +from esmerald import params +from esmerald.enums import EncodingType from esmerald.params import DirectInject +from esmerald.typing import Undefined + +_PyUndefined: Any = Undefined def DirectInjects( @@ -14,3 +21,64 @@ def DirectInjects( This is a simple wrapper of the classic Inject(). """ return DirectInject(dependency=dependency, use_cache=use_cache, allow_none=allow_none) + + +def Form( + default: Any = _PyUndefined, + *, + annotation: Optional[Any] = None, + default_factory: Optional[Callable[..., Any]] = _PyUndefined, + allow_none: Optional[bool] = True, + media_type: Union[str, EncodingType] = EncodingType.URL_ENCODED, + content_encoding: Optional[str] = None, + alias: Optional[str] = None, + alias_priority: Optional[int] = _PyUndefined, + title: Optional[str] = None, + description: Optional[str] = None, + gt: Optional[float] = None, + ge: Optional[float] = None, + lt: Optional[float] = None, + le: Optional[float] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + pattern: Optional[str] = None, + examples: Optional[List[Any]] = None, + validation_alias: Optional[Union[str, AliasPath, AliasChoices]] = None, + discriminator: Optional[str] = None, + max_digits: Optional[int] = _PyUndefined, + strict: Optional[bool] = _PyUndefined, + frozen: Optional[bool] = None, + validate_default: bool = True, + init_var: bool = True, + kw_only: bool = True, + include_in_schema: bool = True, +) -> Any: + return params.Form( + default=default, + annotation=annotation, + default_factory=default_factory, + allow_none=allow_none, + media_type=media_type, + content_encoding=content_encoding, + alias=alias, + alias_priority=alias_priority, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + examples=examples, + validation_alias=validation_alias, + discriminator=discriminator, + max_digits=max_digits, + strict=strict, + frozen=frozen, + validate_default=validate_default, + init_var=init_var, + kw_only=kw_only, + include_in_schema=include_in_schema, + ) diff --git a/esmerald/params.py b/esmerald/params.py index b1a4b097..259ca5fd 100644 --- a/esmerald/params.py +++ b/esmerald/params.py @@ -1,4 +1,3 @@ -# from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional, Union from pydantic.dataclasses import dataclass diff --git a/esmerald/routing/_internal.py b/esmerald/routing/_internal.py index 934c332a..97647d48 100644 --- a/esmerald/routing/_internal.py +++ b/esmerald/routing/_internal.py @@ -19,7 +19,7 @@ from esmerald.routing.router import HTTPHandler, WebhookHandler -def get_base_annotations(base_annotation: Any) -> Dict[str, Any]: +def get_base_annotations(base_annotation: Any, is_class: bool = False) -> Dict[str, Any]: """ Returns the annotations of the base class. @@ -30,7 +30,12 @@ def get_base_annotations(base_annotation: Any) -> Dict[str, Any]: Dict[str, Any]: The annotations of the base class. """ base_annotations: Dict[str, Any] = {} - for base in base_annotation.__bases__: + if not is_class: + bases = base_annotation.__bases__ + else: + bases = base_annotation.__class__.__bases__ + + for base in bases: base_annotations.update(**get_base_annotations(base)) if hasattr(base, "__annotations__"): for name, annotation in base.__annotations__.items(): @@ -38,6 +43,50 @@ def get_base_annotations(base_annotation: Any) -> Dict[str, Any]: return base_annotations +def convert_object_annotation_to_pydantic_model(field_annotation: Any) -> BaseModel: + """ + Converts any annotation of the body into a Pydantic + base model. + + This is used for OpenAPI representation purposes only. + + Esmerald will try internally to convert the model into a Pydantic BaseModel, + this will serve as representation of the model in the documentation but internally, + it will use the native type to validate the data being sent and parsed in the + payload/data field. + + Encoders are not supported in the OpenAPI representation, this is because the encoders + are unique to Esmerald and are not part of the OpenAPI specification. This is why + we convert the encoders into a Pydantic model for OpenAPI representation purposes only. + """ + annotation_args = get_args(field_annotation) + if isinstance(field_annotation, _GenericAlias): + annotations = tuple(convert_annotation_to_pydantic_model(arg) for arg in annotation_args) + field_annotation.__args__ = annotations + return cast(BaseModel, field_annotation) + + field_definitions: Dict[str, Any] = {} + + # Get any possible annotations from the base classes + base_annotations: Dict[str, Any] = {**get_base_annotations(field_annotation, is_class=True)} + field_annotations = { + **base_annotations, + **field_annotation.__annotations__, + } + for name, annotation in field_annotations.items(): + field_definitions[name] = (annotation, ...) + + if inspect.isclass(field_annotation): + name = field_annotation.__name__ + else: + name = field_annotation.__class__.__name__ + + return cast( + BaseModel, + create_model(name, __config__={"arbitrary_types_allowed": True}, **field_definitions), + ) + + def convert_annotation_to_pydantic_model(field_annotation: Any) -> Any: """ Converts any annotation of the body into a Pydantic diff --git a/esmerald/security/oauth2/__init__.py b/esmerald/security/oauth2/__init__.py new file mode 100644 index 00000000..46141baa --- /dev/null +++ b/esmerald/security/oauth2/__init__.py @@ -0,0 +1,17 @@ +from .oauth import ( + OAuth2, + OAuth2AuthorizationCodeBearer, + OAuth2PasswordBearer, + OAuth2PasswordRequestForm, + OAuth2PasswordRequestFormStrict, + SecurityScopes, +) + +__all__ = [ + "OAuth2", + "OAuth2AuthorizationCodeBearer", + "OAuth2PasswordBearer", + "OAuth2PasswordRequestForm", + "OAuth2PasswordRequestFormStrict", + "SecurityScopes", +] diff --git a/esmerald/security/oauth2/oauth.py b/esmerald/security/oauth2/oauth.py new file mode 100644 index 00000000..9340fa73 --- /dev/null +++ b/esmerald/security/oauth2/oauth.py @@ -0,0 +1,713 @@ +from typing import Any, Dict, List, Optional, Union, cast + +from lilya.requests import Request +from lilya.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN +from typing_extensions import Annotated, Doc + +from esmerald.exceptions import HTTPException +from esmerald.openapi.models import ( + OAuth2 as OAuth2Model, + OAuthFlows as OAuthFlowsModel, + SecurityScheme, +) +from esmerald.param_functions import Form +from esmerald.security.utils import get_authorization_scheme_param + + +class SecurityBase(SecurityScheme): ... + + +class OAuth2PasswordRequestForm: + """ + This is a dependency class to collect the `username` and `password` as form data + for an OAuth2 password flow. + + The OAuth2 specification dictates that for a password flow the data should be + collected using form data (instead of JSON) and that it should have the specific + fields `username` and `password`. + + All the initialization parameters are extracted from the request. + + Read more about it in the + [Esmerald docs for Simple OAuth2 with Password and Bearer](https://esmerald.dev/tutorial/security/simple-oauth2/). + + ## Example + + ```python + from typing import Annotated + + from esmerald import Esmerald, Gateway, Inject, Injects, post + from esmerald.security.oauth2 import OAuth2PasswordRequestForm + + + @post("/login", dependencies={"form_data": Inject(OAuth2PasswordRequestForm)}) + def login(form_data: Annotated[OAuth2PasswordRequestForm, Injects()]) -> dict: + data = {} + data["scopes"] = [] + for scope in form_data.scopes: + data["scopes"].append(scope) + if form_data.client_id: + data["client_id"] = form_data.client_id + if form_data.client_secret: + data["client_secret"] = form_data.client_secret + return data + + + app = Esmerald( + routes=[ + Gateway(handler=login), + ] + ) + ``` + + Note that for OAuth2 the scope `items:read` is a single scope in an opaque string. + You could have custom internal logic to separate it by colon characters (`:`) or + similar, and get the two parts `items` and `read`. Many applications do that to + group and organize permissions, you could do it as well in your application, just + know that that it is application specific, it's not part of the specification. + """ + + def __init__( + self, + *, + grant_type: Annotated[ + Union[str, None], + Form(pattern="password"), + Doc( + """ + Specifies the OAuth2 grant type. + + Per the OAuth2 specification, this value is required and must be set + to the fixed string "password" when using the password grant flow. + However, this class allows flexibility and does not enforce this + restriction. To enforce the "password" value strictly, consider using + the `OAuth2PasswordRequestFormStrict` dependency. + """ + ), + ] = None, + username: Annotated[ + str, + Form(), + Doc( + """ + The username of the user for OAuth2 authentication. + + According to the OAuth2 specification, this field must be named + `username`, as it is used to identify the user during the + authentication process. + """ + ), + ], + password: Annotated[ + str, + Form(), + Doc( + """ + The password of the user for OAuth2 authentication. + + Per the OAuth2 spec, this field must also use the name `password`. + It is required for authentication to validate the provided username. + """ + ), + ], + scope: Annotated[ + str, + Form(), + Doc( + """ + A single string containing one or more scopes, space-separated. + + Each scope represents a permission requested by the application. + Scopes help specify fine-grained access control, enabling the client + to request only the permissions it needs. For example, the following + string: + + ```python + "items:read items:write users:read profile openid" + ``` + + represents multiple scopes: + + * `items:read` + * `items:write` + * `users:read` + * `profile` + * `openid` + """ + ), + ] = "", + client_id: Annotated[ + Union[str, None], + Form(), + Doc( + """ + Optional client identifier used to identify the client application. + + If provided, `client_id` can be sent as part of the form data. + Although the OAuth2 specification recommends sending both `client_id` + and `client_secret` via HTTP Basic authentication headers, some APIs + accept these values in the form fields for flexibility. + """ + ), + ] = None, + client_secret: Annotated[ + Union[str, None], + Form(), + Doc( + """ + Optional client secret for authenticating the client application. + + If a `client_secret` is required (along with `client_id`), it can be + included in the form data. However, the OAuth2 spec advises sending + both `client_id` and `client_secret` using HTTP Basic authentication + headers for security. + """ + ), + ] = None, + ) -> None: + self.grant_type = grant_type + self.username = username + self.password = password + self.scopes = scope.split() + self.client_id = client_id + self.client_secret = client_secret + + +class OAuth2PasswordRequestFormStrict(OAuth2PasswordRequestForm): + """ + This is a dependency class to collect the `username` and `password` as form data + for an OAuth2 password flow. + + The OAuth2 specification dictates that for a password flow the data should be + collected using form data (instead of JSON) and that it should have the specific + fields `username` and `password`. + + All the initialization parameters are extracted from the request. + + The only difference between `OAuth2PasswordRequestFormStrict` and + `OAuth2PasswordRequestForm` is that `OAuth2PasswordRequestFormStrict` requires the + client to send the form field `grant_type` with the value `"password"`, which + is required in the OAuth2 specification (it seems that for no particular reason), + while for `OAuth2PasswordRequestForm` `grant_type` is optional. + + ## Example + + ```python + from typing import Annotated + + from esmerald import Esmerald, Gateway, Inject, Injects, post + from esmerald.security.oauth2 import OAuth2PasswordRequestForm + + + @post("/login", dependencies={"form_data": Inject(OAuth2PasswordRequestForm)}) + def login(form_data: Annotated[OAuth2PasswordRequestForm, Injects()]) -> dict: + data = {} + data["scopes"] = [] + for scope in form_data.scopes: + data["scopes"].append(scope) + if form_data.client_id: + data["client_id"] = form_data.client_id + if form_data.client_secret: + data["client_secret"] = form_data.client_secret + return data + + + app = Esmerald( + routes=[ + Gateway(handler=login), + ] + ) + ``` + + Note that for OAuth2 the scope `items:read` is a single scope in an opaque string. + You could have custom internal logic to separate it by colon caracters (`:`) or + similar, and get the two parts `items` and `read`. Many applications do that to + group and organize permissions, you could do it as well in your application, just + know that that it is application specific, it's not part of the specification. + + + grant_type: the OAuth2 spec says it is required and MUST be the fixed string "password". + This dependency is strict about it. If you want to be permissive, use instead the + OAuth2PasswordRequestForm dependency class. + username: username string. The OAuth2 spec requires the exact field name "username". + password: password string. The OAuth2 spec requires the exact field name "password". + scope: Optional string. Several scopes (each one a string) separated by spaces. E.g. + "items:read items:write users:read profile openid" + client_id: optional string. OAuth2 recommends sending the client_id and client_secret (if any) + using HTTP Basic auth, as: client_id:client_secret + client_secret: optional string. OAuth2 recommends sending the client_id and client_secret (if any) + using HTTP Basic auth, as: client_id:client_secret + """ + + def __init__( + self, + grant_type: Annotated[ + str, + Form(pattern="password"), + Doc( + """ + The OAuth2 spec says it is required and MUST be the fixed string + "password". This dependency is strict about it. If you want to be + permissive, use instead the `OAuth2PasswordRequestForm` dependency + class. + """ + ), + ], + username: Annotated[ + str, + Form(), + Doc( + """ + `username` string. The OAuth2 spec requires the exact field name + `username`. + """ + ), + ], + password: Annotated[ + str, + Form(), + Doc( + """ + `password` string. The OAuth2 spec requires the exact field name + `password". + """ + ), + ], + scope: Annotated[ + str, + Form(), + Doc( + """ + A single string with actually several scopes separated by spaces. Each + scope is also a string. + + For example, a single string with: + + ```python + "items:read items:write users:read profile openid" + ```` + + would represent the scopes: + + * `items:read` + * `items:write` + * `users:read` + * `profile` + * `openid` + """ + ), + ] = "", + client_id: Annotated[ + Union[str, None], + Form(), + Doc( + """ + If there's a `client_id`, it can be sent as part of the form fields. + But the OAuth2 specification recommends sending the `client_id` and + `client_secret` (if any) using HTTP Basic auth. + """ + ), + ] = None, + client_secret: Annotated[ + Union[str, None], + Form(), + Doc( + """ + If there's a `client_password` (and a `client_id`), they can be sent + as part of the form fields. But the OAuth2 specification recommends + sending the `client_id` and `client_secret` (if any) using HTTP Basic + auth. + """ + ), + ] = None, + ) -> None: + super().__init__( + grant_type=grant_type, + username=username, + password=password, + scope=scope, + client_id=client_id, + client_secret=client_secret, + ) + + +class OAuth2(SecurityBase): + """ + This is the base class for OAuth2 authentication, an instance of it would be used + as a dependency. All other OAuth2 classes inherit from it and customize it for + each OAuth2 flow. + + You normally would not create a new class inheriting from it but use one of the + existing subclasses, and maybe compose them if you want to support multiple flows. + """ + + def __init__( + self, + *, + flows: Annotated[ + Union[OAuthFlowsModel, Dict[str, Dict[str, Any]]], + Doc( + """ + The dictionary containing the OAuth2 flows. + """ + ), + ] = OAuthFlowsModel(), + scheme_name: Annotated[ + Optional[str], + Doc( + """ + The name of the Security scheme. + + It will be in the OpenAPI documentation. + """ + ), + ] = None, + description: Annotated[ + Optional[str], + Doc( + """ + Security scheme description. + + It will be in the OpenAPI documentation. + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if no HTTP Authorization header is provided, which is required for + OAuth2 authentication, the request will automatically be canceled and + an error will be sent to the client. + + If `auto_error` is set to `False`, when the HTTP Authorization header + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, with OAuth2 + or in a cookie). + """ + ), + ] = True, + ) -> None: + model = OAuth2Model(flows=cast(OAuthFlowsModel, flows), description=description) + model_dump = model.model_dump() + super().__init__(**model_dump) + self.scheme_name = scheme_name or self.__class__.__name__ + self.auto_error = auto_error + + async def __call__(self, request: Request) -> Any: + authorization = request.headers.get("Authorization") + + if authorization: + return authorization + + if self.auto_error: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + + return None + + +class OAuth2PasswordBearer(OAuth2): + """ + This class is typically used as a dependency in path operations. + + Args: + tokenUrl (str): The URL to obtain the OAuth2 token. This should be the path + operation that has `OAuth2PasswordRequestForm` as a dependency. + scheme_name (Optional[str], optional): The security scheme name. This will appear + in the OpenAPI documentation. Defaults to None. + scopes (Optional[Dict[str, str]], optional): The OAuth2 scopes required by the + path operations using this dependency. + Defaults to None. + description (Optional[str], optional): The security scheme description. This will + appear in the OpenAPI documentation. Defaults to None. + auto_error (bool, optional): If set to True (default), the request will automatically + be canceled and an error sent to the client if no HTTP + Authorization header is provided. If set to False, the + dependency result will be None when the HTTP Authorization + header is not available, allowing for optional authentication. + + Methods: + __call__(request: Request) -> Optional[str]: Extracts and returns the bearer token + from the request's Authorization header. + Raises an HTTP 401 error if authentication + fails and `auto_error` is True. + + OAuth2 flow for authentication using a bearer token obtained with a password. + An instance of it would be used as a dependency. + """ + + def __init__( + self, + tokenUrl: Annotated[ + str, + Doc( + """ + The endpoint URL used to obtain the OAuth2 token. + + This URL should point to the *path operation* that includes + `OAuth2PasswordRequestForm` as a dependency, facilitating user + authentication and token retrieval within the OAuth2 framework. + """ + ), + ], + scheme_name: Annotated[ + Optional[str], + Doc( + """ + The name of the security scheme. + + This value will be displayed in the OpenAPI documentation, + identifying the OAuth2 security scheme associated with this + configuration. + """ + ), + ] = None, + scopes: Annotated[ + Optional[Dict[str, str]], + Doc( + """ + A dictionary of OAuth2 scopes associated with this configuration. + + Scopes define specific permissions that the *path operations* require, + enabling fine-grained access control. The dictionary should use + scope names as keys and their descriptions as values, aiding in + understanding each scope's purpose. + """ + ), + ] = None, + description: Annotated[ + Optional[str], + Doc( + """ + A description of the security scheme. + + This description will be included in the OpenAPI documentation, + providing users with an overview of the OAuth2 security scheme's + purpose and any additional information relevant to this configuration. + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + Flag to control automatic error response when authorization fails. + + If `True` (default), the application will automatically cancel the + request and return an error response if the HTTP Authorization header + is missing or invalid. Setting `auto_error` to `False` allows the + request to proceed without authentication, returning `None` for the + dependency result, which is useful in cases where authentication + should be optional or supported through multiple methods (e.g., + OAuth2 or cookies). + """ + ), + ] = True, + ): + if not scopes: + scopes = {} + flows = OAuthFlowsModel(password=cast(Any, {"tokenUrl": tokenUrl, "scopes": scopes})) + super().__init__( + flows=flows, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> Optional[str]: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + + if authorization and scheme.lower() == "bearer": + return param + + if self.auto_error: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return None + + +class OAuth2AuthorizationCodeBearer(OAuth2): + """ + Implements the OAuth2 authorization code flow for obtaining a bearer token. + + This class is used to handle authentication by exchanging an authorization + code for an access token. An instance of `OAuth2AuthorizationCodeBearer` can + be used as a dependency to secure endpoint access, ensuring users are + authenticated via an OAuth2 authorization code flow. + """ + + def __init__( + self, + authorizationUrl: str, + tokenUrl: Annotated[ + str, + Doc( + """ + The URL endpoint to exchange the authorization code for an OAuth2 access token. + + This URL should point to the token endpoint in the OAuth2 provider's + API, enabling users to obtain a bearer token after the authorization + code is provided. + """ + ), + ], + refreshUrl: Annotated[ + Optional[str], + Doc( + """ + Optional URL endpoint for refreshing the OAuth2 access token. + + When provided, this URL allows users to renew an expired access token + without re-authenticating, improving usability and security. This + endpoint is part of the OAuth2 provider's API. + """ + ), + ] = None, + scheme_name: Annotated[ + Optional[str], + Doc( + """ + The name of the OAuth2 security scheme. + + This name will be displayed in the OpenAPI documentation, identifying + the authorization method used for API access. + """ + ), + ] = None, + scopes: Annotated[ + Optional[Dict[str, str]], + Doc( + """ + Dictionary of OAuth2 scopes required for API access. + + Scopes represent permissions requested by the application for specific + actions or data access. Each scope is represented by a key-value pair + where the key is the scope identifier, and the value is a brief + description of the scope's purpose. + """ + ), + ] = None, + description: Annotated[ + Optional[str], + Doc( + """ + Description of the OAuth2 security scheme for documentation purposes. + + This text will appear in the OpenAPI documentation, providing context + on the authentication method used by this scheme and any relevant + details. + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + Determines if the request should automatically error on authentication failure. + + If set to `True` (default), requests without a valid Authorization + header will automatically return an error response. If `False`, the + request can continue without authentication, returning `None` as the + dependency result. This option is useful for optional authentication + or for scenarios where multiple authentication methods are permitted + (e.g., OAuth2 or session-based tokens). + """ + ), + ] = True, + ): + if not scopes: + scopes = {} + flows = OAuthFlowsModel( + authorizationCode=cast( + Any, + { + "authorizationUrl": authorizationUrl, + "tokenUrl": tokenUrl, + "refreshUrl": refreshUrl, + "scopes": scopes, + }, + ) + ) + super().__init__( + flows=flows, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> Optional[str]: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + + if authorization and scheme.lower() == "bearer": + return param + + if self.auto_error: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return None + + +class SecurityScopes: + """ + Represents a collection of OAuth2 scopes required across multiple dependencies. + + `SecurityScopes` is used as a parameter in a dependency to aggregate the OAuth2 + scopes required by all dependent components within the same request. This allows + different dependencies to specify varying scopes even when used together in the + same *path operation*, and provides access to all required scopes in one place + for streamlined authorization handling. + """ + + def __init__( + self, + scopes: Annotated[ + Optional[List[str]], + Doc( + """ + A list of OAuth2 scopes needed for the current request, populated by Esmerald. + + This list will automatically include all required scopes based on the + dependencies in the request, ensuring all necessary permissions are available. + """ + ), + ] = None, + ): + self.scopes: Annotated[ + List[str], + Doc( + """ + List of all OAuth2 scopes required by dependencies for this request. + + This list consolidates the individual scopes required by each dependency, + providing a comprehensive view of the permissions needed for access. + """ + ), + ] = scopes or [] + + self.scope_str: Annotated[ + str, + Doc( + """ + A single string of all required scopes, space-separated as per OAuth2 specification. + + This string is useful for passing scopes in formats where a single, + space-delimited string is required, such as in OAuth2-compliant authorization + requests or access tokens. + """ + ), + ] = " ".join(self.scopes) diff --git a/esmerald/security/utils.py b/esmerald/security/utils.py index 6e92f553..54902cb7 100644 --- a/esmerald/security/utils.py +++ b/esmerald/security/utils.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from typing import Optional, Tuple def convert_time(date: datetime) -> datetime: @@ -8,3 +9,12 @@ def convert_time(date: datetime) -> datetime: if date.tzinfo is not None: date.astimezone(timezone.utc) return date.replace(microsecond=0) + + +def get_authorization_scheme_param( + authorization_header_value: Optional[str], +) -> Tuple[str, str]: + if not authorization_header_value: + return "", "" + scheme, _, param = authorization_header_value.partition(" ") + return scheme, param diff --git a/esmerald/typing.py b/esmerald/typing.py index 7ec862b6..a70a2f31 100644 --- a/esmerald/typing.py +++ b/esmerald/typing.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Type, TypeVar +from typing import Any, Callable, Dict, Set, Type, TypeVar, Union from pydantic_core import PydanticUndefined @@ -12,3 +12,4 @@ class Void: VoidType = Type[Void] AnyCallable = Callable[..., Any] Undefined = PydanticUndefined +IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]] From 216d7834bd82784bea8d42219a63480d9def1141 Mon Sep 17 00:00:00 2001 From: tarsil Date: Fri, 15 Nov 2024 18:20:52 +0100 Subject: [PATCH 04/24] Add new tests for OAuth and add Security --- esmerald/__init__.py | 3 +- esmerald/openapi/openapi.py | 1 - .../openapi/schemas/v3_1_0/security_scheme.py | 12 +- esmerald/param_functions.py | 15 +- esmerald/params.py | 25 ++- esmerald/security/oauth2/oauth.py | 22 ++- tests/security/oauth/__init__.py | 0 .../security/oauth/test_oauth_code_bearer.py | 165 +++++++++++++++++ .../oauth/test_oauth_code_bearer_desc.py | 170 ++++++++++++++++++ ...ecurity_oauth2_password_bearer_optional.py | 154 ++++++++++++++++ 10 files changed, 546 insertions(+), 21 deletions(-) create mode 100644 tests/security/oauth/__init__.py create mode 100644 tests/security/oauth/test_oauth_code_bearer.py create mode 100644 tests/security/oauth/test_oauth_code_bearer_desc.py create mode 100644 tests/security/oauth/test_security_oauth2_password_bearer_optional.py diff --git a/esmerald/__init__.py b/esmerald/__init__.py index 00632db3..0713b0b1 100644 --- a/esmerald/__init__.py +++ b/esmerald/__init__.py @@ -23,7 +23,7 @@ ValidationErrorException, ) from .interceptors.interceptor import EsmeraldInterceptor -from .param_functions import DirectInjects +from .param_functions import DirectInjects, Security from .params import Body, Cookie, File, Form, Header, Injects, Param, Path, Query from .permissions import AllowAny, BasePermission, DenyAll from .pluggables import Extension, Pluggable @@ -93,6 +93,7 @@ "Request", "Response", "Router", + "Security", "ServiceUnavailable", "SessionConfig", "SimpleAPIView", diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index bef8eb77..0fd234e2 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -118,7 +118,6 @@ def get_openapi_security_schemes(schemes: Any) -> Tuple[dict, list]: security_name = security_requirement.scheme_name security_definitions[security_name] = security_definition operation_security.append({security_name: security_requirement}) - return security_definitions, operation_security diff --git a/esmerald/openapi/schemas/v3_1_0/security_scheme.py b/esmerald/openapi/schemas/v3_1_0/security_scheme.py index ee20999d..8939ca0c 100644 --- a/esmerald/openapi/schemas/v3_1_0/security_scheme.py +++ b/esmerald/openapi/schemas/v3_1_0/security_scheme.py @@ -76,16 +76,6 @@ class SecurityScheme(BaseModel): An optional model to be used for the security scheme. """ - scheme_name: Optional[str] = None - """ - An optional name for the security scheme. - """ - - auto_error: bool = False - """ - A flag to indicate if automatic error handling should be enabled. - """ - model_config = ConfigDict( extra="ignore", populate_by_name=True, @@ -110,7 +100,7 @@ class SecurityScheme(BaseModel): { "type": "openIdConnect", "openIdConnectUrl": "openIdConnect", - }, # issue #5: allow relative path + }, ] }, ) diff --git a/esmerald/param_functions.py b/esmerald/param_functions.py index 8aa1aa75..ebd575c9 100644 --- a/esmerald/param_functions.py +++ b/esmerald/param_functions.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, List, Optional, Union +from typing import Any, Callable, List, Optional, Sequence, Union from pydantic.fields import AliasChoices, AliasPath @@ -10,6 +10,19 @@ _PyUndefined: Any = Undefined +def Security( + dependency: Optional[Callable[..., Any]] = None, + *, + scopes: Optional[Sequence[str]] = None, + use_cache: bool = True, +) -> Any: + """ + This function should be only called if Inject/Injects is not used in the dependencies. + This is a simple wrapper of the classic Inject(). + """ + return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache) + + def DirectInjects( dependency: Optional[Callable[..., Any]] = None, *, diff --git a/esmerald/params.py b/esmerald/params.py index 259ca5fd..a3e22957 100644 --- a/esmerald/params.py +++ b/esmerald/params.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Union from pydantic.dataclasses import dataclass from pydantic.fields import AliasChoices, AliasPath, FieldInfo @@ -650,3 +650,26 @@ def __hash__(self) -> int: else: values[key] = value return hash((type(self),) + tuple(values)) + + +class Depends: + def __init__(self, dependency: Optional[Callable[..., Any]] = None, *, use_cache: bool = True): + self.dependency = dependency + self.use_cache = use_cache + + def __repr__(self) -> str: + attr = getattr(self.dependency, "__name__", type(self.dependency).__name__) + cache = "" if self.use_cache else ", use_cache=False" + return f"{self.__class__.__name__}({attr}{cache})" + + +class Security(Depends): + def __init__( + self, + dependency: Optional[Callable[..., Any]] = None, + *, + scopes: Optional[Sequence[str]] = None, + use_cache: bool = True, + ): + super().__init__(dependency=dependency, use_cache=use_cache) + self.scopes = scopes or [] diff --git a/esmerald/security/oauth2/oauth.py b/esmerald/security/oauth2/oauth.py index 9340fa73..ebd404b1 100644 --- a/esmerald/security/oauth2/oauth.py +++ b/esmerald/security/oauth2/oauth.py @@ -14,7 +14,15 @@ from esmerald.security.utils import get_authorization_scheme_param -class SecurityBase(SecurityScheme): ... +class SecurityBase(SecurityScheme): + scheme_name: Optional[str] = None + """ + An optional name for the security scheme. + """ + __auto_error__: bool = False + """ + A flag to indicate if automatic error handling should be enabled. + """ class OAuth2PasswordRequestForm: @@ -393,11 +401,13 @@ def __init__( ), ] = True, ) -> None: - model = OAuth2Model(flows=cast(OAuthFlowsModel, flows), description=description) + model = OAuth2Model( + flows=cast(OAuthFlowsModel, flows), scheme=scheme_name, description=description + ) model_dump = model.model_dump() super().__init__(**model_dump) self.scheme_name = scheme_name or self.__class__.__name__ - self.auto_error = auto_error + self.__auto_error__ = auto_error async def __call__(self, request: Request) -> Any: authorization = request.headers.get("Authorization") @@ -405,7 +415,7 @@ async def __call__(self, request: Request) -> Any: if authorization: return authorization - if self.auto_error: + if self.__auto_error__: raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") return None @@ -526,7 +536,7 @@ async def __call__(self, request: Request) -> Optional[str]: if authorization and scheme.lower() == "bearer": return param - if self.auto_error: + if self.__auto_error__: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Not authenticated", @@ -652,7 +662,7 @@ async def __call__(self, request: Request) -> Optional[str]: if authorization and scheme.lower() == "bearer": return param - if self.auto_error: + if self.__auto_error__: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Not authenticated", diff --git a/tests/security/oauth/__init__.py b/tests/security/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/security/oauth/test_oauth_code_bearer.py b/tests/security/oauth/test_oauth_code_bearer.py new file mode 100644 index 00000000..4a86eea0 --- /dev/null +++ b/tests/security/oauth/test_oauth_code_bearer.py @@ -0,0 +1,165 @@ +from typing import Any, Optional + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.oauth2 import OAuth2AuthorizationCodeBearer +from esmerald.testclient import create_client + +oauth2_scheme = OAuth2AuthorizationCodeBearer( + authorizationUrl="authorize", tokenUrl="token", auto_error=True +) + + +@get("/items", dependencies={"token": Inject(oauth2_scheme)}, security=[oauth2_scheme]) +async def read_items(token: Optional[str] = Injects()) -> dict[str, Any]: + return {"token": token} + + +def test_no_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items") + assert response.status_code == 401, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_incorrect_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items", headers={"Authorization": "Non-existent testtoken"}) + assert response.status_code == 401, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "testtoken"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/items": { + "get": { + "summary": "Read Items", + "description": "", + "operationId": "read_items_items_get", + "deprecated": False, + "security": [ + { + "OAuth2AuthorizationCodeBearer": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "authorize", + "tokenUrl": "token", + "scopes": {}, + } + }, + "scheme_name": "OAuth2AuthorizationCodeBearer", + } + } + ], + "parameters": [ + { + "name": "token", + "in": "query", + "required": True, + "deprecated": False, + "allowEmptyValue": False, + "allowReserved": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Token", + }, + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "type": "array", + "title": "Detail", + } + }, + "type": "object", + "title": "HTTPValidationError", + }, + "ValidationError": { + "properties": { + "loc": { + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + "type": "array", + "title": "Location", + }, + "msg": {"type": "string", "title": "Message"}, + "type": {"type": "string", "title": "Error Type"}, + }, + "type": "object", + "required": ["loc", "msg", "type"], + "title": "ValidationError", + }, + }, + "securitySchemes": { + "OAuth2AuthorizationCodeBearer": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "authorize", + "tokenUrl": "token", + "scopes": {}, + } + }, + } + }, + }, + } diff --git a/tests/security/oauth/test_oauth_code_bearer_desc.py b/tests/security/oauth/test_oauth_code_bearer_desc.py new file mode 100644 index 00000000..6fb2fe61 --- /dev/null +++ b/tests/security/oauth/test_oauth_code_bearer_desc.py @@ -0,0 +1,170 @@ +from typing import Any, Optional + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.oauth2 import OAuth2AuthorizationCodeBearer +from esmerald.testclient import create_client + +oauth2_scheme = OAuth2AuthorizationCodeBearer( + authorizationUrl="authorize", + tokenUrl="token", + description="OAuth2 Code Bearer", + auto_error=True, +) + + +@get("/items", dependencies={"token": Inject(oauth2_scheme)}, security=[oauth2_scheme]) +async def read_items(token: Optional[str] = Injects()) -> dict[str, Any]: + return {"token": token} + + +def test_no_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items") + assert response.status_code == 401, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_incorrect_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items", headers={"Authorization": "Non-existent testtoken"}) + assert response.status_code == 401, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "testtoken"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/items": { + "get": { + "summary": "Read Items", + "description": "", + "operationId": "read_items_items_get", + "deprecated": False, + "security": [ + { + "OAuth2AuthorizationCodeBearer": { + "type": "oauth2", + "description": "OAuth2 Code Bearer", + "flows": { + "authorizationCode": { + "authorizationUrl": "authorize", + "tokenUrl": "token", + "scopes": {}, + } + }, + "scheme_name": "OAuth2AuthorizationCodeBearer", + } + } + ], + "parameters": [ + { + "name": "token", + "in": "query", + "required": True, + "deprecated": False, + "allowEmptyValue": False, + "allowReserved": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Token", + }, + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "type": "array", + "title": "Detail", + } + }, + "type": "object", + "title": "HTTPValidationError", + }, + "ValidationError": { + "properties": { + "loc": { + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + "type": "array", + "title": "Location", + }, + "msg": {"type": "string", "title": "Message"}, + "type": {"type": "string", "title": "Error Type"}, + }, + "type": "object", + "required": ["loc", "msg", "type"], + "title": "ValidationError", + }, + }, + "securitySchemes": { + "OAuth2AuthorizationCodeBearer": { + "type": "oauth2", + "description": "OAuth2 Code Bearer", + "flows": { + "authorizationCode": { + "authorizationUrl": "authorize", + "tokenUrl": "token", + "scopes": {}, + } + }, + } + }, + }, + } diff --git a/tests/security/oauth/test_security_oauth2_password_bearer_optional.py b/tests/security/oauth/test_security_oauth2_password_bearer_optional.py new file mode 100644 index 00000000..24490cec --- /dev/null +++ b/tests/security/oauth/test_security_oauth2_password_bearer_optional.py @@ -0,0 +1,154 @@ +from typing import Any, Dict, Optional + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.oauth2 import OAuth2PasswordBearer +from esmerald.testclient import create_client + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token", auto_error=False) + + +@get("/items/", security=[oauth2_scheme], dependencies={"token": Inject(oauth2_scheme)}) +async def read_items(token: Optional[str] = Injects()) -> Dict[str, Any]: + if token is None: + return {"msg": "Create an account first"} + return {"token": token} + + +def test_no_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "testtoken"} + + +def test_incorrect_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items", headers={"Authorization": "Notexistent testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/items": { + "get": { + "summary": "Read Items", + "description": "", + "operationId": "read_items_items_get", + "deprecated": False, + "security": [ + { + "OAuth2PasswordBearer": { + "type": "oauth2", + "flows": {"password": {"tokenUrl": "/token", "scopes": {}}}, + "scheme_name": "OAuth2PasswordBearer", + } + } + ], + "parameters": [ + { + "name": "token", + "in": "query", + "required": True, + "deprecated": False, + "allowEmptyValue": False, + "allowReserved": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Token", + }, + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "type": "array", + "title": "Detail", + } + }, + "type": "object", + "title": "HTTPValidationError", + }, + "ValidationError": { + "properties": { + "loc": { + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + "type": "array", + "title": "Location", + }, + "msg": {"type": "string", "title": "Message"}, + "type": {"type": "string", "title": "Error Type"}, + }, + "type": "object", + "required": ["loc", "msg", "type"], + "title": "ValidationError", + }, + }, + "securitySchemes": { + "OAuth2PasswordBearer": { + "type": "oauth2", + "flows": {"password": {"tokenUrl": "/token", "scopes": {}}}, + } + }, + }, + } From 81b9add3debd57a38ffcd0452a42315f9171f9c6 Mon Sep 17 00:00:00 2001 From: tarsil Date: Fri, 15 Nov 2024 18:26:31 +0100 Subject: [PATCH 05/24] Add extra test --- ...ty_oauth2_password_bearer_optional_desc.py | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/security/oauth/test_security_oauth2_password_bearer_optional_desc.py diff --git a/tests/security/oauth/test_security_oauth2_password_bearer_optional_desc.py b/tests/security/oauth/test_security_oauth2_password_bearer_optional_desc.py new file mode 100644 index 00000000..e40f3e3e --- /dev/null +++ b/tests/security/oauth/test_security_oauth2_password_bearer_optional_desc.py @@ -0,0 +1,158 @@ +from typing import Any, Dict, Optional + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.oauth2 import OAuth2PasswordBearer +from esmerald.testclient import create_client + +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl="/token", description="OAuth2PasswordBearer security scheme", auto_error=False +) + + +@get("/items/", security=[oauth2_scheme], dependencies={"token": Inject(oauth2_scheme)}) +async def read_items(token: Optional[str] = Injects()) -> Dict[str, Any]: + if token is None: + return {"msg": "Create an account first"} + return {"token": token} + + +def test_no_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "testtoken"} + + +def test_incorrect_token(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + ) as client: + response = client.get("/items", headers={"Authorization": "Notexistent testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_items), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/items": { + "get": { + "summary": "Read Items", + "description": "", + "operationId": "read_items_items_get", + "deprecated": False, + "security": [ + { + "OAuth2PasswordBearer": { + "type": "oauth2", + "description": "OAuth2PasswordBearer security scheme", + "flows": {"password": {"tokenUrl": "/token", "scopes": {}}}, + "scheme_name": "OAuth2PasswordBearer", + } + } + ], + "parameters": [ + { + "name": "token", + "in": "query", + "required": True, + "deprecated": False, + "allowEmptyValue": False, + "allowReserved": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Token", + }, + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "type": "array", + "title": "Detail", + } + }, + "type": "object", + "title": "HTTPValidationError", + }, + "ValidationError": { + "properties": { + "loc": { + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + "type": "array", + "title": "Location", + }, + "msg": {"type": "string", "title": "Message"}, + "type": {"type": "string", "title": "Error Type"}, + }, + "type": "object", + "required": ["loc", "msg", "type"], + "title": "ValidationError", + }, + }, + "securitySchemes": { + "OAuth2PasswordBearer": { + "type": "oauth2", + "description": "OAuth2PasswordBearer security scheme", + "flows": {"password": {"tokenUrl": "/token", "scopes": {}}}, + } + }, + }, + } From 5ba043357a4c7f40be82cb984097d9094389cbe8 Mon Sep 17 00:00:00 2001 From: tarsil Date: Sun, 17 Nov 2024 18:32:57 +0100 Subject: [PATCH 06/24] Implement security dependency for OAuth and OpenId --- docs/en/docs/references/injector.md | 2 - esmerald/__init__.py | 3 +- esmerald/openapi/openapi.py | 23 +- esmerald/openapi/security/oauth2/base.py | 49 ---- .../openapi/security/openid_connect/base.py | 27 +- esmerald/param_functions.py | 14 - esmerald/params.py | 25 -- esmerald/security/base.py | 16 + esmerald/security/oauth2/__init__.py | 2 - esmerald/security/oauth2/oauth.py | 276 +++++++----------- esmerald/security/open_id/__init__.py | 3 + esmerald/security/open_id/openid_connect.py | 70 +++++ esmerald/transformers/model.py | 22 +- esmerald/transformers/signature.py | 4 +- .../test_security_oauth2_optional_desc.py | 167 +++++++++++ tests/security/openid/__init__.py | 0 .../openid/test_security_openid_connect.py | 112 +++++++ ...est_security_openid_connect_description.py | 117 ++++++++ 18 files changed, 636 insertions(+), 296 deletions(-) create mode 100644 esmerald/security/base.py create mode 100644 esmerald/security/open_id/__init__.py create mode 100644 esmerald/security/open_id/openid_connect.py create mode 100644 tests/security/oauth/test_security_oauth2_optional_desc.py create mode 100644 tests/security/openid/__init__.py create mode 100644 tests/security/openid/test_security_openid_connect.py create mode 100644 tests/security/openid/test_security_openid_connect_description.py diff --git a/docs/en/docs/references/injector.md b/docs/en/docs/references/injector.md index 9bd6a8e7..7e442eae 100644 --- a/docs/en/docs/references/injector.md +++ b/docs/en/docs/references/injector.md @@ -24,8 +24,6 @@ from esmerald import Inject, Injects, Factory, DiderectInjects - "!^__call__" - "!^__eq__" -::: esmerald.DirectInjects - ::: esmerald.Factory options: filters: diff --git a/esmerald/__init__.py b/esmerald/__init__.py index 0713b0b1..62a817eb 100644 --- a/esmerald/__init__.py +++ b/esmerald/__init__.py @@ -23,7 +23,7 @@ ValidationErrorException, ) from .interceptors.interceptor import EsmeraldInterceptor -from .param_functions import DirectInjects, Security +from .param_functions import Security from .params import Body, Cookie, File, Form, Header, Injects, Param, Path, Query from .permissions import AllowAny, BasePermission, DenyAll from .pluggables import Extension, Pluggable @@ -62,7 +62,6 @@ "Cookie", "DaoProtocol", "DenyAll", - "DirectInjects", "Esmerald", "EsmeraldAPISettings", "EsmeraldInterceptor", diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index 0fd234e2..62fb777f 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -47,12 +47,13 @@ get_schema_from_model_field, is_status_code_allowed, ) -from esmerald.params import Param +from esmerald.params import Param, Security from esmerald.routing import gateways, router from esmerald.routing._internal import ( convert_annotation_to_pydantic_model, ) from esmerald.security.oauth2.oauth import SecurityBase +from esmerald.transformers.model import ParamSetting from esmerald.typing import Undefined from esmerald.utils.helpers import is_class_and_subclass, is_union @@ -61,6 +62,15 @@ TRANSFORMER_TYPES_KEYS += ADDITIONAL_TYPES +def is_security_scheme(param: ParamSetting) -> bool: + """ + Checks if the object is a security scheme. + """ + if not param.field_info.default: + return False + return isinstance(param.field_info.default, Security) + + def get_flat_params(route: Union[router.HTTPHandler, Any], body_fields: List[str]) -> List[Any]: """ Gets all the neded params of the request and route. @@ -83,17 +93,20 @@ def get_flat_params(route: Union[router.HTTPHandler, Any], body_fields: List[str # Making sure all the optional and union types are included if is_union_or_optional: - # field_info = should_skip_json_schema(param.field_info) - query_params.append(param.field_info) + if not is_security_scheme(param): + query_params.append(param.field_info) else: - if isinstance(param.field_info.annotation, _GenericAlias): + if isinstance(param.field_info.annotation, _GenericAlias) and not is_security_scheme( + param + ): query_params.append(param.field_info) elif ( param.field_info.annotation.__class__.__name__ in TRANSFORMER_TYPES_KEYS or param.field_info.annotation.__name__ in TRANSFORMER_TYPES_KEYS ): - query_params.append(param.field_info) + if not is_security_scheme(param): + query_params.append(param.field_info) return path_params + query_params + cookie_params + header_params diff --git a/esmerald/openapi/security/oauth2/base.py b/esmerald/openapi/security/oauth2/base.py index 66065e56..6761c759 100644 --- a/esmerald/openapi/security/oauth2/base.py +++ b/esmerald/openapi/security/oauth2/base.py @@ -1,53 +1,4 @@ -from typing import Any, Dict, Literal, Optional, Union - -from esmerald.openapi.enums import SecuritySchemeType -from esmerald.openapi.models import OAuthFlows -from esmerald.openapi.security.base import HTTPBase from esmerald.security.oauth2.oauth import OAuth2 as BaseOAuth2 class OAuth2(BaseOAuth2): ... - - -class AOAuth2(HTTPBase): - """ - The OAuth2 scheme. - - For every parameter of the OAuthFlows, expects a OAuthFlow object type. - - Example: - implicit: Optional[OAuthFlow] = OAuthFlow() - password: Optional[OAuthFlow] = OAuthFlow() - clientCredentials: Optional[OAuthFlow] = OAuthFlow() - authorizationCode: Optional[OAuthFlow] = OAuthFlow() - - flows: OAuthFlows( - implicit=implicit, - password=password, - clientCredentials=clientCredentials, - authorizationCode=authorizationCode, - ) - """ - - def __init__( - self, - *, - type_: Literal[ - "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" - ] = SecuritySchemeType.oauth2.value, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - name: Optional[str] = None, - flows: Union[OAuthFlows, Dict[str, Dict[str, Any]]] = OAuthFlows(), - **kwargs: Any, - ): - extra: Dict[Any, Any] = {} - extra["flows"] = flows - extra.update(kwargs) - super().__init__( - type_=type_, - description=description, - name=name, - scheme_name=scheme_name or self.__class__.__name__, - **extra, - ) diff --git a/esmerald/openapi/security/openid_connect/base.py b/esmerald/openapi/security/openid_connect/base.py index 706976cc..7c66a9f8 100644 --- a/esmerald/openapi/security/openid_connect/base.py +++ b/esmerald/openapi/security/openid_connect/base.py @@ -1,27 +1,4 @@ -from typing import Any, Literal, Optional, Union +from esmerald.security.open_id import OpenIdConnect as BaseOpenIdConnect -from pydantic import AnyUrl -from esmerald.openapi.enums import SecuritySchemeType -from esmerald.openapi.security.base import HTTPBase - - -class OpenIdConnect(HTTPBase): - def __init__( - self, - *, - type_: Literal[ - "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" - ] = SecuritySchemeType.openIdConnect.value, - openIdConnectUrl: Optional[Union[AnyUrl, str]] = None, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - **kwargs: Any, - ): - super().__init__( - type_=type_, - description=description, - scheme_name=scheme_name or self.__class__.__name__, - openIdConnectUrl=openIdConnectUrl, - **kwargs, - ) +class OpenIdConnect(BaseOpenIdConnect): ... diff --git a/esmerald/param_functions.py b/esmerald/param_functions.py index ebd575c9..c94e6fbd 100644 --- a/esmerald/param_functions.py +++ b/esmerald/param_functions.py @@ -4,7 +4,6 @@ from esmerald import params from esmerald.enums import EncodingType -from esmerald.params import DirectInject from esmerald.typing import Undefined _PyUndefined: Any = Undefined @@ -23,19 +22,6 @@ def Security( return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache) -def DirectInjects( - dependency: Optional[Callable[..., Any]] = None, - *, - use_cache: bool = True, - allow_none: bool = True, -) -> Any: - """ - This function should be only called if Inject/Injects is not used in the dependencies. - This is a simple wrapper of the classic Inject(). - """ - return DirectInject(dependency=dependency, use_cache=use_cache, allow_none=allow_none) - - def Form( default: Any = _PyUndefined, *, diff --git a/esmerald/params.py b/esmerald/params.py index a3e22957..31022bbc 100644 --- a/esmerald/params.py +++ b/esmerald/params.py @@ -1,6 +1,5 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Union -from pydantic.dataclasses import dataclass from pydantic.fields import AliasChoices, AliasPath, FieldInfo from esmerald.enums import EncodingType, ParamType @@ -628,30 +627,6 @@ def __init__( super().__init__(default=default, json_schema_extra=self.extra) -@dataclass -class DirectInject: # pragma: no cover - def __init__( - self, - dependency: Optional[Callable[..., Any]] = None, - *, - use_cache: bool = True, - allow_none: bool = True, - ) -> None: - self.dependency = dependency - self.use_cache = use_cache - self.allow_none = allow_none - - def __hash__(self) -> int: - values: Dict[str, Any] = {} - for key, value in self.__dict__.items(): - values[key] = None - if isinstance(value, (list, set)): - values[key] = tuple(value) - else: - values[key] = value - return hash((type(self),) + tuple(values)) - - class Depends: def __init__(self, dependency: Optional[Callable[..., Any]] = None, *, use_cache: bool = True): self.dependency = dependency diff --git a/esmerald/security/base.py b/esmerald/security/base.py new file mode 100644 index 00000000..81867bab --- /dev/null +++ b/esmerald/security/base.py @@ -0,0 +1,16 @@ +from typing import Optional + +from esmerald.openapi.models import ( + SecurityScheme, +) + + +class SecurityBase(SecurityScheme): + scheme_name: Optional[str] = None + """ + An optional name for the security scheme. + """ + __auto_error__: bool = False + """ + A flag to indicate if automatic error handling should be enabled. + """ diff --git a/esmerald/security/oauth2/__init__.py b/esmerald/security/oauth2/__init__.py index 46141baa..f3d1d82f 100644 --- a/esmerald/security/oauth2/__init__.py +++ b/esmerald/security/oauth2/__init__.py @@ -4,7 +4,6 @@ OAuth2PasswordBearer, OAuth2PasswordRequestForm, OAuth2PasswordRequestFormStrict, - SecurityScopes, ) __all__ = [ @@ -13,5 +12,4 @@ "OAuth2PasswordBearer", "OAuth2PasswordRequestForm", "OAuth2PasswordRequestFormStrict", - "SecurityScopes", ] diff --git a/esmerald/security/oauth2/oauth.py b/esmerald/security/oauth2/oauth.py index ebd404b1..d3835478 100644 --- a/esmerald/security/oauth2/oauth.py +++ b/esmerald/security/oauth2/oauth.py @@ -2,30 +2,20 @@ from lilya.requests import Request from lilya.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN +from pydantic import BaseModel, field_validator from typing_extensions import Annotated, Doc from esmerald.exceptions import HTTPException from esmerald.openapi.models import ( OAuth2 as OAuth2Model, OAuthFlows as OAuthFlowsModel, - SecurityScheme, ) from esmerald.param_functions import Form +from esmerald.security.base import SecurityBase as SecurityBase from esmerald.security.utils import get_authorization_scheme_param -class SecurityBase(SecurityScheme): - scheme_name: Optional[str] = None - """ - An optional name for the security scheme. - """ - __auto_error__: bool = False - """ - A flag to indicate if automatic error handling should be enabled. - """ - - -class OAuth2PasswordRequestForm: +class OAuth2PasswordRequestForm(BaseModel): """ This is a dependency class to collect the `username` and `password` as form data for an OAuth2 password flow. @@ -75,110 +65,109 @@ def login(form_data: Annotated[OAuth2PasswordRequestForm, Injects()]) -> dict: know that that it is application specific, it's not part of the specification. """ - def __init__( - self, - *, - grant_type: Annotated[ - Union[str, None], - Form(pattern="password"), - Doc( - """ - Specifies the OAuth2 grant type. - - Per the OAuth2 specification, this value is required and must be set - to the fixed string "password" when using the password grant flow. - However, this class allows flexibility and does not enforce this - restriction. To enforce the "password" value strictly, consider using - the `OAuth2PasswordRequestFormStrict` dependency. - """ - ), - ] = None, - username: Annotated[ - str, - Form(), - Doc( - """ - The username of the user for OAuth2 authentication. - - According to the OAuth2 specification, this field must be named - `username`, as it is used to identify the user during the - authentication process. - """ - ), - ], - password: Annotated[ - str, - Form(), - Doc( - """ - The password of the user for OAuth2 authentication. - - Per the OAuth2 spec, this field must also use the name `password`. - It is required for authentication to validate the provided username. - """ - ), - ], - scope: Annotated[ - str, - Form(), - Doc( - """ - A single string containing one or more scopes, space-separated. - - Each scope represents a permission requested by the application. - Scopes help specify fine-grained access control, enabling the client - to request only the permissions it needs. For example, the following - string: - - ```python - "items:read items:write users:read profile openid" - ``` - - represents multiple scopes: - - * `items:read` - * `items:write` - * `users:read` - * `profile` - * `openid` - """ - ), - ] = "", - client_id: Annotated[ - Union[str, None], - Form(), - Doc( - """ - Optional client identifier used to identify the client application. - - If provided, `client_id` can be sent as part of the form data. - Although the OAuth2 specification recommends sending both `client_id` - and `client_secret` via HTTP Basic authentication headers, some APIs - accept these values in the form fields for flexibility. - """ - ), - ] = None, - client_secret: Annotated[ - Union[str, None], - Form(), - Doc( - """ - Optional client secret for authenticating the client application. - - If a `client_secret` is required (along with `client_id`), it can be - included in the form data. However, the OAuth2 spec advises sending - both `client_id` and `client_secret` using HTTP Basic authentication - headers for security. - """ - ), - ] = None, - ) -> None: - self.grant_type = grant_type - self.username = username - self.password = password - self.scopes = scope.split() - self.client_id = client_id - self.client_secret = client_secret + grant_type: Annotated[ + Union[str, None], + Form(pattern="password"), + Doc( + """ + Specifies the OAuth2 grant type. + + Per the OAuth2 specification, this value is required and must be set + to the fixed string "password" when using the password grant flow. + However, this class allows flexibility and does not enforce this + restriction. To enforce the "password" value strictly, consider using + the `OAuth2PasswordRequestFormStrict` dependency. + """ + ), + ] = None + username: Annotated[ + str, + Form(), + Doc( + """ + The username of the user for OAuth2 authentication. + + According to the OAuth2 specification, this field must be named + `username`, as it is used to identify the user during the + authentication process. + """ + ), + ] + password: Annotated[ + str, + Form(), + Doc( + """ + The password of the user for OAuth2 authentication. + + Per the OAuth2 spec, this field must also use the name `password`. + It is required for authentication to validate the provided username. + """ + ), + ] + scopes: Annotated[ + Union[str, List[str]], + Form(), + Doc( + """ + A single string containing one or more scopes, space-separated. + + Each scope represents a permission requested by the application. + Scopes help specify fine-grained access control, enabling the client + to request only the permissions it needs. For example, the following + string: + + ```python + "items:read items:write users:read profile openid" + ``` + + represents multiple scopes: + + * `items:read` + * `items:write` + * `users:read` + * `profile` + * `openid` + """ + ), + ] = [] + client_id: Annotated[ + Union[str, None], + Form(), + Doc( + """ + Optional client identifier used to identify the client application. + + If provided, `client_id` can be sent as part of the form data. + Although the OAuth2 specification recommends sending both `client_id` + and `client_secret` via HTTP Basic authentication headers, some APIs + accept these values in the form fields for flexibility. + """ + ), + ] = None + client_secret: Annotated[ + Union[str, None], + Form(), + Doc( + """ + Optional client secret for authenticating the client application. + + If a `client_secret` is required (along with `client_id`), it can be + included in the form data. However, the OAuth2 spec advises sending + both `client_id` and `client_secret` using HTTP Basic authentication + headers for security. + """ + ), + ] = None + + @field_validator("scopes", mode="before") + @classmethod + def validate_scopes(cls, value: Union[str, List[str]]) -> Any: + if isinstance(value, str) and len(value) == 0: + return [] + if isinstance(value, str): + return value.split(" ") + return value class OAuth2PasswordRequestFormStrict(OAuth2PasswordRequestForm): @@ -333,7 +322,7 @@ def __init__( grant_type=grant_type, username=username, password=password, - scope=scope, + scopes=scope, client_id=client_id, client_secret=client_secret, ) @@ -670,54 +659,3 @@ async def __call__(self, request: Request) -> Optional[str]: ) return None - - -class SecurityScopes: - """ - Represents a collection of OAuth2 scopes required across multiple dependencies. - - `SecurityScopes` is used as a parameter in a dependency to aggregate the OAuth2 - scopes required by all dependent components within the same request. This allows - different dependencies to specify varying scopes even when used together in the - same *path operation*, and provides access to all required scopes in one place - for streamlined authorization handling. - """ - - def __init__( - self, - scopes: Annotated[ - Optional[List[str]], - Doc( - """ - A list of OAuth2 scopes needed for the current request, populated by Esmerald. - - This list will automatically include all required scopes based on the - dependencies in the request, ensuring all necessary permissions are available. - """ - ), - ] = None, - ): - self.scopes: Annotated[ - List[str], - Doc( - """ - List of all OAuth2 scopes required by dependencies for this request. - - This list consolidates the individual scopes required by each dependency, - providing a comprehensive view of the permissions needed for access. - """ - ), - ] = scopes or [] - - self.scope_str: Annotated[ - str, - Doc( - """ - A single string of all required scopes, space-separated as per OAuth2 specification. - - This string is useful for passing scopes in formats where a single, - space-delimited string is required, such as in OAuth2-compliant authorization - requests or access tokens. - """ - ), - ] = " ".join(self.scopes) diff --git a/esmerald/security/open_id/__init__.py b/esmerald/security/open_id/__init__.py new file mode 100644 index 00000000..e302d729 --- /dev/null +++ b/esmerald/security/open_id/__init__.py @@ -0,0 +1,3 @@ +from .openid_connect import OpenIdConnect + +__all__ = ["OpenIdConnect"] diff --git a/esmerald/security/open_id/openid_connect.py b/esmerald/security/open_id/openid_connect.py new file mode 100644 index 00000000..ad6bea66 --- /dev/null +++ b/esmerald/security/open_id/openid_connect.py @@ -0,0 +1,70 @@ +from typing import Any, Optional + +from lilya.exceptions import HTTPException +from lilya.requests import Request +from lilya.status import HTTP_403_FORBIDDEN +from typing_extensions import Annotated, Doc + +from esmerald.openapi.models import OpenIdConnect as OpenIdConnectModel +from esmerald.security.base import SecurityBase as SecurityBase + + +class OpenIdConnect(SecurityBase): + def __init__( + self, + *, + openIdConnectUrl: Annotated[ + str, + Doc( + """ + The OpenID Connect URL. + """ + ), + ], + scheme_name: Annotated[ + Optional[str], + Doc( + """ + The name of the security scheme. + """ + ), + ] = None, + description: Annotated[ + Optional[str], + Doc( + """ + A description of the security scheme. + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + Determines the behavior when the HTTP Authorization header is missing. + + If set to `True` (default), the request will be automatically canceled and an error will be sent to the client if the header is not provided. + + If set to `False`, the dependency result will be `None` when the header is not available, allowing for optional authentication or multiple authentication methods (e.g., OpenID Connect or a cookie). + """ + ), + ] = True, + ): + model = OpenIdConnectModel( + openIdConnectUrl=openIdConnectUrl, description=description, scheme=scheme_name + ) + model_dump = model.model_dump() + super().__init__(**model_dump) + self.scheme_name = scheme_name or self.__class__.__name__ + self.__auto_error__ = auto_error + + async def __call__(self, request: Request) -> Any: + authorization = request.headers.get("Authorization") + + if authorization: + return authorization + + if self.__auto_error__: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + + return None diff --git a/esmerald/transformers/model.py b/esmerald/transformers/model.py index 24ef16c4..44dcc5bc 100644 --- a/esmerald/transformers/model.py +++ b/esmerald/transformers/model.py @@ -5,7 +5,7 @@ from esmerald.context import Context from esmerald.enums import EncodingType, ParamType from esmerald.exceptions import ImproperlyConfigured -from esmerald.params import Body +from esmerald.params import Body, Security from esmerald.parsers import ArbitraryExtraBaseModel, parse_form_data from esmerald.requests import Request from esmerald.transformers.signature import SignatureModel @@ -193,8 +193,22 @@ def get_request_context( """ return Context(__handler__=handler, __request__=request) + async def get_for_security_dependencies( + self, connection: Union["Request", "WebSocket"], kwargs: Any + ) -> Any: + """ + Checks if the class has security dependencies. + + Returns: + bool: True if security dependencies are present, False otherwise. + """ + for name, dependency in kwargs.items(): + if isinstance(dependency, Security): + kwargs[name] = await dependency.dependency(connection) + return kwargs + async def get_dependencies( - self, dependency: Dependency, connection: Union["WebSocket", Request], **kwargs: Any + self, dependency: Dependency, connection: Union["WebSocket", "Request"], **kwargs: Any ) -> Any: """ Get dependencies asynchronously. @@ -212,6 +226,10 @@ async def get_dependencies( kwargs[_dependency.key] = await self.get_dependencies( dependency=_dependency, connection=connection, **kwargs ) + + if kwargs: + kwargs = await self.get_for_security_dependencies(connection, kwargs) + dependency_kwargs = signature_model.parse_values_for_connection( connection=connection, **kwargs ) diff --git a/esmerald/transformers/signature.py b/esmerald/transformers/signature.py index 865ab02d..6cd18122 100644 --- a/esmerald/transformers/signature.py +++ b/esmerald/transformers/signature.py @@ -178,7 +178,9 @@ def encode_value(encoder: "Encoder", annotation: Any, value: Any) -> Any: encoder_info: Dict[str, "Encoder"] = cls.encoders[key] # type: ignore encoder: "Encoder" = encoder_info["encoder"] annotation = encoder_info["annotation"] - kwargs[key] = encode_value(encoder, annotation, value) + kwargs[key] = ( + encode_value(encoder, annotation, value) if value is not None else value + ) return kwargs diff --git a/tests/security/oauth/test_security_oauth2_optional_desc.py b/tests/security/oauth/test_security_oauth2_optional_desc.py new file mode 100644 index 00000000..c28d7724 --- /dev/null +++ b/tests/security/oauth/test_security_oauth2_optional_desc.py @@ -0,0 +1,167 @@ +from typing import Any, Dict, Optional, Union + +from pydantic import BaseModel, __version__ + +from esmerald import Gateway, Inject, Injects, Security, get, post +from esmerald.security.oauth2 import OAuth2, OAuth2PasswordRequestFormStrict +from esmerald.testclient import create_client + +pydantic_version = __version__[:3] + +reusable_oauth2 = OAuth2( + flows={ + "password": { + "tokenUrl": "token", + "scopes": {"read:users": "Read the users", "write:users": "Create users"}, + } + }, + description="OAuth2 security scheme", + auto_error=False, +) + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: Union[str, Any] = Security(reusable_oauth2)): + if oauth_header is None: + return None + user = User(username=oauth_header) + return user + + +@post( + "/login", + security=[reusable_oauth2], + dependencies={"form_data": Inject(OAuth2PasswordRequestFormStrict)}, +) +def login(form_data: OAuth2PasswordRequestFormStrict = Injects()) -> Dict[str, Any]: + return form_data + + +@get( + "/users/me", + dependencies={"current_user": Inject(get_current_user)}, + security=[reusable_oauth2], +) +def read_users_me(current_user: Optional[User] = Injects()) -> Dict[str, Any]: + if current_user is None: + return {"msg": "Create an account first"} + return current_user + + +def test_security_oauth2(): + with create_client( + routes=[Gateway(handler=read_users_me)], security=[reusable_oauth2] + ) as client: + response = client.get("/users/me", headers={"Authorization": "Bearer footokenbar"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "Bearer footokenbar"} + + +def test_security_oauth2_password_other_header(): + with create_client( + routes=[Gateway(handler=read_users_me)], security=[reusable_oauth2] + ) as client: + response = client.get("/users/me", headers={"Authorization": "Other footokenbar"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "Other footokenbar"} + + +def test_security_oauth2_password_bearer_no_header(): + with create_client( + routes=[Gateway(handler=read_users_me)], security=[reusable_oauth2] + ) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_strict_login_None(): + with create_client(routes=[Gateway(handler=login)], security=[reusable_oauth2]) as client: + response = client.post("/login", data=None) + assert response.status_code == 400 + assert response.json() == { + "detail": "Validation failed for http://testserver/login with method POST.", + "errors": [ + { + "type": "string_type", + "loc": ["grant_type"], + "msg": "Input should be a valid string", + "input": None, + "url": f"https://errors.pydantic.dev/{pydantic_version}/v/string_type", + }, + { + "type": "string_type", + "loc": ["username"], + "msg": "Input should be a valid string", + "input": None, + "url": f"https://errors.pydantic.dev/{pydantic_version}/v/string_type", + }, + { + "type": "string_type", + "loc": ["password"], + "msg": "Input should be a valid string", + "input": None, + "url": f"https://errors.pydantic.dev/{pydantic_version}/v/string_type", + }, + ], + } + + +def test_strict_login_no_grant_type(): + with create_client(routes=[Gateway(handler=login)], security=[reusable_oauth2]) as client: + response = client.post("/login", json={"username": "johndoe", "password": "secret"}) + assert response.status_code == 400 + assert response.json() == { + "detail": "Validation failed for http://testserver/login with method POST.", + "errors": [ + { + "type": "string_type", + "loc": ["grant_type"], + "msg": "Input should be a valid string", + "input": None, + "url": f"https://errors.pydantic.dev/{pydantic_version}/v/string_type", + } + ], + } + + +def test_strict_login_incorrect_grant_type(): + with create_client(routes=[Gateway(handler=login)], security=[reusable_oauth2]) as client: + response = client.post( + "/login", + json={"username": "johndoe", "password": "secret", "grant_type": "incorrect"}, + ) + assert response.status_code == 400 + assert response.json() == { + "detail": "Validation failed for http://testserver/login with method POST.", + "errors": [ + { + "type": "string_pattern_mismatch", + "loc": ["grant_type"], + "msg": "String should match pattern 'password'", + "input": "incorrect", + "ctx": {"pattern": "password"}, + "url": f"https://errors.pydantic.dev/{pydantic_version}/v/string_pattern_mismatch", + } + ], + } + + +def test_strict_login_correct_correct_grant_type(): + with create_client(routes=[Gateway(handler=login)], security=[reusable_oauth2]) as client: + response = client.post( + "/login", + json={"username": "johndoe", "password": "secret", "grant_type": "password"}, + ) + assert response.status_code == 201, response.text + assert response.json() == { + "grant_type": "password", + "username": "johndoe", + "password": "secret", + "scopes": [], + "client_id": None, + "client_secret": None, + } diff --git a/tests/security/openid/__init__.py b/tests/security/openid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/security/openid/test_security_openid_connect.py b/tests/security/openid/test_security_openid_connect.py new file mode 100644 index 00000000..3d75acac --- /dev/null +++ b/tests/security/openid/test_security_openid_connect.py @@ -0,0 +1,112 @@ +from typing import Any + +from pydantic import BaseModel + +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.open_id import OpenIdConnect +from esmerald.testclient import create_client + +oid = OpenIdConnect(openIdConnectUrl="/openid") + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: str = Security(oid)): + user = User(username=oauth_header) + return user + + +@get("/users/me", security=[oid], dependencies={"current_user": Inject(get_current_user)}) +def read_current_user(current_user: User = Injects()) -> Any: + return current_user + + +def test_security_oauth2(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me", headers={"Authorization": "Bearer footokenbar"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "Bearer footokenbar"} + + +def test_security_oauth2_password_other_header(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me", headers={"Authorization": "Other footokenbar"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "Other footokenbar"} + + +def test_security_oauth2_password_bearer_no_header(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "OpenIdConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "/openid", + "scheme_name": "OpenIdConnect", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "OpenIdConnect": {"type": "openIdConnect", "openIdConnectUrl": "/openid"} + } + }, + } diff --git a/tests/security/openid/test_security_openid_connect_description.py b/tests/security/openid/test_security_openid_connect_description.py new file mode 100644 index 00000000..5a3b496c --- /dev/null +++ b/tests/security/openid/test_security_openid_connect_description.py @@ -0,0 +1,117 @@ +from typing import Any + +from pydantic import BaseModel + +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.open_id import OpenIdConnect +from esmerald.testclient import create_client + +oid = OpenIdConnect(openIdConnectUrl="/openid", description="OpenIdConnect security scheme") + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: str = Security(oid)): + user = User(username=oauth_header) + return user + + +@get("/users/me", security=[oid], dependencies={"current_user": Inject(get_current_user)}) +def read_current_user(current_user: User = Injects()) -> Any: + return current_user + + +def test_security_oauth2(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me", headers={"Authorization": "Bearer footokenbar"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "Bearer footokenbar"} + + +def test_security_oauth2_password_other_header(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me", headers={"Authorization": "Other footokenbar"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "Other footokenbar"} + + +def test_security_oauth2_password_bearer_no_header(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "OpenIdConnect": { + "type": "openIdConnect", + "description": "OpenIdConnect security scheme", + "openIdConnectUrl": "/openid", + "scheme_name": "OpenIdConnect", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "OpenIdConnect": { + "type": "openIdConnect", + "description": "OpenIdConnect security scheme", + "openIdConnectUrl": "/openid", + } + } + }, + } From 7b9b7bac2f8adfe4a41178b991fadf72a9ba2395 Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Sun, 17 Nov 2024 20:33:12 +0100 Subject: [PATCH 07/24] Update param_functions.py --- esmerald/param_functions.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esmerald/param_functions.py b/esmerald/param_functions.py index c94e6fbd..81928669 100644 --- a/esmerald/param_functions.py +++ b/esmerald/param_functions.py @@ -22,6 +22,17 @@ def Security( return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache) +def DirectInjects( + dependency: Optional[Callable[..., Any]] = None, + *, + use_cache: bool = True, +) -> Any: + """ + This function should be only called if Inject/Injects is not used in the dependencies. + This is a simple wrapper of the classic Depends(). + """ + return params.Depends(dependency=dependency, use_cache=use_cache) + def Form( default: Any = _PyUndefined, *, From d245b9418d1da2fd9f8d843dd5a402c6edb9ceae Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Sun, 17 Nov 2024 20:46:20 +0100 Subject: [PATCH 08/24] Change signature.py --- esmerald/transformers/signature.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esmerald/transformers/signature.py b/esmerald/transformers/signature.py index 6cd18122..865ab02d 100644 --- a/esmerald/transformers/signature.py +++ b/esmerald/transformers/signature.py @@ -178,9 +178,7 @@ def encode_value(encoder: "Encoder", annotation: Any, value: Any) -> Any: encoder_info: Dict[str, "Encoder"] = cls.encoders[key] # type: ignore encoder: "Encoder" = encoder_info["encoder"] annotation = encoder_info["annotation"] - kwargs[key] = ( - encode_value(encoder, annotation, value) if value is not None else value - ) + kwargs[key] = encode_value(encoder, annotation, value) return kwargs From 1dc3cd5aeb3fb2ff475261f6480b494a33ed85e3 Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Sun, 17 Nov 2024 20:54:11 +0100 Subject: [PATCH 09/24] Update test_security_oauth2_optional_desc.py --- tests/security/oauth/test_security_oauth2_optional_desc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/security/oauth/test_security_oauth2_optional_desc.py b/tests/security/oauth/test_security_oauth2_optional_desc.py index c28d7724..4628ff26 100644 --- a/tests/security/oauth/test_security_oauth2_optional_desc.py +++ b/tests/security/oauth/test_security_oauth2_optional_desc.py @@ -69,7 +69,7 @@ def test_security_oauth2_password_other_header(): assert response.json() == {"username": "Other footokenbar"} -def test_security_oauth2_password_bearer_no_header(): +def xtest_security_oauth2_password_bearer_no_header(): with create_client( routes=[Gateway(handler=read_users_me)], security=[reusable_oauth2] ) as client: From e94ac3b6be6aebf0d3cc86ee98feb0884342d791 Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 18 Nov 2024 10:32:27 +0100 Subject: [PATCH 10/24] Fix optional data payload encoding validation --- esmerald/openapi/openapi.py | 19 ++----- esmerald/param_functions.py | 4 +- esmerald/params.py | 46 ++++++++++++++++- esmerald/transformers/signature.py | 23 ++++++++- esmerald/utils/dependencies.py | 20 ++++++++ esmerald/utils/dependency.py | 15 ------ esmerald/utils/schema.py | 25 ++++++++++ pyproject.toml | 2 +- .../test_injects_with_fastapi_examples.py | 18 +++---- tests/encoding/test_encoder_optional.py | 50 +++++++++++++++++++ .../test_security_oauth2_optional_desc.py | 2 +- 11 files changed, 179 insertions(+), 45 deletions(-) create mode 100644 esmerald/utils/dependencies.py delete mode 100644 esmerald/utils/dependency.py create mode 100644 tests/encoding/test_encoder_optional.py diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index 62fb777f..c220fd74 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -47,14 +47,14 @@ get_schema_from_model_field, is_status_code_allowed, ) -from esmerald.params import Param, Security +from esmerald.params import Param from esmerald.routing import gateways, router from esmerald.routing._internal import ( convert_annotation_to_pydantic_model, ) from esmerald.security.oauth2.oauth import SecurityBase -from esmerald.transformers.model import ParamSetting from esmerald.typing import Undefined +from esmerald.utils.dependencies import is_security_scheme from esmerald.utils.helpers import is_class_and_subclass, is_union ADDITIONAL_TYPES = ["bool", "list", "dict"] @@ -62,15 +62,6 @@ TRANSFORMER_TYPES_KEYS += ADDITIONAL_TYPES -def is_security_scheme(param: ParamSetting) -> bool: - """ - Checks if the object is a security scheme. - """ - if not param.field_info.default: - return False - return isinstance(param.field_info.default, Security) - - def get_flat_params(route: Union[router.HTTPHandler, Any], body_fields: List[str]) -> List[Any]: """ Gets all the neded params of the request and route. @@ -93,19 +84,19 @@ def get_flat_params(route: Union[router.HTTPHandler, Any], body_fields: List[str # Making sure all the optional and union types are included if is_union_or_optional: - if not is_security_scheme(param): + if not is_security_scheme(param.field_info.default): query_params.append(param.field_info) else: if isinstance(param.field_info.annotation, _GenericAlias) and not is_security_scheme( - param + param.field_info.default ): query_params.append(param.field_info) elif ( param.field_info.annotation.__class__.__name__ in TRANSFORMER_TYPES_KEYS or param.field_info.annotation.__name__ in TRANSFORMER_TYPES_KEYS ): - if not is_security_scheme(param): + if not is_security_scheme(param.field_info.default): query_params.append(param.field_info) return path_params + query_params + cookie_params + header_params diff --git a/esmerald/param_functions.py b/esmerald/param_functions.py index 81928669..b7c1c6d4 100644 --- a/esmerald/param_functions.py +++ b/esmerald/param_functions.py @@ -22,7 +22,7 @@ def Security( return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache) -def DirectInjects( +def Requires( dependency: Optional[Callable[..., Any]] = None, *, use_cache: bool = True, @@ -31,7 +31,7 @@ def DirectInjects( This function should be only called if Inject/Injects is not used in the dependencies. This is a simple wrapper of the classic Depends(). """ - return params.Depends(dependency=dependency, use_cache=use_cache) + return params.Requires(dependency=dependency, use_cache=use_cache) def Form( default: Any = _PyUndefined, diff --git a/esmerald/params.py b/esmerald/params.py index 31022bbc..39169a1e 100644 --- a/esmerald/params.py +++ b/esmerald/params.py @@ -627,8 +627,33 @@ def __init__( super().__init__(default=default, json_schema_extra=self.extra) -class Depends: +class Requires: + """ + A class that represents a requirement with an optional dependency and caching behavior. + + Attributes: + dependency (Optional[Callable[..., Any]]): An optional callable that represents the dependency. + use_cache (bool): A flag indicating whether to use caching for the dependency. Defaults to True. + + Methods: + __repr__(): Returns a string representation of the Requires instance. + """ + def __init__(self, dependency: Optional[Callable[..., Any]] = None, *, use_cache: bool = True): + """ + Initializes a Requires instance. + + Args: + dependency (Optional[Callable[..., Any]]): An optional callable that represents the dependency. + use_cache (bool): A flag indicating whether to use caching for the dependency. Defaults to True. + """ + + """ + Returns a string representation of the Requires instance. + + Returns: + str: A string representation of the Requires instance. + """ self.dependency = dependency self.use_cache = use_cache @@ -638,7 +663,24 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({attr}{cache})" -class Security(Depends): +class Security(Requires): + """ + A class used to represent security requirements for a particular operation. + + Attributes: + ---------- + dependency : Optional[Callable[..., Any]] + A callable that represents the dependency required for security. + scopes : Optional[Sequence[str]] + A sequence of scopes required for the security. Defaults to an empty list. + use_cache : bool + A flag indicating whether to use cache. Defaults to True. + + Methods: + ------- + __init__(self, dependency: Optional[Callable[..., Any]] = None, *, scopes: Optional[Sequence[str]] = None, use_cache: bool = True) + Initializes the Security class with the given dependency, scopes, and use_cache flag. + """ def __init__( self, dependency: Optional[Callable[..., Any]] = None, diff --git a/esmerald/transformers/signature.py b/esmerald/transformers/signature.py index 865ab02d..e46a2ec2 100644 --- a/esmerald/transformers/signature.py +++ b/esmerald/transformers/signature.py @@ -18,6 +18,7 @@ from orjson import loads from pydantic import ValidationError, create_model +from pydantic.fields import FieldInfo from esmerald.encoders import ENCODER_TYPES, Encoder from esmerald.exceptions import ( @@ -31,8 +32,9 @@ from esmerald.transformers.constants import CLASS_SPECIAL_WORDS, UNDEFINED, VALIDATION_NAMES from esmerald.transformers.utils import get_connection_info, get_field_definition_from_param from esmerald.typing import Undefined -from esmerald.utils.dependency import is_dependency_field, should_skip_dependency_validation +from esmerald.utils.constants import IS_DEPENDENCY, SKIP_VALIDATION from esmerald.utils.helpers import is_optional_union +from esmerald.utils.schema import extract_arguments from esmerald.websockets import WebSocket if TYPE_CHECKING: @@ -59,6 +61,16 @@ def is_server_error(error: Any, klass: Type["SignatureModel"]) -> bool: return False +def is_dependency_field(val: Any) -> bool: + json_schema_extra = getattr(val, "json_schema_extra", None) or {} + return bool(isinstance(val, FieldInfo) and bool(json_schema_extra.get(IS_DEPENDENCY))) + + +def should_skip_dependency_validation(val: Any) -> bool: + json_schema_extra = getattr(val, "json_schema_extra", None) or {} + return bool(is_dependency_field(val) and json_schema_extra.get(SKIP_VALIDATION)) + + class Parameter(ArbitraryBaseModel): """ Represents a function parameter with associated metadata. @@ -178,6 +190,15 @@ def encode_value(encoder: "Encoder", annotation: Any, value: Any) -> Any: encoder_info: Dict[str, "Encoder"] = cls.encoders[key] # type: ignore encoder: "Encoder" = encoder_info["encoder"] annotation = encoder_info["annotation"] + + if is_optional_union(annotation) and not value: + kwargs[key] = None + continue + + if is_optional_union(annotation) and value: + decoded_list = extract_arguments(annotation) + annotation = decoded_list[0] # type: ignore + kwargs[key] = encode_value(encoder, annotation, value) return kwargs diff --git a/esmerald/utils/dependencies.py b/esmerald/utils/dependencies.py new file mode 100644 index 00000000..4086ef53 --- /dev/null +++ b/esmerald/utils/dependencies.py @@ -0,0 +1,20 @@ +from typing import Any + +from esmerald import params +from esmerald.utils.helpers import is_class_and_subclass + + +def is_requires_scheme(param: Any) -> bool: + """ + Checks if the object is a security scheme. + """ + return is_class_and_subclass(param, params.Requires) + + +def is_security_scheme(param: Any) -> bool: + """ + Checks if the object is a security scheme. + """ + if not param: + return False + return isinstance(param, params.Security) diff --git a/esmerald/utils/dependency.py b/esmerald/utils/dependency.py deleted file mode 100644 index effd96d0..00000000 --- a/esmerald/utils/dependency.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Any - -from pydantic.fields import FieldInfo - -from esmerald.utils.constants import IS_DEPENDENCY, SKIP_VALIDATION - - -def is_dependency_field(val: Any) -> bool: - json_schema_extra = getattr(val, "json_schema_extra", None) or {} - return bool(isinstance(val, FieldInfo) and bool(json_schema_extra.get(IS_DEPENDENCY))) - - -def should_skip_dependency_validation(val: Any) -> bool: - json_schema_extra = getattr(val, "json_schema_extra", None) or {} - return bool(is_dependency_field(val) and json_schema_extra.get(SKIP_VALIDATION)) diff --git a/esmerald/utils/schema.py b/esmerald/utils/schema.py index 80ab8610..1ce25c50 100644 --- a/esmerald/utils/schema.py +++ b/esmerald/utils/schema.py @@ -52,3 +52,28 @@ def should_skip_json_schema(field_info: Union[FieldInfo, Any]) -> FieldInfo: arguments = tuple(arguments) # type: ignore field_info.annotation = Union[arguments] return field_info + + +def extract_arguments( + param: Union[Any, None] = None, argument_list: Union[List[Any], None] = None +) -> List[Type[type]]: + """ + Recursively extracts unique types from a parameter's type annotation. + + Args: + param (Union[Parameter, None], optional): The parameter with type annotation to extract from. + argument_list (Union[List[Any], None], optional): The list of arguments extracted so far. + + Returns: + List[Type[type]]: A list of unique types extracted from the parameter's type annotation. + """ + arguments: List[Any] = list(argument_list) if argument_list is not None else [] + args = get_args(param) + + for arg in args: + if isinstance(arg, _GenericAlias): + arguments.extend(extract_arguments(param=arg, argument_list=arguments)) + else: + if arg not in arguments: + arguments.append(arg) + return arguments diff --git a/pyproject.toml b/pyproject.toml index 1fa287b8..731a5610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ testing = [ "ujson>=5.7.0,<6", "anyio[trio]>=3.6.2,<5.0.0", "brotli>=1.0.9,<2.0.0", - "edgy[postgres]>=0.16.0", + "edgy[postgres]>=0.21.0", "databasez>=0.9.7", "flask>=1.1.2,<4.0.0", "freezegun>=1.2.2,<2.0.0", diff --git a/tests/dependencies/test_injects_with_fastapi_examples.py b/tests/dependencies/test_injects_with_fastapi_examples.py index 3a050834..b0fe40d5 100644 --- a/tests/dependencies/test_injects_with_fastapi_examples.py +++ b/tests/dependencies/test_injects_with_fastapi_examples.py @@ -3,7 +3,7 @@ import pytest from esmerald import Esmerald, Gateway, get -from esmerald.param_functions import DirectInjects +from esmerald.param_functions import Requires from esmerald.testclient import EsmeraldTestClient @@ -49,53 +49,53 @@ async def asynchronous_gen(self, value: str) -> AsyncGenerator[str, None]: @get("/callable-dependency") -async def get_callable_dependency(value: str = DirectInjects(callable_dependency)) -> str: +async def get_callable_dependency(value: str = Requires(callable_dependency)) -> str: return value @get("/callable-gen-dependency") -async def get_callable_gen_dependency(value: str = DirectInjects(callable_gen_dependency)) -> str: +async def get_callable_gen_dependency(value: str = Requires(callable_gen_dependency)) -> str: return value @get("/async-callable-dependency") async def get_async_callable_dependency( - value: str = DirectInjects(async_callable_dependency), + value: str = Requires(async_callable_dependency), ) -> str: return value @get("/async-callable-gen-dependency") async def get_async_callable_gen_dependency( - value: str = DirectInjects(async_callable_gen_dependency), + value: str = Requires(async_callable_gen_dependency), ) -> str: return value @get("/synchronous-method-dependency") async def get_synchronous_method_dependency( - value: str = DirectInjects(methods_dependency.synchronous), + value: str = Requires(methods_dependency.synchronous), ) -> str: return value @get("/synchronous-method-gen-dependency") async def get_synchronous_method_gen_dependency( - value: str = DirectInjects(methods_dependency.synchronous_gen), + value: str = Requires(methods_dependency.synchronous_gen), ) -> str: return value @get("/asynchronous-method-dependency") async def get_asynchronous_method_dependency( - value: str = DirectInjects(methods_dependency.asynchronous), + value: str = Requires(methods_dependency.asynchronous), ) -> str: return value @get("/asynchronous-method-gen-dependency") async def get_asynchronous_method_gen_dependency( - value: str = DirectInjects(methods_dependency.asynchronous_gen), + value: str = Requires(methods_dependency.asynchronous_gen), ) -> str: return value diff --git a/tests/encoding/test_encoder_optional.py b/tests/encoding/test_encoder_optional.py new file mode 100644 index 00000000..6ffa226b --- /dev/null +++ b/tests/encoding/test_encoder_optional.py @@ -0,0 +1,50 @@ +from typing import Any, Optional + +from pydantic import BaseModel + +from esmerald import Gateway, post +from esmerald.testclient import create_client + + +class User(BaseModel): + username: str + + +@post("/optional") +async def create(data: Optional[User]) -> Any: + return data if data else {} + + +def test_optional(): + with create_client(routes=[Gateway(handler=create)]) as client: + response = client.post("/optional", json={"username": "test"}) + assert response.status_code == 201 + assert response.json() == {"username": "test"} + + response = client.post("/optional", json={}) + assert response.status_code == 201 + assert response.json() == {} + + response = client.post("/optional") + assert response.status_code == 201 + assert response.json() == {} + + +@post("/union") +async def create_union(data: Optional[User]) -> Any: + return data if data else {} + + +def test_union(): + with create_client(routes=[Gateway(handler=create_union)]) as client: + response = client.post("/union", json={"username": "test"}) + assert response.status_code == 201 + assert response.json() == {"username": "test"} + + response = client.post("/union", json={}) + assert response.status_code == 201 + assert response.json() == {} + + response = client.post("/union") + assert response.status_code == 201 + assert response.json() == {} diff --git a/tests/security/oauth/test_security_oauth2_optional_desc.py b/tests/security/oauth/test_security_oauth2_optional_desc.py index 4628ff26..c28d7724 100644 --- a/tests/security/oauth/test_security_oauth2_optional_desc.py +++ b/tests/security/oauth/test_security_oauth2_optional_desc.py @@ -69,7 +69,7 @@ def test_security_oauth2_password_other_header(): assert response.json() == {"username": "Other footokenbar"} -def xtest_security_oauth2_password_bearer_no_header(): +def test_security_oauth2_password_bearer_no_header(): with create_client( routes=[Gateway(handler=read_users_me)], security=[reusable_oauth2] ) as client: From 2af226bd006ff92134b3879f7293d9abf7d855e9 Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 18 Nov 2024 10:40:56 +0100 Subject: [PATCH 11/24] Add extra test for union with different payload data --- tests/encoding/test_encoder_optional.py | 44 +++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/encoding/test_encoder_optional.py b/tests/encoding/test_encoder_optional.py index 6ffa226b..d4f95309 100644 --- a/tests/encoding/test_encoder_optional.py +++ b/tests/encoding/test_encoder_optional.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, Union from pydantic import BaseModel @@ -31,7 +31,7 @@ def test_optional(): @post("/union") -async def create_union(data: Optional[User]) -> Any: +async def create_union(data: Union[User, None]) -> Any: return data if data else {} @@ -48,3 +48,43 @@ def test_union(): response = client.post("/union") assert response.status_code == 201 assert response.json() == {} + + +@post("/optional-one") +async def create_one(test: Optional[User]) -> Any: + return test if test else {} + + +def test_optional_one(): + with create_client(routes=[Gateway(handler=create_one)]) as client: + response = client.post("/optional-one", json={"test": {"username": "test"}}) + assert response.status_code == 201 + assert response.json() == {"username": "test"} + + response = client.post("/optional-one", json={}) + assert response.status_code == 201 + assert response.json() == {} + + response = client.post("/optional-one") + assert response.status_code == 201 + assert response.json() == {} + + +@post("/union-one") +async def create_union_one(test: Union[User, None]) -> Any: + return test if test else {} + + +def test_union_one(): + with create_client(routes=[Gateway(handler=create_union_one)]) as client: + response = client.post("/union-one", json={"test": {"username": "test"}}) + assert response.status_code == 201 + assert response.json() == {"username": "test"} + + response = client.post("/union-one", json={}) + assert response.status_code == 201 + assert response.json() == {} + + response = client.post("/union-one") + assert response.status_code == 201 + assert response.json() == {} From 9213a2d4b865178f3e1ca00dcb1a33204165a9fd Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 18 Nov 2024 14:33:46 +0100 Subject: [PATCH 12/24] Add new API tests --- esmerald/openapi/security/api_key/base.py | 75 +--- esmerald/security/api_key/__init__.py | 3 + esmerald/security/api_key/api_key.py | 212 +++++++++ esmerald/security/http/__init__.py | 15 + esmerald/security/http/http.py | 415 ++++++++++++++++++ esmerald/security/oauth2/oauth.py | 3 +- tests/_security/open_api_classes/__init__.py | 0 .../open_api_classes/test_security_all.py | 281 ------------ .../test_security_api_key_in_cookie.py | 162 ------- .../test_security_api_key_in_header.py | 164 ------- .../test_security_api_key_in_query.py | 162 ------- .../open_api_classes/test_security_digest.py | 163 ------- .../test_security_token_bearer.py | 163 ------- tests/_security/openapi_normal/__init__.py | 0 .../openapi_normal/test_security_all.py | 250 ----------- .../test_security_api_key_in_cookie.py | 156 ------- .../test_security_api_key_in_header.py | 156 ------- .../test_security_api_key_in_query.py | 156 ------- .../openapi_normal/test_security_digest.py | 160 ------- .../test_security_token_bearer.py | 161 ------- .../{_security => security/http}/__init__.py | 0 .../http/test_security_api_key_cookie.py | 99 +++++ ...est_security_api_key_cookie_description.py | 105 +++++ .../test_security_api_key_cookie_optional.py} | 149 ++++--- .../http/test_security_api_key_header.py | 104 +++++ ...est_security_api_key_header_description.py | 105 +++++ .../test_security_api_key_header_optional.py} | 144 +++--- .../http/test_security_api_key_query.py | 66 +++ ...test_security_api_key_query_description.py | 110 +++++ .../test_security_api_key_query_optional.py | 72 +++ 30 files changed, 1468 insertions(+), 2343 deletions(-) create mode 100644 esmerald/security/api_key/__init__.py create mode 100644 esmerald/security/api_key/api_key.py create mode 100644 esmerald/security/http/__init__.py create mode 100644 esmerald/security/http/http.py delete mode 100644 tests/_security/open_api_classes/__init__.py delete mode 100644 tests/_security/open_api_classes/test_security_all.py delete mode 100644 tests/_security/open_api_classes/test_security_api_key_in_cookie.py delete mode 100644 tests/_security/open_api_classes/test_security_api_key_in_header.py delete mode 100644 tests/_security/open_api_classes/test_security_api_key_in_query.py delete mode 100644 tests/_security/open_api_classes/test_security_digest.py delete mode 100644 tests/_security/open_api_classes/test_security_token_bearer.py delete mode 100644 tests/_security/openapi_normal/__init__.py delete mode 100644 tests/_security/openapi_normal/test_security_all.py delete mode 100644 tests/_security/openapi_normal/test_security_api_key_in_cookie.py delete mode 100644 tests/_security/openapi_normal/test_security_api_key_in_header.py delete mode 100644 tests/_security/openapi_normal/test_security_api_key_in_query.py delete mode 100644 tests/_security/openapi_normal/test_security_digest.py delete mode 100644 tests/_security/openapi_normal/test_security_token_bearer.py rename tests/{_security => security/http}/__init__.py (100%) create mode 100644 tests/security/http/test_security_api_key_cookie.py create mode 100644 tests/security/http/test_security_api_key_cookie_description.py rename tests/{_security/openapi_normal/test_security_basic.py => security/http/test_security_api_key_cookie_optional.py} (53%) create mode 100644 tests/security/http/test_security_api_key_header.py create mode 100644 tests/security/http/test_security_api_key_header_description.py rename tests/{_security/open_api_classes/test_security_basic.py => security/http/test_security_api_key_header_optional.py} (54%) create mode 100644 tests/security/http/test_security_api_key_query.py create mode 100644 tests/security/http/test_security_api_key_query_description.py create mode 100644 tests/security/http/test_security_api_key_query_optional.py diff --git a/esmerald/openapi/security/api_key/base.py b/esmerald/openapi/security/api_key/base.py index cdfecdab..c6f8e183 100644 --- a/esmerald/openapi/security/api_key/base.py +++ b/esmerald/openapi/security/api_key/base.py @@ -1,73 +1,14 @@ -from typing import Any, Literal, Optional +from esmerald.security.api_key import ( + APIKeyInCookie as BaseAPIKeyInCookie, + APIKeyInHeader as BaseAPIKeyInHeader, + APIKeyInQuery as BaseAPIKeyInQuery, +) -from esmerald.openapi.enums import APIKeyIn, SecuritySchemeType -from esmerald.openapi.security.base import HTTPBase +class APIKeyInQuery(BaseAPIKeyInQuery): ... -class APIKeyInQuery(HTTPBase): - def __init__( - self, - *, - type_: Literal[ - "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" - ] = SecuritySchemeType.apiKey.value, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - in_: Optional[Literal["query", "header", "cookie"]] = APIKeyIn.query.value, - name: Optional[str] = None, - **kwargs: Any, - ): - super().__init__( - type_=type_, - description=description, - name=name, - in_=in_, - scheme_name=scheme_name or self.__class__.__name__, - **kwargs, - ) +class APIKeyInHeader(BaseAPIKeyInHeader): ... -class APIKeyInHeader(HTTPBase): - def __init__( - self, - *, - type_: Literal[ - "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" - ] = SecuritySchemeType.apiKey.value, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - in_: Optional[Literal["query", "header", "cookie"]] = APIKeyIn.header.value, - name: Optional[str] = None, - **kwargs: Any, - ): - super().__init__( - type_=type_, - description=description, - name=name, - in_=in_, - scheme_name=scheme_name or self.__class__.__name__, - **kwargs, - ) - -class APIKeyInCookie(HTTPBase): - def __init__( - self, - *, - type_: Literal[ - "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" - ] = SecuritySchemeType.apiKey.value, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - in_: Optional[Literal["query", "header", "cookie"]] = APIKeyIn.cookie.value, - name: Optional[str] = None, - **kwargs: Any, - ): - super().__init__( - type_=type_, - description=description, - name=name, - in_=in_, - scheme_name=scheme_name or self.__class__.__name__, - **kwargs, - ) +class APIKeyInCookie(BaseAPIKeyInCookie): ... diff --git a/esmerald/security/api_key/__init__.py b/esmerald/security/api_key/__init__.py new file mode 100644 index 00000000..ceba86c5 --- /dev/null +++ b/esmerald/security/api_key/__init__.py @@ -0,0 +1,3 @@ +from .api_key import APIKeyInCookie, APIKeyInHeader, APIKeyInQuery + +__all__ = ["APIKeyInCookie", "APIKeyInHeader", "APIKeyInQuery"] diff --git a/esmerald/security/api_key/api_key.py b/esmerald/security/api_key/api_key.py new file mode 100644 index 00000000..c27270a8 --- /dev/null +++ b/esmerald/security/api_key/api_key.py @@ -0,0 +1,212 @@ +from typing import Union, cast + +from lilya.exceptions import HTTPException +from lilya.status import HTTP_403_FORBIDDEN +from pydantic import BaseModel +from typing_extensions import Annotated, Doc + +from esmerald.openapi.models import APIKey, APIKeyIn +from esmerald.requests import Request +from esmerald.security.base import SecurityBase + + +class APIKeyBase(SecurityBase): + __model__: Union[BaseModel, None] = None + + +class APIKeyInQuery(APIKeyBase): + """ + API key authentication using a query parameter. + + Defines the query parameter name for the API key and integrates it into the OpenAPI documentation. + Extracts the key value from the query parameter and provides it as the dependency result. + + ## Usage + + Create an instance and use it as a dependency in `Inject()`. + + The dependency result will be a string containing the key value after using the `Injects()`. + + ## Example + + ```python + from esmerald import Esmerald, Gateway, get, Inject, Injects + from esmerald.security.api_key import APIKeyInQuery + + query_scheme = APIKeyInQuery(name="api_key") + + @get("/items/", dependencies={"api_key": Inject(query_scheme)}) + async def read_items(api_key: str = Injects()) -> dict[str, str]: + return {"api_key": api_key} + ``` + """ + + def __init__( + self, + *, + name: Annotated[str, Doc("Name of the query parameter.")], + scheme_name: Annotated[ + Union[str, None], + Doc("Name of the security scheme, shown in OpenAPI documentation."), + ] = None, + description: Annotated[ + Union[str, None], + Doc("Description of the security scheme, shown in OpenAPI documentation."), + ] = None, + auto_error: Annotated[ + bool, + Doc( + "If True, raises an error if the query parameter is missing. " + "If False, returns None when the query parameter is missing." + ), + ] = True, + ): + model: APIKey = APIKey( + **{"in": APIKeyIn.query.value}, # type: ignore[arg-type] + name=name, + description=description, + ) + super().__init__(**model.model_dump()) + self.__model__ = model + self.scheme_name = scheme_name or self.__class__.__name__ + self.__auto_error__ = auto_error + + async def __call__(self, request: Request) -> Union[str, None]: + api_key = request.query_params.get(self.__model__.name) + if api_key: + return cast(str, api_key) + if self.__auto_error__: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + return None + + +class APIKeyInHeader(APIKeyBase): + """ + API key authentication using a header parameter. + + Defines the header parameter name for the API key and integrates it into the OpenAPI documentation. + Extracts the key value from the header parameter and provides it as the dependency result. + + ## Usage + + Create an instance and use it as a dependency in `Inject()`. + + The dependency result will be a string containing the key value after using the `Injects()`. + + ## Example + + ```python + from esmerald import Esmerald, Gateway, get, Inject, Injects + from esmerald.security.api_key import APIKeyInHeader + + header_scheme = APIKeyInHeader(name="x-key") + + @get("/items/", dependencies={"api_key": Inject(header_scheme)}) + async def read_items(api_key: str = Injects()) -> dict[str, str]: + return {"api_key": api_key} + ``` + """ + + def __init__( + self, + *, + name: Annotated[str, Doc("The name of the header parameter.")], + scheme_name: Annotated[ + Union[str, None], + Doc("The name of the security scheme to be shown in the OpenAPI documentation."), + ] = None, + description: Annotated[ + Union[str, None], + Doc("A description of the security scheme to be shown in the OpenAPI documentation."), + ] = None, + auto_error: Annotated[ + bool, + Doc( + "If True, an error is raised if the header is missing. " + "If False, None is returned when the header is missing." + ), + ] = True, + ): + model: APIKey = APIKey( + **{"in": APIKeyIn.header.value}, # type: ignore[arg-type] + name=name, + description=description, + ) + super().__init__(**model.model_dump()) + self.__model__ = model + self.scheme_name = scheme_name or self.__class__.__name__ + self.__auto_error__ = auto_error + + async def __call__(self, request: Request) -> Union[str, None]: + api_key = request.headers.get(self.__model__.name) + if api_key: + return cast(str, api_key) + if self.__auto_error__: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + return None + + +class APIKeyInCookie(APIKeyBase): + """ + API key authentication using a cookie parameter. + + Defines the cookie parameter name for the API key and integrates it into the OpenAPI documentation. + Extracts the key value from the cookie parameter and provides it as the dependency result. + + ## Usage + + Create an instance and use it as a dependency in `Inject()`. + + The dependency result will be a string containing the key value after using the `Injects()`. + + ## Example + + ```python + from esmerald import Esmerald, Gateway, get, Inject, Injects + from esmerald.security.api_key import APIKeyInCookie + + cookie_scheme = APIKeyInCookie(name="session") + + @get("/items/", dependencies={"api_key": Inject(cookie_scheme)}) + async def read_items(api_key: str = Injects()) -> dict[str, str]: + return {"api_key": api_key} + ``` + """ + + def __init__( + self, + *, + name: Annotated[str, Doc("The name of the cookie parameter.")], + scheme_name: Annotated[ + Union[str, None], + Doc("The name of the security scheme to be shown in the OpenAPI documentation."), + ] = None, + description: Annotated[ + Union[str, None], + Doc("A description of the security scheme to be shown in the OpenAPI documentation."), + ] = None, + auto_error: Annotated[ + bool, + Doc( + "If True, an error is raised if the cookie is missing. " + "If False, None is returned when the cookie is missing." + ), + ] = True, + ): + model: APIKey = APIKey( + **{"in": APIKeyIn.cookie.value}, # type: ignore[arg-type] + name=name, + description=description, + ) + super().__init__(**model.model_dump()) + self.__model__ = model + self.scheme_name = scheme_name or self.__class__.__name__ + self.__auto_error__ = auto_error + + async def __call__(self, request: Request) -> Union[str, None]: + api_key = request.cookies.get(self.__model__.name) + if api_key: + return api_key + if self.__auto_error__: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + return None diff --git a/esmerald/security/http/__init__.py b/esmerald/security/http/__init__.py new file mode 100644 index 00000000..7663700c --- /dev/null +++ b/esmerald/security/http/__init__.py @@ -0,0 +1,15 @@ +from .http import ( + HTTPAuthorizationCredentials, + HTTPBasic, + HTTPBasicCredentials, + HTTPBearer, + HTTPDigest, +) + +__all__ = [ + "HTTPBasic", + "HTTPBearer", + "HTTPDigest", + "HTTPAuthorizationCredentials", + "HTTPBasicCredentials", +] diff --git a/esmerald/security/http/http.py b/esmerald/security/http/http.py new file mode 100644 index 00000000..ce728e91 --- /dev/null +++ b/esmerald/security/http/http.py @@ -0,0 +1,415 @@ +import binascii +from base64 import b64decode +from typing import Optional, Union + +from lilya.requests import Request +from lilya.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN +from pydantic import BaseModel +from typing_extensions import Annotated, Doc + +from esmerald.exceptions import HTTPException +from esmerald.openapi.models import HTTPBase as HTTPBaseModel, HTTPBearer as HTTPBearerModel +from esmerald.security.base import SecurityBase +from esmerald.security.utils import get_authorization_scheme_param + + +class HTTPBasicCredentials(BaseModel): + """ + The HTTP Basic credentials given as the result of using `HTTPBasic` in a + dependency. + """ + + username: Annotated[str, Doc("The HTTP Basic username.")] + password: Annotated[str, Doc("The HTTP Basic password.")] + + +class HTTPAuthorizationCredentials(BaseModel): + """ + The HTTP authorization credentials in the result of using `HTTPBearer` or + `HTTPDigest` in a dependency. + + The HTTP authorization header value is split by the first space. + + The first part is the `scheme`, the second part is the `credentials`. + + For example, in an HTTP Bearer token scheme, the client will send a header + like: + + ``` + Authorization: Bearer deadbeef12346 + ``` + + In this case: + + * `scheme` will have the value `"Bearer"` + * `credentials` will have the value `"deadbeef12346"` + """ + + scheme: Annotated[ + str, + Doc( + """ + The HTTP authorization scheme extracted from the header value. + """ + ), + ] + credentials: Annotated[ + str, + Doc( + """ + The HTTP authorization credentials extracted from the header value. + """ + ), + ] + + +class HTTPBase(SecurityBase): + def __init__( + self, + *, + scheme: str, + scheme_name: Union[str, None] = None, + description: Union[str, None] = None, + auto_error: bool = True, + ): + model = HTTPBaseModel(scheme=scheme, description=description) + super().__init__(**model.model_dump()) + self.scheme_name = scheme_name or self.__class__.__name__ + self.__auto_error__ = auto_error + + async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]: + authorization = request.headers.get("Authorization") + if not authorization: + if self.__auto_error__: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + return None + + scheme, credentials = get_authorization_scheme_param(authorization) + if not (scheme and credentials): + if self.__auto_error__: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, detail="Invalid authentication credentials" + ) + return None + + return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) + + +class HTTPBasic(HTTPBase): + """ + HTTP Basic authentication. + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be an `HTTPBasicCredentials` object containing the + `username` and the `password`. + + ## Example + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + from fastapi.security import HTTPBasic, HTTPBasicCredentials + + app = FastAPI() + + security = HTTPBasic() + + + @app.get("/users/me") + def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]): + return {"username": credentials.username, "password": credentials.password} + ``` + """ + + def __init__( + self, + *, + scheme_name: Annotated[ + Union[str, None], + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + realm: Annotated[ + Union[str, None], + Doc( + """ + HTTP Basic authentication realm. + """ + ), + ] = None, + description: Annotated[ + Union[str, None], + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the HTTP Basic authentication is not provided (a + header), `HTTPBasic` will automatically cancel the request and send the + client an error. + + If `auto_error` is set to `False`, when the HTTP Basic authentication + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in HTTP Basic + authentication or in an HTTP Bearer token). + """ + ), + ] = True, + ): + model = HTTPBaseModel(scheme="basic", description=description) + super().__init__(**model.model_dump()) + self.scheme_name = scheme_name or self.__class__.__name__ + self.realm = realm + self.__auto_error__ = auto_error + + async def __call__(self, request: Request) -> Optional[HTTPBasicCredentials]: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + + unauthorized_headers = { + "WWW-Authenticate": f'Basic realm="{self.realm}"' if self.realm else "Basic" + } + + if not authorization or scheme.lower() != "basic": + if self.__auto_error__: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers=unauthorized_headers, + ) + return None + + try: + data = b64decode(param).decode("ascii") + username, separator, password = data.partition(":") + if not separator: + raise ValueError("Invalid credentials format") + except (ValueError, UnicodeDecodeError, binascii.Error): + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers=unauthorized_headers, + ) from None + + return HTTPBasicCredentials(username=username, password=password) + + +class HTTPBearer(HTTPBase): + """ + HTTP Bearer token authentication. + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be an `HTTPAuthorizationCredentials` object containing + the `scheme` and the `credentials`. + + ## Example + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + + app = FastAPI() + + security = HTTPBearer() + + + @app.get("/users/me") + def read_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)] + ): + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + ``` + """ + + def __init__( + self, + *, + bearerFormat: Annotated[Union[str, None], Doc("Bearer token format.")] = None, + scheme_name: Annotated[ + Union[str, None], + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + Union[str, None], + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the HTTP Bearer token is not provided (in an + `Authorization` header), `HTTPBearer` will automatically cancel the + request and send the client an error. + + If `auto_error` is set to `False`, when the HTTP Bearer token + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in an HTTP + Bearer token or in a cookie). + """ + ), + ] = True, + ): + model = HTTPBearerModel(bearerFormat=bearerFormat, description=description) + super().__init__(**model.model_dump()) + self.scheme_name = scheme_name or self.__class__.__name__ + self.__auto_error__ = auto_error + + async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]: + authorization = request.headers.get("Authorization") + if not authorization: + if self.auto_error: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + return None + + scheme, credentials = get_authorization_scheme_param(authorization) + if not (scheme and credentials) or scheme.lower() != "bearer": + if self.__auto_error__: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Invalid authentication credentials", + ) + return None + + return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) + + +class HTTPDigest(HTTPBase): + """ + HTTP Digest authentication. + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be an `HTTPAuthorizationCredentials` object containing + the `scheme` and the `credentials`. + + ## Example + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + from fastapi.security import HTTPAuthorizationCredentials, HTTPDigest + + app = FastAPI() + + security = HTTPDigest() + + + @app.get("/users/me") + def read_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)] + ): + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + ``` + """ + + def __init__( + self, + *, + scheme_name: Annotated[ + Union[str, None], + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + Union[str, None], + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the HTTP Digest is not provided, `HTTPDigest` will + automatically cancel the request and send the client an error. + + If `auto_error` is set to `False`, when the HTTP Digest is not + available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in HTTP + Digest or in a cookie). + """ + ), + ] = True, + ): + model = HTTPBaseModel(scheme="digest", description=description) + super().__init__(**model.model_dump()) + self.scheme_name = scheme_name or self.__class__.__name__ + self.__auto_error__ = auto_error + + async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]: + authorization = request.headers.get("Authorization") + if not authorization: + if self.__auto_error__: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + return None + + scheme, credentials = get_authorization_scheme_param(authorization) + if not (scheme and credentials) or scheme.lower() != "digest": + if self.__auto_error__: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Invalid authentication credentials", + ) + return None + + return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) diff --git a/esmerald/security/oauth2/oauth.py b/esmerald/security/oauth2/oauth.py index d3835478..e44d2679 100644 --- a/esmerald/security/oauth2/oauth.py +++ b/esmerald/security/oauth2/oauth.py @@ -393,8 +393,7 @@ def __init__( model = OAuth2Model( flows=cast(OAuthFlowsModel, flows), scheme=scheme_name, description=description ) - model_dump = model.model_dump() - super().__init__(**model_dump) + super().__init__(**model.model_dump()) self.scheme_name = scheme_name or self.__class__.__name__ self.__auto_error__ = auto_error diff --git a/tests/_security/open_api_classes/__init__.py b/tests/_security/open_api_classes/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/_security/open_api_classes/test_security_all.py b/tests/_security/open_api_classes/test_security_all.py deleted file mode 100644 index 1db991e0..00000000 --- a/tests/_security/open_api_classes/test_security_all.py +++ /dev/null @@ -1,281 +0,0 @@ -from typing import Dict, List, Union - -from pydantic import BaseModel - -from esmerald import APIView, Gateway, JSONResponse, get -from esmerald.openapi.security.api_key import APIKeyInCookie, APIKeyInHeader, APIKeyInQuery -from esmerald.openapi.security.http import Basic, Bearer, Digest -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -def test_security_api_key_in_cookie(): - class TestAPI(APIView): - security = [ - Basic, - Basic(), - Bearer, - Bearer(), - Digest, - Digest(), - APIKeyInHeader, - APIKeyInHeader(), - APIKeyInHeader(name="X_TOKEN_HEADER"), - APIKeyInQuery, - APIKeyInQuery(), - APIKeyInQuery(name="X_TOKEN_QUERY"), - APIKeyInCookie, - APIKeyInCookie(), - APIKeyInCookie(name="X_TOKEN_COOKIE"), - ] - - @get("/{pk:int}", response_class=JsonResponse) - def read_people(self, pk: int) -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=TestAPI)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings, - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/{pk}": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapi_read_people__pk__get", - "deprecated": False, - "security": [ - { - "Basic": { - "type": "http", - "name": "Basic", - "in": "header", - "scheme": "basic", - "scheme_name": "Basic", - } - }, - { - "Basic": { - "type": "http", - "name": "Basic", - "in": "header", - "scheme": "basic", - "scheme_name": "Basic", - } - }, - { - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - "scheme_name": "Bearer", - } - }, - { - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - "scheme_name": "Bearer", - } - }, - { - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - "scheme_name": "Digest", - } - }, - { - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - "scheme_name": "Digest", - } - }, - { - "APIKeyInHeader": { - "type": "apiKey", - "in": "header", - "scheme_name": "APIKeyInHeader", - } - }, - { - "APIKeyInHeader": { - "type": "apiKey", - "in": "header", - "scheme_name": "APIKeyInHeader", - } - }, - { - "APIKeyInHeader": { - "type": "apiKey", - "name": "X_TOKEN_HEADER", - "in": "header", - "scheme_name": "APIKeyInHeader", - } - }, - { - "APIKeyInQuery": { - "type": "apiKey", - "in": "query", - "scheme_name": "APIKeyInQuery", - } - }, - { - "APIKeyInQuery": { - "type": "apiKey", - "in": "query", - "scheme_name": "APIKeyInQuery", - } - }, - { - "APIKeyInQuery": { - "type": "apiKey", - "name": "X_TOKEN_QUERY", - "in": "query", - "scheme_name": "APIKeyInQuery", - } - }, - { - "APIKeyInCookie": { - "type": "apiKey", - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - }, - { - "APIKeyInCookie": { - "type": "apiKey", - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - }, - { - "APIKeyInCookie": { - "type": "apiKey", - "name": "X_TOKEN_COOKIE", - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - }, - ], - "parameters": [ - { - "name": "pk", - "in": "path", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": {"type": "integer", "title": "Pk"}, - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, - }, - } - } - }, - "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, - "securitySchemes": { - "Basic": {"type": "http", "name": "Basic", "in": "header", "scheme": "basic"}, - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - }, - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - }, - "APIKeyInHeader": {"type": "apiKey", "name": "X_TOKEN_HEADER", "in": "header"}, - "APIKeyInQuery": {"type": "apiKey", "name": "X_TOKEN_QUERY", "in": "query"}, - "APIKeyInCookie": {"type": "apiKey", "name": "X_TOKEN_COOKIE", "in": "cookie"}, - }, - }, - } diff --git a/tests/_security/open_api_classes/test_security_api_key_in_cookie.py b/tests/_security/open_api_classes/test_security_api_key_in_cookie.py deleted file mode 100644 index 76e88002..00000000 --- a/tests/_security/open_api_classes/test_security_api_key_in_cookie.py +++ /dev/null @@ -1,162 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import APIView, Gateway, JSONResponse, get -from esmerald.openapi.security.api_key import APIKeyInCookie -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@pytest.mark.parametrize("auth", [APIKeyInCookie, APIKeyInCookie()]) -def test_security_api_key_in_cookie(auth): - class TestAPIView(APIView): - security = [auth] - - @get( - response_class=JsonResponse, - ) - def read_people(self) -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=TestAPIView)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings, - ) as client: - response = client.get("/openapi.json") - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInCookie": { - "type": "apiKey", - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": {"APIKeyInCookie": {"type": "apiKey", "in": "cookie"}} - }, - } - - -@pytest.mark.parametrize( - "token,value", - [ - (APIKeyInCookie(name="X_API_TOKEN"), "X_API_TOKEN"), - (APIKeyInCookie(name="X_TOKEN"), "X_TOKEN"), - (APIKeyInCookie(name="test"), "test"), - ], - ids=["x-api-token", "x-token", "test"], -) -def test_security_api_key_in_cookie_value(token, value): - - class TestAPIView(APIView): - security = [token] - - @get( - response_class=JsonResponse, - ) - def read_people(self) -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=TestAPIView)], - enable_openapi=True, - include_in_schema=True, - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInCookie": { - "type": "apiKey", - "name": value, - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": { - "APIKeyInCookie": {"type": "apiKey", "name": value, "in": "cookie"} - } - }, - } diff --git a/tests/_security/open_api_classes/test_security_api_key_in_header.py b/tests/_security/open_api_classes/test_security_api_key_in_header.py deleted file mode 100644 index 302c8a91..00000000 --- a/tests/_security/open_api_classes/test_security_api_key_in_header.py +++ /dev/null @@ -1,164 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import APIView, Gateway, JSONResponse, get -from esmerald.openapi.security.api_key import APIKeyInHeader -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@pytest.mark.parametrize("auth", [APIKeyInHeader, APIKeyInHeader()]) -def test_security_api_key_in_header(auth): - - class TestAPIView(APIView): - security = [auth] - - @get( - response_class=JsonResponse, - ) - def read_people(self) -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=TestAPIView)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings(), - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInHeader": { - "type": "apiKey", - "in": "header", - "scheme_name": "APIKeyInHeader", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": {"APIKeyInHeader": {"type": "apiKey", "in": "header"}} - }, - } - - -@pytest.mark.parametrize( - "token,value", - [ - (APIKeyInHeader(name="X_API_TOKEN"), "X_API_TOKEN"), - (APIKeyInHeader(name="X_TOKEN"), "X_TOKEN"), - (APIKeyInHeader(name="test"), "test"), - ], - ids=["x-api-token", "x-token", "test"], -) -def test_security_api_key_header_value(token, value): - - class TestAPIView(APIView): - security = [token] - - @get( - response_class=JsonResponse, - ) - def read_people(self) -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=TestAPIView)], - enable_openapi=True, - include_in_schema=True, - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInHeader": { - "type": "apiKey", - "name": value, - "in": "header", - "scheme_name": "APIKeyInHeader", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": { - "APIKeyInHeader": {"type": "apiKey", "name": value, "in": "header"} - } - }, - } diff --git a/tests/_security/open_api_classes/test_security_api_key_in_query.py b/tests/_security/open_api_classes/test_security_api_key_in_query.py deleted file mode 100644 index 79433e25..00000000 --- a/tests/_security/open_api_classes/test_security_api_key_in_query.py +++ /dev/null @@ -1,162 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import APIView, Gateway, JSONResponse, get -from esmerald.openapi.security.api_key import APIKeyInQuery -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@pytest.mark.parametrize("auth", [APIKeyInQuery, APIKeyInQuery()]) -def test_security_api_key_in_query(auth): - class TestAPIView(APIView): - security = [auth] - - @get( - response_class=JsonResponse, - ) - def read_people(self) -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=TestAPIView)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings, - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInQuery": { - "type": "apiKey", - "in": "query", - "scheme_name": "APIKeyInQuery", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": {"APIKeyInQuery": {"type": "apiKey", "in": "query"}} - }, - } - - -@pytest.mark.parametrize( - "token,value", - [ - (APIKeyInQuery(name="Authorization"), "Authorization"), - (APIKeyInQuery(name="X_TOKEN"), "X_TOKEN"), - (APIKeyInQuery(name="test"), "test"), - ], - ids=["x-api-token", "x-token", "test"], -) -def test_security_api_key_in_query_value(token, value): - class TestAPIView(APIView): - security = [token] - - @get( - response_class=JsonResponse, - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=TestAPIView)], - enable_openapi=True, - include_in_schema=True, - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInQuery": { - "type": "apiKey", - "name": value, - "in": "query", - "scheme_name": "APIKeyInQuery", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": { - "APIKeyInQuery": {"type": "apiKey", "name": value, "in": "query"} - } - }, - } diff --git a/tests/_security/open_api_classes/test_security_digest.py b/tests/_security/open_api_classes/test_security_digest.py deleted file mode 100644 index e9fb0f6f..00000000 --- a/tests/_security/open_api_classes/test_security_digest.py +++ /dev/null @@ -1,163 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import APIView, Gateway, JSONResponse, get -from esmerald.openapi.security.http import Digest -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@get("/item/{id}") -async def read_item(id: str) -> None: - """ """ - - -@pytest.mark.parametrize("auth", [Digest, Digest()]) -def test_security_digest(auth): - class TestAPIView(APIView): - security = [auth] - - @get( - response_class=JsonResponse, - ) - def read_people(self) -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_item), Gateway(handler=TestAPIView)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings(), - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/item/{id}": { - "get": { - "summary": "Read Item", - "description": "", - "operationId": "read_item_item__id__get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": {"type": "string", "title": "Id"}, - } - ], - "responses": { - "200": {"description": "Successful response"}, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, - }, - "deprecated": False, - } - }, - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - "scheme_name": "Digest", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - }, - }, - "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, - "securitySchemes": { - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - } - }, - }, - } diff --git a/tests/_security/open_api_classes/test_security_token_bearer.py b/tests/_security/open_api_classes/test_security_token_bearer.py deleted file mode 100644 index 1ef7a026..00000000 --- a/tests/_security/open_api_classes/test_security_token_bearer.py +++ /dev/null @@ -1,163 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import APIView, Gateway, JSONResponse, get -from esmerald.openapi.security.http import Bearer -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@get("/item/{id}") -async def read_item(id: str) -> None: - """ """ - - -@pytest.mark.parametrize("auth", [Bearer, Bearer()]) -def test_security_token_bearer(auth): - class TestAPIView(APIView): - security = [auth] - - @get( - response_class=JsonResponse, - ) - def read_people(self) -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_item), Gateway(handler=TestAPIView)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings, - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/item/{id}": { - "get": { - "summary": "Read Item", - "description": "", - "operationId": "read_item_item__id__get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": {"type": "string", "title": "Id"}, - } - ], - "responses": { - "200": {"description": "Successful response"}, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, - }, - "deprecated": False, - } - }, - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - "scheme_name": "Bearer", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - }, - }, - "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, - "securitySchemes": { - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - } - }, - }, - } diff --git a/tests/_security/openapi_normal/__init__.py b/tests/_security/openapi_normal/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/_security/openapi_normal/test_security_all.py b/tests/_security/openapi_normal/test_security_all.py deleted file mode 100644 index 7c8c9da9..00000000 --- a/tests/_security/openapi_normal/test_security_all.py +++ /dev/null @@ -1,250 +0,0 @@ -from typing import Dict, List, Union - -from pydantic import BaseModel - -from esmerald import Gateway, JSONResponse, get -from esmerald.openapi.security.api_key import APIKeyInCookie, APIKeyInHeader, APIKeyInQuery -from esmerald.openapi.security.http import Basic, Bearer, Digest -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -def test_security_api_key_in_cookie(): - @get( - response_class=JsonResponse, - security=[ - Basic, - Basic(), - Bearer, - Bearer(), - Digest, - Digest(), - APIKeyInHeader, - APIKeyInHeader(), - APIKeyInHeader(name="X_TOKEN_HEADER"), - APIKeyInQuery, - APIKeyInQuery(), - APIKeyInQuery(name="X_TOKEN_QUERY"), - APIKeyInCookie, - APIKeyInCookie(), - APIKeyInCookie(name="X_TOKEN_COOKIE"), - ], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings, - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "Basic": { - "type": "http", - "name": "Basic", - "in": "header", - "scheme": "basic", - "scheme_name": "Basic", - } - }, - { - "Basic": { - "type": "http", - "name": "Basic", - "in": "header", - "scheme": "basic", - "scheme_name": "Basic", - } - }, - { - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - "scheme_name": "Bearer", - } - }, - { - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - "scheme_name": "Bearer", - } - }, - { - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - "scheme_name": "Digest", - } - }, - { - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - "scheme_name": "Digest", - } - }, - { - "APIKeyInHeader": { - "type": "apiKey", - "in": "header", - "scheme_name": "APIKeyInHeader", - } - }, - { - "APIKeyInHeader": { - "type": "apiKey", - "in": "header", - "scheme_name": "APIKeyInHeader", - } - }, - { - "APIKeyInHeader": { - "type": "apiKey", - "name": "X_TOKEN_HEADER", - "in": "header", - "scheme_name": "APIKeyInHeader", - } - }, - { - "APIKeyInQuery": { - "type": "apiKey", - "in": "query", - "scheme_name": "APIKeyInQuery", - } - }, - { - "APIKeyInQuery": { - "type": "apiKey", - "in": "query", - "scheme_name": "APIKeyInQuery", - } - }, - { - "APIKeyInQuery": { - "type": "apiKey", - "name": "X_TOKEN_QUERY", - "in": "query", - "scheme_name": "APIKeyInQuery", - } - }, - { - "APIKeyInCookie": { - "type": "apiKey", - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - }, - { - "APIKeyInCookie": { - "type": "apiKey", - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - }, - { - "APIKeyInCookie": { - "type": "apiKey", - "name": "X_TOKEN_COOKIE", - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - }, - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": { - "Basic": { - "type": "http", - "name": "Basic", - "in": "header", - "scheme": "basic", - }, - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - }, - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - }, - "APIKeyInHeader": { - "type": "apiKey", - "name": "X_TOKEN_HEADER", - "in": "header", - }, - "APIKeyInQuery": { - "type": "apiKey", - "name": "X_TOKEN_QUERY", - "in": "query", - }, - "APIKeyInCookie": { - "type": "apiKey", - "name": "X_TOKEN_COOKIE", - "in": "cookie", - }, - } - }, - } diff --git a/tests/_security/openapi_normal/test_security_api_key_in_cookie.py b/tests/_security/openapi_normal/test_security_api_key_in_cookie.py deleted file mode 100644 index b36590af..00000000 --- a/tests/_security/openapi_normal/test_security_api_key_in_cookie.py +++ /dev/null @@ -1,156 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import Gateway, JSONResponse, get -from esmerald.openapi.security.api_key import APIKeyInCookie -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@pytest.mark.parametrize("auth", [APIKeyInCookie, APIKeyInCookie()]) -def test_security_api_key_in_cookie(auth): - @get( - response_class=JsonResponse, - security=[auth], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings, - ) as client: - response = client.get("/openapi.json") - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInCookie": { - "type": "apiKey", - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": {"APIKeyInCookie": {"type": "apiKey", "in": "cookie"}} - }, - } - - -@pytest.mark.parametrize( - "token,value", - [ - (APIKeyInCookie(name="X_API_TOKEN"), "X_API_TOKEN"), - (APIKeyInCookie(name="X_TOKEN"), "X_TOKEN"), - (APIKeyInCookie(name="test"), "test"), - ], - ids=["x-api-token", "x-token", "test"], -) -def test_security_api_key_in_cookie_value(token, value): - @get( - response_class=JsonResponse, - security=[token], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - ) as client: - response = client.get("/openapi.json") - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInCookie": { - "type": "apiKey", - "name": value, - "in": "cookie", - "scheme_name": "APIKeyInCookie", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": { - "APIKeyInCookie": {"type": "apiKey", "name": value, "in": "cookie"} - } - }, - } diff --git a/tests/_security/openapi_normal/test_security_api_key_in_header.py b/tests/_security/openapi_normal/test_security_api_key_in_header.py deleted file mode 100644 index 823672bc..00000000 --- a/tests/_security/openapi_normal/test_security_api_key_in_header.py +++ /dev/null @@ -1,156 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import Gateway, JSONResponse, get -from esmerald.openapi.security.api_key import APIKeyInHeader -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@pytest.mark.parametrize("auth", [APIKeyInHeader, APIKeyInHeader()]) -def test_security_api_key_in_header(auth): - @get( - response_class=JsonResponse, - security=[auth], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings(), - ) as client: - response = client.get("/openapi.json") - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInHeader": { - "type": "apiKey", - "in": "header", - "scheme_name": "APIKeyInHeader", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": {"APIKeyInHeader": {"type": "apiKey", "in": "header"}} - }, - } - - -@pytest.mark.parametrize( - "token,value", - [ - (APIKeyInHeader(name="X_API_TOKEN"), "X_API_TOKEN"), - (APIKeyInHeader(name="X_TOKEN"), "X_TOKEN"), - (APIKeyInHeader(name="test"), "test"), - ], - ids=["x-api-token", "x-token", "test"], -) -def test_security_api_key_header_value(token, value): - @get( - response_class=JsonResponse, - security=[token], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - ) as client: - response = client.get("/openapi.json") - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInHeader": { - "type": "apiKey", - "name": value, - "in": "header", - "scheme_name": "APIKeyInHeader", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": { - "APIKeyInHeader": {"type": "apiKey", "name": value, "in": "header"} - } - }, - } diff --git a/tests/_security/openapi_normal/test_security_api_key_in_query.py b/tests/_security/openapi_normal/test_security_api_key_in_query.py deleted file mode 100644 index bf1f4122..00000000 --- a/tests/_security/openapi_normal/test_security_api_key_in_query.py +++ /dev/null @@ -1,156 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import Gateway, JSONResponse, get -from esmerald.openapi.security.api_key import APIKeyInQuery -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@pytest.mark.parametrize("auth", [APIKeyInQuery, APIKeyInQuery()]) -def test_security_api_key_in_query(auth): - @get( - response_class=JsonResponse, - security=[auth], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings, - ) as client: - response = client.get("/openapi.json") - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInQuery": { - "type": "apiKey", - "in": "query", - "scheme_name": "APIKeyInQuery", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": {"APIKeyInQuery": {"type": "apiKey", "in": "query"}} - }, - } - - -@pytest.mark.parametrize( - "token,value", - [ - (APIKeyInQuery(name="Authorization"), "Authorization"), - (APIKeyInQuery(name="X_TOKEN"), "X_TOKEN"), - (APIKeyInQuery(name="test"), "test"), - ], - ids=["x-api-token", "x-token", "test"], -) -def test_security_api_key_in_query_value(token, value): - @get( - response_class=JsonResponse, - security=[token], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - ) as client: - response = client.get("/openapi.json") - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "APIKeyInQuery": { - "type": "apiKey", - "name": value, - "in": "query", - "scheme_name": "APIKeyInQuery", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - } - }, - "components": { - "securitySchemes": { - "APIKeyInQuery": {"type": "apiKey", "name": value, "in": "query"} - } - }, - } diff --git a/tests/_security/openapi_normal/test_security_digest.py b/tests/_security/openapi_normal/test_security_digest.py deleted file mode 100644 index d9c12819..00000000 --- a/tests/_security/openapi_normal/test_security_digest.py +++ /dev/null @@ -1,160 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import Gateway, JSONResponse, get -from esmerald.openapi.security.http import Digest -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@get("/item/{id}") -async def read_item(id: str) -> None: - """ """ - - -@pytest.mark.parametrize("auth", [Digest, Digest()]) -def test_security_digest(auth): - @get( - response_class=JsonResponse, - security=[auth], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_item), Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings(), - ) as client: - response = client.get("/openapi.json") - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/item/{id}": { - "get": { - "summary": "Read Item", - "description": "", - "operationId": "read_item_item__id__get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": {"type": "string", "title": "Id"}, - } - ], - "responses": { - "200": {"description": "Successful response"}, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, - }, - "deprecated": False, - } - }, - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - "scheme_name": "Digest", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - }, - }, - "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, - "securitySchemes": { - "Digest": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "digest", - } - }, - }, - } diff --git a/tests/_security/openapi_normal/test_security_token_bearer.py b/tests/_security/openapi_normal/test_security_token_bearer.py deleted file mode 100644 index 153795ab..00000000 --- a/tests/_security/openapi_normal/test_security_token_bearer.py +++ /dev/null @@ -1,161 +0,0 @@ -from typing import Dict, List, Union - -import pytest -from pydantic import BaseModel - -from esmerald import Gateway, JSONResponse, get -from esmerald.openapi.security.http import Bearer -from esmerald.testclient import create_client -from tests.settings import TestSettings - - -class Error(BaseModel): - status: int - detail: str - - -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] - - -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" - - -class Item(BaseModel): - sku: Union[int, str] - - -@get("/item/{id}") -async def read_item(id: str) -> None: - """ """ - - -@pytest.mark.parametrize("auth", [Bearer, Bearer()]) -def test_security_token_bearer(auth): - @get( - response_class=JsonResponse, - security=[auth], - ) - def read_people() -> Dict[str, str]: - """ """ - - with create_client( - routes=[Gateway(handler=read_item), Gateway(handler=read_people)], - enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings, - ) as client: - response = client.get("/openapi.json") - - assert response.json() == { - "openapi": "3.1.0", - "info": { - "title": "Esmerald", - "summary": "Esmerald application", - "description": "Highly scalable, performant, easy to learn and for every application.", - "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": client.app.version, - }, - "servers": [{"url": "/"}], - "paths": { - "/item/{id}": { - "get": { - "summary": "Read Item", - "description": "", - "operationId": "read_item_item__id__get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": {"type": "string", "title": "Id"}, - } - ], - "responses": { - "200": {"description": "Successful response"}, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, - }, - "deprecated": False, - } - }, - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - "scheme_name": "Bearer", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - }, - }, - "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, - "securitySchemes": { - "Bearer": { - "type": "http", - "name": "Authorization", - "in": "header", - "scheme": "bearer", - } - }, - }, - } diff --git a/tests/_security/__init__.py b/tests/security/http/__init__.py similarity index 100% rename from tests/_security/__init__.py rename to tests/security/http/__init__.py diff --git a/tests/security/http/test_security_api_key_cookie.py b/tests/security/http/test_security_api_key_cookie.py new file mode 100644 index 00000000..6f5e7bd6 --- /dev/null +++ b/tests/security/http/test_security_api_key_cookie.py @@ -0,0 +1,99 @@ +from typing import Any + +from pydantic import BaseModel + +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInCookie +from esmerald.testclient import create_client + +api_key = APIKeyInCookie(name="key") + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: str = Security(api_key)): + user = User(username=oauth_header) + return user + + +@get("/users/me", dependencies={"current_user": Inject(get_current_user)}, security=[api_key]) +def read_current_user(current_user: User = Injects()) -> Any: + return current_user + + +def test_security_api_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", cookies={"key": "secret"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} + + +def test_security_api_key_no_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInCookie": { + "type": "apiKey", + "name": "key", + "in": "cookie", + "scheme_name": "APIKeyInCookie", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "APIKeyInCookie": {"type": "apiKey", "name": "key", "in": "cookie"} + } + }, + } diff --git a/tests/security/http/test_security_api_key_cookie_description.py b/tests/security/http/test_security_api_key_cookie_description.py new file mode 100644 index 00000000..a23f3759 --- /dev/null +++ b/tests/security/http/test_security_api_key_cookie_description.py @@ -0,0 +1,105 @@ +from typing import Any + +from pydantic import BaseModel + +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInCookie +from esmerald.testclient import create_client + +api_key = APIKeyInCookie(name="key", description="An API Cookie Key") + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: str = Security(api_key)): + user = User(username=oauth_header) + return user + + +@get("/users/me", security=[api_key], dependencies={"current_user": Inject(get_current_user)}) +def read_current_user(current_user: User = Injects()) -> Any: + return current_user + + +def test_security_api_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", cookies={"key": "secret"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} + + +def test_security_api_key_no_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInCookie": { + "type": "apiKey", + "description": "An API Cookie Key", + "name": "key", + "in": "cookie", + "scheme_name": "APIKeyInCookie", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "APIKeyInCookie": { + "type": "apiKey", + "description": "An API Cookie Key", + "name": "key", + "in": "cookie", + } + } + }, + } diff --git a/tests/_security/openapi_normal/test_security_basic.py b/tests/security/http/test_security_api_key_cookie_optional.py similarity index 53% rename from tests/_security/openapi_normal/test_security_basic.py rename to tests/security/http/test_security_api_key_cookie_optional.py index 6315b12b..e21a4d43 100644 --- a/tests/_security/openapi_normal/test_security_basic.py +++ b/tests/security/http/test_security_api_key_cookie_optional.py @@ -1,54 +1,64 @@ -from typing import Dict, List, Union +from typing import Any, Optional -import pytest from pydantic import BaseModel -from esmerald import Gateway, JSONResponse, get -from esmerald.openapi.security.http import Basic +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInCookie from esmerald.testclient import create_client -from tests.settings import TestSettings +api_key = APIKeyInCookie(name="key", auto_error=False) -class Error(BaseModel): - status: int - detail: str +class User(BaseModel): + username: str -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] +def get_current_user(oauth_header: Optional[str] = Security(api_key)): + if oauth_header is None: + return None + user = User(username=oauth_header) + return user -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" +@get("/users/me", security=[api_key], dependencies={"current_user": Inject(get_current_user)}) +def read_current_user(current_user: Optional[User] = Injects()) -> Any: + if current_user is None: + return {"msg": "Create an account first"} + else: + return current_user -class Item(BaseModel): - sku: Union[int, str] +def test_security_api_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", cookies={"key": "secret"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} -@get("/item/{id}") -async def read_item(id: str) -> None: - """ """ +def test_security_api_key_no_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} -@pytest.mark.parametrize("auth", [Basic, Basic()]) -def test_security_basic(auth): - @get( - response_class=JsonResponse, - security=[auth], - ) - def read_people() -> Dict[str, str]: - """ """ +def test_openapi_schema(): with create_client( - routes=[Gateway(handler=read_item), Gateway(handler=read_people)], + routes=[ + Gateway(handler=read_current_user), + ], enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings(), ) as client: response = client.get("/openapi.json") + assert response.status_code == 200, response.text assert response.json() == { "openapi": "3.1.0", @@ -61,24 +71,44 @@ def read_people() -> Dict[str, str]: }, "servers": [{"url": "/"}], "paths": { - "/item/{id}": { + "/users/me": { "get": { - "summary": "Read Item", + "summary": "Read Current User", "description": "", - "operationId": "read_item_item__id__get", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInCookie": { + "type": "apiKey", + "name": "key", + "in": "cookie", + "scheme_name": "APIKeyInCookie", + } + } + ], "parameters": [ { - "name": "id", - "in": "path", + "name": "current_user", + "in": "query", "required": True, "deprecated": False, "allowEmptyValue": False, "allowReserved": False, - "schema": {"type": "string", "title": "Id"}, + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/User"}, + {"type": "null"}, + ], + "title": "Current User", + }, } ], "responses": { - "200": {"description": "Successful response"}, + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + }, "422": { "description": "Validation Error", "content": { @@ -90,36 +120,8 @@ def read_people() -> Dict[str, str]: }, }, }, - "deprecated": False, } - }, - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "read_people__get", - "deprecated": False, - "security": [ - { - "Basic": { - "type": "http", - "name": "Basic", - "in": "header", - "scheme": "basic", - "scheme_name": "Basic", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, - } - }, + } }, "components": { "schemas": { @@ -134,6 +136,12 @@ def read_people() -> Dict[str, str]: "type": "object", "title": "HTTPValidationError", }, + "User": { + "properties": {"username": {"type": "string", "title": "Username"}}, + "type": "object", + "required": ["username"], + "title": "User", + }, "ValidationError": { "properties": { "loc": { @@ -150,11 +158,10 @@ def read_people() -> Dict[str, str]: }, }, "securitySchemes": { - "Basic": { - "type": "http", - "name": "Basic", - "in": "header", - "scheme": "basic", + "APIKeyInCookie": { + "type": "apiKey", + "name": "key", + "in": "cookie", } }, }, diff --git a/tests/security/http/test_security_api_key_header.py b/tests/security/http/test_security_api_key_header.py new file mode 100644 index 00000000..4d4773cb --- /dev/null +++ b/tests/security/http/test_security_api_key_header.py @@ -0,0 +1,104 @@ +from typing import Any + +from pydantic import BaseModel + +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInHeader +from esmerald.testclient import create_client + +api_key = APIKeyInHeader(name="key") + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: str = Security(api_key)): + if oauth_header is None: + return None + user = User(username=oauth_header) + return user + + +@get("/users/me", security=[api_key], dependencies={"current_user": Inject(get_current_user)}) +def read_current_user(current_user: User = Injects()) -> Any: + if current_user is None: + return {"msg": "Create an account first"} + else: + return current_user + + +def test_security_api_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", headers={"key": "secret"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} + + +def test_security_api_key_no_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInHeader": { + "type": "apiKey", + "name": "key", + "in": "header", + "scheme_name": "APIKeyInHeader", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "APIKeyInHeader": {"type": "apiKey", "name": "key", "in": "header"} + } + }, + } diff --git a/tests/security/http/test_security_api_key_header_description.py b/tests/security/http/test_security_api_key_header_description.py new file mode 100644 index 00000000..e5ed2b12 --- /dev/null +++ b/tests/security/http/test_security_api_key_header_description.py @@ -0,0 +1,105 @@ +from typing import Any + +from pydantic import BaseModel + +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInHeader +from esmerald.testclient import create_client + +api_key = APIKeyInHeader(name="key", description="An API Key Header") + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: str = Security(api_key)): + user = User(username=oauth_header) + return user + + +@get("/users/me", dependencies={"current_user": Inject(get_current_user)}, security=[api_key]) +def read_current_user(current_user: User = Injects()) -> Any: + return current_user + + +def test_security_api_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", headers={"key": "secret"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} + + +def test_security_api_key_no_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInHeader": { + "type": "apiKey", + "description": "An API Key Header", + "name": "key", + "in": "header", + "scheme_name": "APIKeyInHeader", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "APIKeyInHeader": { + "type": "apiKey", + "description": "An API Key Header", + "name": "key", + "in": "header", + } + } + }, + } diff --git a/tests/_security/open_api_classes/test_security_basic.py b/tests/security/http/test_security_api_key_header_optional.py similarity index 54% rename from tests/_security/open_api_classes/test_security_basic.py rename to tests/security/http/test_security_api_key_header_optional.py index c6cbc948..0c2173ad 100644 --- a/tests/_security/open_api_classes/test_security_basic.py +++ b/tests/security/http/test_security_api_key_header_optional.py @@ -1,56 +1,64 @@ -from typing import Dict, List, Union +from typing import Any, Optional -import pytest from pydantic import BaseModel -from esmerald import APIView, Gateway, JSONResponse, get -from esmerald.openapi.security.http import Basic +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInHeader from esmerald.testclient import create_client -from tests.settings import TestSettings +api_key = APIKeyInHeader(name="key", auto_error=False) -class Error(BaseModel): - status: int - detail: str +class User(BaseModel): + username: str -class CustomResponse(BaseModel): - status: str - title: str - errors: List[Error] +def get_current_user(oauth_header: Optional[str] = Security(api_key)): + if oauth_header is None: + return None + user = User(username=oauth_header) + return user -class JsonResponse(JSONResponse): - media_type: str = "application/vnd.api+json" +@get("/users/me", security=[api_key], dependencies={"current_user": Inject(get_current_user)}) +def read_current_user(current_user: Optional[User] = Injects()) -> Any: + if current_user is None: + return {"msg": "Create an account first"} + else: + return current_user -class Item(BaseModel): - sku: Union[int, str] - -@get("/item/{id}") -async def read_item(id: str) -> None: - """ """ +def test_security_api_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", headers={"key": "secret"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} -@pytest.mark.parametrize("auth", [Basic, Basic()]) -def test_security_basic(auth): - class TestAPIView(APIView): - security = [auth] +def test_security_api_key_no_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} - @get( - response_class=JsonResponse, - ) - def read_people(self) -> Dict[str, str]: - """ """ +def test_openapi_schema(): with create_client( - routes=[Gateway(handler=read_item), Gateway(handler=TestAPIView)], + routes=[ + Gateway(handler=read_current_user), + ], enable_openapi=True, - include_in_schema=True, - settings_module=TestSettings(), ) as client: response = client.get("/openapi.json") + assert response.status_code == 200, response.text assert response.json() == { "openapi": "3.1.0", @@ -63,24 +71,44 @@ def read_people(self) -> Dict[str, str]: }, "servers": [{"url": "/"}], "paths": { - "/item/{id}": { + "/users/me": { "get": { - "summary": "Read Item", + "summary": "Read Current User", "description": "", - "operationId": "read_item_item__id__get", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInHeader": { + "type": "apiKey", + "name": "key", + "in": "header", + "scheme_name": "APIKeyInHeader", + } + } + ], "parameters": [ { - "name": "id", - "in": "path", + "name": "current_user", + "in": "query", "required": True, "deprecated": False, "allowEmptyValue": False, "allowReserved": False, - "schema": {"type": "string", "title": "Id"}, + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/User"}, + {"type": "null"}, + ], + "title": "Current User", + }, } ], "responses": { - "200": {"description": "Successful response"}, + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + }, "422": { "description": "Validation Error", "content": { @@ -92,36 +120,8 @@ def read_people(self) -> Dict[str, str]: }, }, }, - "deprecated": False, - } - }, - "/": { - "get": { - "summary": "Read People", - "description": "", - "operationId": "testapiview_read_people__get", - "deprecated": False, - "security": [ - { - "Basic": { - "type": "http", - "name": "Basic", - "in": "header", - "scheme": "basic", - "scheme_name": "Basic", - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/vnd.api+json": {"schema": {"type": "string"}} - }, - } - }, } - }, + } }, "components": { "schemas": { @@ -136,6 +136,12 @@ def read_people(self) -> Dict[str, str]: "type": "object", "title": "HTTPValidationError", }, + "User": { + "properties": {"username": {"type": "string", "title": "Username"}}, + "type": "object", + "required": ["username"], + "title": "User", + }, "ValidationError": { "properties": { "loc": { @@ -152,7 +158,7 @@ def read_people(self) -> Dict[str, str]: }, }, "securitySchemes": { - "Basic": {"type": "http", "name": "Basic", "in": "header", "scheme": "basic"} + "APIKeyInHeader": {"type": "apiKey", "name": "key", "in": "header"} }, }, } diff --git a/tests/security/http/test_security_api_key_query.py b/tests/security/http/test_security_api_key_query.py new file mode 100644 index 00000000..dc7a0a62 --- /dev/null +++ b/tests/security/http/test_security_api_key_query.py @@ -0,0 +1,66 @@ +from fastapi import Depends, FastAPI, Security +from fastapi.security import APIKeyQuery +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + +api_key = APIKeyQuery(name="key") + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: str = Security(api_key)): + user = User(username=oauth_header) + return user + + +@app.get("/users/me") +def read_current_user(current_user: User = Depends(get_current_user)): + return current_user + + +client = TestClient(app) + + +def test_security_api_key(): + response = client.get("/users/me?key=secret") + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} + + +def test_security_api_key_no_key(): + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/users/me": { + "get": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "summary": "Read Current User", + "operationId": "read_current_user_users_me_get", + "security": [{"APIKeyQuery": []}], + } + } + }, + "components": { + "securitySchemes": { + "APIKeyQuery": {"type": "apiKey", "name": "key", "in": "query"} + } + }, + } diff --git a/tests/security/http/test_security_api_key_query_description.py b/tests/security/http/test_security_api_key_query_description.py new file mode 100644 index 00000000..a1ef40ed --- /dev/null +++ b/tests/security/http/test_security_api_key_query_description.py @@ -0,0 +1,110 @@ +from typing import Any + +from pydantic import BaseModel + +from esmerald import Gateway, Include, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInQuery +from esmerald.testclient import create_client + +api_key = APIKeyInQuery(name="key", description="API Key Query") + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: str = Security(api_key)): + if oauth_header is None: + return None + user = User(username=oauth_header) + return user + + +@get("/users/me", dependencies={"current_user": Inject(get_current_user)}) +def read_current_user(current_user: User = Injects()) -> Any: + if current_user is None: + return {"msg": "Create an account first"} + else: + return current_user + + +def test_security_api_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me?key=secret") + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} + + +def test_security_api_key_no_key(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Include(routes=[Gateway(handler=read_current_user)], security=[api_key]), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInQuery": { + "type": "apiKey", + "description": "API Key Query", + "name": "key", + "in": "query", + "scheme_name": "APIKeyInQuery", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "APIKeyInQuery": { + "type": "apiKey", + "description": "API Key Query", + "name": "key", + "in": "query", + } + } + }, + } diff --git a/tests/security/http/test_security_api_key_query_optional.py b/tests/security/http/test_security_api_key_query_optional.py new file mode 100644 index 00000000..4cc134bd --- /dev/null +++ b/tests/security/http/test_security_api_key_query_optional.py @@ -0,0 +1,72 @@ +from typing import Optional + +from fastapi import Depends, FastAPI, Security +from fastapi.security import APIKeyQuery +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + +api_key = APIKeyQuery(name="key", auto_error=False) + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: Optional[str] = Security(api_key)): + if oauth_header is None: + return None + user = User(username=oauth_header) + return user + + +@app.get("/users/me") +def read_current_user(current_user: Optional[User] = Depends(get_current_user)): + if current_user is None: + return {"msg": "Create an account first"} + return current_user + + +client = TestClient(app) + + +def test_security_api_key(): + response = client.get("/users/me?key=secret") + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} + + +def test_security_api_key_no_key(): + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/users/me": { + "get": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "summary": "Read Current User", + "operationId": "read_current_user_users_me_get", + "security": [{"APIKeyQuery": []}], + } + } + }, + "components": { + "securitySchemes": { + "APIKeyQuery": {"type": "apiKey", "name": "key", "in": "query"} + } + }, + } From bc42466ec3c78428665c7080ffbaf3024ce82f80 Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 18 Nov 2024 16:44:55 +0100 Subject: [PATCH 13/24] Update internal ignore security params in the query --- esmerald/openapi/openapi.py | 5 +- esmerald/openapi/security/base.py | 38 +-- esmerald/openapi/security/http/base.py | 83 +---- esmerald/routing/_internal.py | 5 +- esmerald/routing/base.py | 2 +- esmerald/routing/router.py | 2 +- esmerald/security/http/__init__.py | 2 + esmerald/security/http/http.py | 285 +++++------------- esmerald/transformers/model.py | 14 +- esmerald/transformers/signature.py | 8 +- esmerald/transformers/utils.py | 4 +- .../http/test_security_api_key_query.py | 122 +++++--- .../test_security_api_key_query_optional.py | 165 +++++++--- .../security/http/test_security_http_base.py | 78 +++++ .../test_security_http_base_description.py | 79 +++++ .../http/test_security_http_base_optional.py | 145 +++++++++ 16 files changed, 616 insertions(+), 421 deletions(-) create mode 100644 tests/security/http/test_security_http_base.py create mode 100644 tests/security/http/test_security_http_base_description.py create mode 100644 tests/security/http/test_security_http_base_optional.py diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index c220fd74..c30b3615 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -73,7 +73,7 @@ def get_flat_params(route: Union[router.HTTPHandler, Any], body_fields: List[str handler_query_params = [ param for param in route.transformer.get_query_params() - if param.field_alias not in route.body_encoder_fields.keys() + if param.field_alias not in route.body_encoder_fields.keys() and not param.is_security ] query_params = [] for param in handler_query_params: @@ -82,6 +82,9 @@ def get_flat_params(route: Union[router.HTTPHandler, Any], body_fields: List[str if param.field_info.alias in body_fields: continue + if param.is_security: + continue + # Making sure all the optional and union types are included if is_union_or_optional: if not is_security_scheme(param.field_info.default): diff --git a/esmerald/openapi/security/base.py b/esmerald/openapi/security/base.py index 3007aac2..4976db43 100644 --- a/esmerald/openapi/security/base.py +++ b/esmerald/openapi/security/base.py @@ -1,38 +1,4 @@ -from typing import Any, Literal, Optional, Union +from esmerald.security.http import HTTPBase as BaseHTTP -from pydantic import AnyUrl, ConfigDict -from esmerald.openapi.models import SecurityScheme - - -class HTTPBase(SecurityScheme): - """ - Base for all HTTP security headers. - """ - - model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) - - def __init__( - self, - *, - type_: Optional[Literal["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"]] = None, - bearerFormat: Optional[str] = None, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - in_: Optional[Literal["query", "header", "cookie"]] = None, - name: Optional[str] = None, - scheme: Optional[str] = None, - openIdConnectUrl: Optional[Union[AnyUrl, str]] = None, - **kwargs: Any, - ): - super().__init__( # type: ignore - type=type_, - bearerFormat=bearerFormat, - description=description, - name=name, - security_scheme_in=in_, - scheme_name=scheme_name, - scheme=scheme, - openIdConnectUrl=openIdConnectUrl, - **kwargs, - ) +class HTTPBase(BaseHTTP): ... diff --git a/esmerald/openapi/security/http/base.py b/esmerald/openapi/security/http/base.py index 69de655a..c15b8fa3 100644 --- a/esmerald/openapi/security/http/base.py +++ b/esmerald/openapi/security/http/base.py @@ -1,85 +1,10 @@ -from typing import Any, Literal, Optional +from esmerald.security.http import HTTPBase -from esmerald.openapi.enums import APIKeyIn, Header, SecuritySchemeType -from esmerald.openapi.security.base import HTTPBase +class Basic(HTTPBase): ... -class Basic(HTTPBase): - def __init__( - self, - *, - type_: Literal[ - "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" - ] = SecuritySchemeType.http.value, - bearerFormat: Optional[str] = None, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - in_: Optional[Literal["query", "header", "cookie"]] = APIKeyIn.header.value, - name: Optional[str] = None, - scheme: Optional[str] = None, - **kwargs: Any, - ): - super().__init__( - type_=type_, - bearerFormat=bearerFormat, - description=description, - name=name or "Basic", - in_=in_, - scheme=scheme or "basic", - scheme_name=scheme_name or self.__class__.__name__, - **kwargs, - ) +class Bearer(HTTPBase): ... -class Bearer(HTTPBase): - def __init__( - self, - *, - type_: Literal[ - "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" - ] = SecuritySchemeType.http.value, - bearerFormat: Optional[str] = None, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - in_: Optional[Literal["query", "header", "cookie"]] = APIKeyIn.header.value, - name: Optional[str] = None, - scheme: Optional[str] = None, - **kwargs: Any, - ): - super().__init__( - type_=type_, - bearerFormat=bearerFormat, - description=description, - name=name or Header.authorization, - in_=in_, - scheme=scheme or "bearer", - scheme_name=scheme_name or self.__class__.__name__, - **kwargs, - ) - -class Digest(HTTPBase): - def __init__( - self, - *, - type_: Literal[ - "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect" - ] = SecuritySchemeType.http.value, - bearerFormat: Optional[str] = None, - scheme_name: Optional[str] = None, - description: Optional[str] = None, - in_: Optional[Literal["query", "header", "cookie"]] = APIKeyIn.header.value, - name: Optional[str] = None, - scheme: Optional[str] = None, - **kwargs: Any, - ): - super().__init__( - type_=type_, - bearerFormat=bearerFormat, - description=description, - name=name or Header.authorization, - in_=in_, - scheme=scheme or "digest", - scheme_name=scheme_name or self.__class__.__name__, - **kwargs, - ) +class Digest(HTTPBase): ... diff --git a/esmerald/routing/_internal.py b/esmerald/routing/_internal.py index 97647d48..44ad78ed 100644 --- a/esmerald/routing/_internal.py +++ b/esmerald/routing/_internal.py @@ -326,12 +326,15 @@ def body_encoder_fields(self) -> Dict[str, FieldInfo]: """ # Making sure the dependencies are not met as body fields for OpenAPI representation handler_dependencies = set(self.get_dependencies().keys()) + security_dependencies = set(self.transformer.get_security_params().keys()) # Getting everything else that is not considered a dependency body_encoder_fields = { name: field for name, field in self.signature_model.model_fields.items() - if is_body_encoder(field.annotation) and name not in handler_dependencies + if is_body_encoder(field.annotation) + and name not in handler_dependencies + and name not in security_dependencies } return body_encoder_fields diff --git a/esmerald/routing/base.py b/esmerald/routing/base.py index ff1d9880..4942bc00 100644 --- a/esmerald/routing/base.py +++ b/esmerald/routing/base.py @@ -223,7 +223,7 @@ async def _get_response_data( dependency=dependency, connection=request, **kwargs ) - parsed_kwargs = signature_model.parse_values_for_connection( + parsed_kwargs = await signature_model.parse_values_for_connection( connection=request, **kwargs ) else: diff --git a/esmerald/routing/router.py b/esmerald/routing/router.py index ab566b07..9546a038 100644 --- a/esmerald/routing/router.py +++ b/esmerald/routing/router.py @@ -2448,7 +2448,7 @@ async def get_kwargs(self, websocket: WebSocket) -> Any: kwargs[dependency.key] = await self.websocket_parameter_model.get_dependencies( dependency=dependency, connection=websocket, **kwargs ) - return signature_model.parse_values_for_connection(connection=websocket, **kwargs) + return await signature_model.parse_values_for_connection(connection=websocket, **kwargs) class Include(LilyaInclude): diff --git a/esmerald/security/http/__init__.py b/esmerald/security/http/__init__.py index 7663700c..b46c3067 100644 --- a/esmerald/security/http/__init__.py +++ b/esmerald/security/http/__init__.py @@ -1,5 +1,6 @@ from .http import ( HTTPAuthorizationCredentials, + HTTPBase, HTTPBasic, HTTPBasicCredentials, HTTPBearer, @@ -7,6 +8,7 @@ ) __all__ = [ + "HTTPBase", "HTTPBasic", "HTTPBearer", "HTTPDigest", diff --git a/esmerald/security/http/http.py b/esmerald/security/http/http.py index ce728e91..688671fe 100644 --- a/esmerald/security/http/http.py +++ b/esmerald/security/http/http.py @@ -15,52 +15,28 @@ class HTTPBasicCredentials(BaseModel): """ - The HTTP Basic credentials given as the result of using `HTTPBasic` in a - dependency. + Represents HTTP Basic credentials. + + Attributes: + username (str): The username. + password (str): The password. """ - username: Annotated[str, Doc("The HTTP Basic username.")] - password: Annotated[str, Doc("The HTTP Basic password.")] + username: Annotated[str, Doc("The username for HTTP Basic authentication.")] + password: Annotated[str, Doc("The password for HTTP Basic authentication.")] class HTTPAuthorizationCredentials(BaseModel): """ - The HTTP authorization credentials in the result of using `HTTPBearer` or - `HTTPDigest` in a dependency. - - The HTTP authorization header value is split by the first space. - - The first part is the `scheme`, the second part is the `credentials`. - - For example, in an HTTP Bearer token scheme, the client will send a header - like: + Represents HTTP authorization credentials. - ``` - Authorization: Bearer deadbeef12346 - ``` - - In this case: - - * `scheme` will have the value `"Bearer"` - * `credentials` will have the value `"deadbeef12346"` + Attributes: + scheme (str): The authorization scheme (e.g., "Bearer"). + credentials (str): The authorization credentials (e.g., token). """ - scheme: Annotated[ - str, - Doc( - """ - The HTTP authorization scheme extracted from the header value. - """ - ), - ] - credentials: Annotated[ - str, - Doc( - """ - The HTTP authorization credentials extracted from the header value. - """ - ), - ] + scheme: Annotated[str, Doc("The authorization scheme extracted from the header.")] + credentials: Annotated[str, Doc("The authorization credentials extracted from the header.")] class HTTPBase(SecurityBase): @@ -72,6 +48,15 @@ def __init__( description: Union[str, None] = None, auto_error: bool = True, ): + """ + Base class for HTTP security schemes. + + Args: + scheme (str): The security scheme (e.g., "basic", "bearer"). + scheme_name (str, optional): The name of the security scheme. + description (str, optional): Description of the security scheme. + auto_error (bool, optional): Whether to automatically raise an error if authentication fails. + """ model = HTTPBaseModel(scheme=scheme, description=description) super().__init__(**model.model_dump()) self.scheme_name = scheme_name or self.__class__.__name__ @@ -99,81 +84,36 @@ class HTTPBasic(HTTPBase): """ HTTP Basic authentication. - ## Usage - - Create an instance object and use that object as the dependency in `Depends()`. - - The dependency result will be an `HTTPBasicCredentials` object containing the - `username` and the `password`. + Use this class as a dependency to enforce HTTP Basic authentication. - ## Example + Example: + ```python + from typing import Annotated + from fastapi import Depends, FastAPI + from fastapi.security import HTTPBasic, HTTPBasicCredentials - ```python - from typing import Annotated + app = FastAPI() + security = HTTPBasic() - from fastapi import Depends, FastAPI - from fastapi.security import HTTPBasic, HTTPBasicCredentials - - app = FastAPI() - - security = HTTPBasic() - - - @app.get("/users/me") - def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]): - return {"username": credentials.username, "password": credentials.password} - ``` + @app.get("/users/me") + def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]): + return {"username": credentials.username, "password": credentials.password} + ``` """ def __init__( self, *, - scheme_name: Annotated[ - Union[str, None], - Doc( - """ - Security scheme name. - - It will be included in the generated OpenAPI (e.g. visible at `/docs`). - """ - ), - ] = None, - realm: Annotated[ - Union[str, None], - Doc( - """ - HTTP Basic authentication realm. - """ - ), - ] = None, + scheme_name: Annotated[Union[str, None], Doc("The name of the security scheme.")] = None, + realm: Annotated[Union[str, None], Doc("The HTTP Basic authentication realm.")] = None, description: Annotated[ - Union[str, None], - Doc( - """ - Security scheme description. - - It will be included in the generated OpenAPI (e.g. visible at `/docs`). - """ - ), + Union[str, None], Doc("Description of the security scheme.") ] = None, auto_error: Annotated[ bool, Doc( - """ - By default, if the HTTP Basic authentication is not provided (a - header), `HTTPBasic` will automatically cancel the request and send the - client an error. - - If `auto_error` is set to `False`, when the HTTP Basic authentication - is not available, instead of erroring out, the dependency result will - be `None`. - - This is useful when you want to have optional authentication. - - It is also useful when you want to have authentication that can be - provided in one of multiple optional ways (for example, in HTTP Basic - authentication or in an HTTP Bearer token). - """ + "Whether to automatically raise an error if authentication fails. " + "If set to False, the dependency result will be None when authentication is not provided." ), ] = True, ): @@ -219,76 +159,36 @@ class HTTPBearer(HTTPBase): """ HTTP Bearer token authentication. - ## Usage - - Create an instance object and use that object as the dependency in `Depends()`. - - The dependency result will be an `HTTPAuthorizationCredentials` object containing - the `scheme` and the `credentials`. - - ## Example - - ```python - from typing import Annotated - - from fastapi import Depends, FastAPI - from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + Use this class as a dependency to enforce HTTP Bearer token authentication. - app = FastAPI() + Example: + ```python + from typing import Annotated + from fastapi import Depends, FastAPI + from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - security = HTTPBearer() + app = FastAPI() + security = HTTPBearer() - - @app.get("/users/me") - def read_current_user( - credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)] - ): - return {"scheme": credentials.scheme, "credentials": credentials.credentials} - ``` + @app.get("/users/me") + def read_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]): + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + ``` """ def __init__( self, *, - bearerFormat: Annotated[Union[str, None], Doc("Bearer token format.")] = None, - scheme_name: Annotated[ - Union[str, None], - Doc( - """ - Security scheme name. - - It will be included in the generated OpenAPI (e.g. visible at `/docs`). - """ - ), - ] = None, + bearerFormat: Annotated[Union[str, None], Doc("The format of the Bearer token.")] = None, + scheme_name: Annotated[Union[str, None], Doc("The name of the security scheme.")] = None, description: Annotated[ - Union[str, None], - Doc( - """ - Security scheme description. - - It will be included in the generated OpenAPI (e.g. visible at `/docs`). - """ - ), + Union[str, None], Doc("Description of the security scheme.") ] = None, auto_error: Annotated[ bool, Doc( - """ - By default, if the HTTP Bearer token is not provided (in an - `Authorization` header), `HTTPBearer` will automatically cancel the - request and send the client an error. - - If `auto_error` is set to `False`, when the HTTP Bearer token - is not available, instead of erroring out, the dependency result will - be `None`. - - This is useful when you want to have optional authentication. - - It is also useful when you want to have authentication that can be - provided in one of multiple optional ways (for example, in an HTTP - Bearer token or in a cookie). - """ + "Whether to automatically raise an error if authentication fails. " + "If set to False, the dependency result will be None when authentication is not provided." ), ] = True, ): @@ -300,7 +200,7 @@ def __init__( async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]: authorization = request.headers.get("Authorization") if not authorization: - if self.auto_error: + if self.__auto_error__: raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") return None @@ -320,74 +220,35 @@ class HTTPDigest(HTTPBase): """ HTTP Digest authentication. - ## Usage - - Create an instance object and use that object as the dependency in `Depends()`. - - The dependency result will be an `HTTPAuthorizationCredentials` object containing - the `scheme` and the `credentials`. + Use this class as a dependency to enforce HTTP Digest authentication. - ## Example + Example: + ```python + from typing import Annotated + from fastapi import Depends, FastAPI + from fastapi.security import HTTPAuthorizationCredentials, HTTPDigest - ```python - from typing import Annotated + app = FastAPI() + security = HTTPDigest() - from fastapi import Depends, FastAPI - from fastapi.security import HTTPAuthorizationCredentials, HTTPDigest - - app = FastAPI() - - security = HTTPDigest() - - - @app.get("/users/me") - def read_current_user( - credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)] - ): - return {"scheme": credentials.scheme, "credentials": credentials.credentials} - ``` + @app.get("/users/me") + def read_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]): + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + ``` """ def __init__( self, *, - scheme_name: Annotated[ - Union[str, None], - Doc( - """ - Security scheme name. - - It will be included in the generated OpenAPI (e.g. visible at `/docs`). - """ - ), - ] = None, + scheme_name: Annotated[Union[str, None], Doc("The name of the security scheme.")] = None, description: Annotated[ - Union[str, None], - Doc( - """ - Security scheme description. - - It will be included in the generated OpenAPI (e.g. visible at `/docs`). - """ - ), + Union[str, None], Doc("Description of the security scheme.") ] = None, auto_error: Annotated[ bool, Doc( - """ - By default, if the HTTP Digest is not provided, `HTTPDigest` will - automatically cancel the request and send the client an error. - - If `auto_error` is set to `False`, when the HTTP Digest is not - available, instead of erroring out, the dependency result will - be `None`. - - This is useful when you want to have optional authentication. - - It is also useful when you want to have authentication that can be - provided in one of multiple optional ways (for example, in HTTP - Digest or in a cookie). - """ + "Whether to automatically raise an error if authentication fails. " + "If set to False, the dependency result will be None when authentication is not provided." ), ] = True, ): diff --git a/esmerald/transformers/model.py b/esmerald/transformers/model.py index 44dcc5bc..ea95fc81 100644 --- a/esmerald/transformers/model.py +++ b/esmerald/transformers/model.py @@ -18,6 +18,7 @@ merge_sets, ) from esmerald.utils.constants import CONTEXT, DATA, PAYLOAD, RESERVED_KWARGS +from esmerald.utils.dependencies import is_security_scheme from esmerald.utils.schema import is_field_optional if TYPE_CHECKING: @@ -115,6 +116,15 @@ def get_header_params(self) -> Set[ParamSetting]: """ return self.headers + def get_security_params(self) -> Dict[str, ParamSetting]: + """ + Get header parameters. + + Returns: + Set[ParamSetting]: Set of header parameters. + """ + return {field.field_name: field for field in self.get_query_params() if field.is_security} + def to_kwargs( self, connection: Union["WebSocket", "Request"], @@ -230,7 +240,7 @@ async def get_dependencies( if kwargs: kwargs = await self.get_for_security_dependencies(connection, kwargs) - dependency_kwargs = signature_model.parse_values_for_connection( + dependency_kwargs = await signature_model.parse_values_for_connection( connection=connection, **kwargs ) return await dependency.inject(**dependency_kwargs) @@ -399,12 +409,14 @@ def get_parameter_settings( for field_name, model_field in signature_fields.items(): if field_name not in ignored_keys: allow_none = getattr(model_field, "allow_none", True) + is_security = is_security_scheme(model_field.default) parameter_definitions.add( create_parameter_setting( allow_none=allow_none, field_name=field_name, field_info=model_field, path_parameters=path_parameters, + is_security=is_security, ) ) diff --git a/esmerald/transformers/signature.py b/esmerald/transformers/signature.py index e46a2ec2..f20ef8da 100644 --- a/esmerald/transformers/signature.py +++ b/esmerald/transformers/signature.py @@ -154,7 +154,9 @@ class SignatureModel(ArbitraryBaseModel): encoders: ClassVar[Dict["Encoder", Any]] @classmethod - def parse_encoders(cls, kwargs: Dict[str, Any]) -> Dict[str, Any]: + async def parse_encoders( + cls, connection: Union[Request, WebSocket], kwargs: Dict[str, Any] + ) -> Dict[str, Any]: """ Parses the kwargs into a proper structure for the encoder itself. @@ -204,7 +206,7 @@ def encode_value(encoder: "Encoder", annotation: Any, value: Any) -> Any: return kwargs @classmethod - def parse_values_for_connection( + async def parse_values_for_connection( cls, connection: Union[Request, WebSocket], **kwargs: Dict[str, Any] ) -> Any: """ @@ -224,7 +226,7 @@ def parse_values_for_connection( """ try: if cls.encoders: - kwargs = cls.parse_encoders(kwargs) + kwargs = await cls.parse_encoders(connection, kwargs) signature = cls(**kwargs) values = {} for key in cls.model_fields: diff --git a/esmerald/transformers/utils.py b/esmerald/transformers/utils.py index 6619005f..93893abd 100644 --- a/esmerald/transformers/utils.py +++ b/esmerald/transformers/utils.py @@ -40,7 +40,7 @@ class ParamSetting(NamedTuple): is_required: bool param_type: ParamType field_info: FieldInfo - + is_security: bool = False class Dependency(HashableBaseModel, ArbitraryExtraBaseModel): def __init__( @@ -111,6 +111,7 @@ def create_parameter_setting( field_info: FieldInfo, field_name: str, path_parameters: Set[str], + is_security: bool, ) -> ParamSetting: """ Create a setting definition for a parameter. @@ -161,6 +162,7 @@ def create_parameter_setting( field_name=field_name, field_info=param, is_required=is_required and (default_value is None and not allow_none), + is_security=is_security, ) return param_settings diff --git a/tests/security/http/test_security_api_key_query.py b/tests/security/http/test_security_api_key_query.py index dc7a0a62..249326a7 100644 --- a/tests/security/http/test_security_api_key_query.py +++ b/tests/security/http/test_security_api_key_query.py @@ -1,11 +1,12 @@ -from fastapi import Depends, FastAPI, Security -from fastapi.security import APIKeyQuery -from fastapi.testclient import TestClient +from typing import Any + from pydantic import BaseModel -app = FastAPI() +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInQuery +from esmerald.testclient import create_client -api_key = APIKeyQuery(name="key") +api_key = APIKeyInQuery(name="key") class User(BaseModel): @@ -13,54 +14,91 @@ class User(BaseModel): def get_current_user(oauth_header: str = Security(api_key)): + if oauth_header is None: + return None user = User(username=oauth_header) return user -@app.get("/users/me") -def read_current_user(current_user: User = Depends(get_current_user)): - return current_user - - -client = TestClient(app) +@get("/users/me", dependencies={"current_user": Inject(get_current_user)}, security=[api_key]) +def read_current_user(current_user: User = Injects()) -> Any: + if current_user is None: + return {"msg": "Create an account first"} + else: + return current_user def test_security_api_key(): - response = client.get("/users/me?key=secret") - assert response.status_code == 200, response.text - assert response.json() == {"username": "secret"} + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me?key=secret") + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} def test_security_api_key_no_key(): - response = client.get("/users/me") - assert response.status_code == 403, response.text - assert response.json() == {"detail": "Not authenticated"} + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} def test_openapi_schema(): - response = client.get("/openapi.json") - assert response.status_code == 200, response.text - assert response.json() == { - "openapi": "3.1.0", - "info": {"title": "FastAPI", "version": "0.1.0"}, - "paths": { - "/users/me": { - "get": { - "responses": { - "200": { - "description": "Successful Response", - "content": {"application/json": {"schema": {}}}, - } - }, - "summary": "Read Current User", - "operationId": "read_current_user_users_me_get", - "security": [{"APIKeyQuery": []}], + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInQuery": { + "type": "apiKey", + "name": "key", + "in": "query", + "scheme_name": "APIKeyInQuery", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "APIKeyInQuery": {"type": "apiKey", "name": "key", "in": "query"} } - } - }, - "components": { - "securitySchemes": { - "APIKeyQuery": {"type": "apiKey", "name": "key", "in": "query"} - } - }, - } + }, + } diff --git a/tests/security/http/test_security_api_key_query_optional.py b/tests/security/http/test_security_api_key_query_optional.py index 4cc134bd..e150d906 100644 --- a/tests/security/http/test_security_api_key_query_optional.py +++ b/tests/security/http/test_security_api_key_query_optional.py @@ -1,13 +1,12 @@ -from typing import Optional +from typing import Any, Optional -from fastapi import Depends, FastAPI, Security -from fastapi.security import APIKeyQuery -from fastapi.testclient import TestClient from pydantic import BaseModel -app = FastAPI() +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.api_key import APIKeyInQuery +from esmerald.testclient import create_client -api_key = APIKeyQuery(name="key", auto_error=False) +api_key = APIKeyInQuery(name="key", auto_error=False) class User(BaseModel): @@ -21,52 +20,132 @@ def get_current_user(oauth_header: Optional[str] = Security(api_key)): return user -@app.get("/users/me") -def read_current_user(current_user: Optional[User] = Depends(get_current_user)): +@get("/users/me", dependencies={"current_user": Inject(get_current_user)}, security=[api_key]) +def read_current_user(current_user: Optional[User] = Injects()) -> Any: if current_user is None: return {"msg": "Create an account first"} - return current_user - - -client = TestClient(app) + else: + return current_user def test_security_api_key(): - response = client.get("/users/me?key=secret") - assert response.status_code == 200, response.text - assert response.json() == {"username": "secret"} + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me?key=secret") + assert response.status_code == 200, response.text + assert response.json() == {"username": "secret"} def test_security_api_key_no_key(): - response = client.get("/users/me") - assert response.status_code == 200, response.text - assert response.json() == {"msg": "Create an account first"} + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} def test_openapi_schema(): - response = client.get("/openapi.json") - assert response.status_code == 200, response.text - assert response.json() == { - "openapi": "3.1.0", - "info": {"title": "FastAPI", "version": "0.1.0"}, - "paths": { - "/users/me": { - "get": { - "responses": { - "200": { - "description": "Successful Response", - "content": {"application/json": {"schema": {}}}, - } - }, - "summary": "Read Current User", - "operationId": "read_current_user_users_me_get", - "security": [{"APIKeyQuery": []}], + with create_client(routes=[Gateway(handler=read_current_user)], enable_openapi=True) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "APIKeyInQuery": { + "type": "apiKey", + "name": "key", + "in": "query", + "scheme_name": "APIKeyInQuery", + } + } + ], + "parameters": [ + { + "name": "current_user", + "in": "query", + "required": True, + "deprecated": False, + "allowEmptyValue": False, + "allowReserved": False, + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/User"}, + {"type": "null"}, + ], + "title": "Current User", + }, + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } } - } - }, - "components": { - "securitySchemes": { - "APIKeyQuery": {"type": "apiKey", "name": "key", "in": "query"} - } - }, - } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "type": "array", + "title": "Detail", + } + }, + "type": "object", + "title": "HTTPValidationError", + }, + "User": { + "properties": {"username": {"type": "string", "title": "Username"}}, + "type": "object", + "required": ["username"], + "title": "User", + }, + "ValidationError": { + "properties": { + "loc": { + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + "type": "array", + "title": "Location", + }, + "msg": {"type": "string", "title": "Message"}, + "type": {"type": "string", "title": "Error Type"}, + }, + "type": "object", + "required": ["loc", "msg", "type"], + "title": "ValidationError", + }, + }, + "securitySchemes": { + "APIKeyInQuery": {"type": "apiKey", "name": "key", "in": "query"} + }, + }, + } diff --git a/tests/security/http/test_security_http_base.py b/tests/security/http/test_security_http_base.py new file mode 100644 index 00000000..2ceb3ee8 --- /dev/null +++ b/tests/security/http/test_security_http_base.py @@ -0,0 +1,78 @@ +from typing import Any + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPBase +from esmerald.testclient import create_client + +security = HTTPBase(scheme="Other") + + +@get( + "/users/me", + dependencies={"credentials": Inject(security)}, + security=[security], +) +def read_current_user( + credentials: HTTPAuthorizationCredentials = Injects(), +) -> Any: + if credentials is None: + return {"msg": "Create an account first"} + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def test_security_http_base(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me", headers={"Authorization": "Other foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Other", "credentials": "foobar"} + + +def test_security_http_base_no_credentials(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client(routes=[Gateway(handler=read_current_user)], enable_openapi=True) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBase": { + "type": "http", + "scheme": "Other", + "scheme_name": "HTTPBase", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": {"securitySchemes": {"HTTPBase": {"type": "http", "scheme": "Other"}}}, + } diff --git a/tests/security/http/test_security_http_base_description.py b/tests/security/http/test_security_http_base_description.py new file mode 100644 index 00000000..0c298de1 --- /dev/null +++ b/tests/security/http/test_security_http_base_description.py @@ -0,0 +1,79 @@ +from typing import Any + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPBase +from esmerald.testclient import create_client + +security = HTTPBase(scheme="Other", description="Other Security Scheme") + + +@get("/users/me", dependencies={"credentials": Inject(security)}, security=[security]) +def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any: + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def xtest_security_http_base(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me", headers={"Authorization": "Other foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Other", "credentials": "foobar"} + + +def test_security_http_base_no_credentials(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_openapi_schema(): + with create_client(routes=[Gateway(handler=read_current_user)], enable_openapi=True) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBase": { + "type": "http", + "description": "Other Security Scheme", + "scheme": "Other", + "scheme_name": "HTTPBase", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "HTTPBase": { + "type": "http", + "description": "Other Security Scheme", + "scheme": "Other", + } + } + }, + } diff --git a/tests/security/http/test_security_http_base_optional.py b/tests/security/http/test_security_http_base_optional.py new file mode 100644 index 00000000..224ac976 --- /dev/null +++ b/tests/security/http/test_security_http_base_optional.py @@ -0,0 +1,145 @@ +from typing import Any, Optional + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPBase +from esmerald.testclient import create_client + +security = HTTPBase(scheme="Other", auto_error=False) + + +@get( + "/users/me", + dependencies={"credentials": Inject(security)}, + security=[security], +) +def read_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Injects(), +) -> Any: + if credentials is None: + return {"msg": "Create an account first"} + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def test_security_http_base(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me", headers={"Au thorization": "Other foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Other", "credentials": "foobar"} + + +def test_security_http_base_no_credentials(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_openapi_schema(): + with create_client(routes=[Gateway(handler=read_current_user)], enable_openapi=True) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBase": { + "type": "http", + "scheme": "Other", + "scheme_name": "HTTPBase", + } + } + ], + "parameters": [ + { + "name": "credentials", + "in": "query", + "required": True, + "deprecated": False, + "allowEmptyValue": False, + "allowReserved": False, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/HTTPAuthorizationCredentials" + }, + {"type": "null"}, + ], + "title": "Credentials", + }, + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "HTTPAuthorizationCredentials": { + "properties": { + "scheme": {"type": "string", "title": "Scheme"}, + "credentials": {"type": "string", "title": "Credentials"}, + }, + "type": "object", + "required": ["scheme", "credentials"], + "title": "HTTPAuthorizationCredentials", + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "type": "array", + "title": "Detail", + } + }, + "type": "object", + "title": "HTTPValidationError", + }, + "ValidationError": { + "properties": { + "loc": { + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + "type": "array", + "title": "Location", + }, + "msg": {"type": "string", "title": "Message"}, + "type": {"type": "string", "title": "Error Type"}, + }, + "type": "object", + "required": ["loc", "msg", "type"], + "title": "ValidationError", + }, + }, + "securitySchemes": {"HTTPBase": {"type": "http", "scheme": "Other"}}, + }, + } From fcc3366546add0afed6e19ad88e235cfa55e3c14 Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 18 Nov 2024 17:50:41 +0100 Subject: [PATCH 14/24] Add tests for basic --- esmerald/openapi/openapi.py | 6 +- esmerald/security/base.py | 17 +++ esmerald/security/http/http.py | 10 +- esmerald/transformers/utils.py | 1 + .../test_security_api_key_cookie_optional.py | 70 +---------- .../test_security_api_key_header_optional.py | 64 +--------- .../test_security_api_key_query_optional.py | 64 +--------- .../http/test_security_http_base_optional.py | 73 +----------- .../http/test_security_http_basic_optional.py | 100 ++++++++++++++++ .../http/test_security_http_basic_realm.py | 112 ++++++++++++++++++ ...t_security_http_basic_realm_description.py | 105 ++++++++++++++++ .../security/oauth/test_oauth_code_bearer.py | 55 +-------- .../oauth/test_oauth_code_bearer_desc.py | 55 +-------- ...ecurity_oauth2_password_bearer_optional.py | 55 +-------- ...ty_oauth2_password_bearer_optional_desc.py | 55 +-------- 15 files changed, 364 insertions(+), 478 deletions(-) create mode 100644 tests/security/http/test_security_http_basic_optional.py create mode 100644 tests/security/http/test_security_http_basic_realm.py create mode 100644 tests/security/http/test_security_http_basic_realm_description.py diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index c30b3615..489720fb 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -70,11 +70,15 @@ def get_flat_params(route: Union[router.HTTPHandler, Any], body_fields: List[str cookie_params = [param.field_info for param in route.transformer.get_cookie_params()] header_params = [param.field_info for param in route.transformer.get_header_params()] + handler_dependencies = set(route.get_dependencies().keys()) handler_query_params = [ param for param in route.transformer.get_query_params() - if param.field_alias not in route.body_encoder_fields.keys() and not param.is_security + if param.field_alias not in route.body_encoder_fields.keys() + and not param.is_security + and param.field_alias not in handler_dependencies ] + query_params = [] for param in handler_query_params: is_union_or_optional = is_union(param.field_info.annotation) diff --git a/esmerald/security/base.py b/esmerald/security/base.py index 81867bab..1aaf48cf 100644 --- a/esmerald/security/base.py +++ b/esmerald/security/base.py @@ -14,3 +14,20 @@ class SecurityBase(SecurityScheme): """ A flag to indicate if automatic error handling should be enabled. """ + __is_security__: bool = True + """A flag to indicate that this is a security scheme. """ + + +class HttpSecurityBase(SecurityScheme): + scheme_name: Optional[str] = None + """ + An optional name for the security scheme. + """ + realm: Optional[str] = None + """ + An optional realm for the security scheme. + """ + __auto_error__: bool = False + """ + A flag to indicate if automatic error handling should be enabled. + """ diff --git a/esmerald/security/http/http.py b/esmerald/security/http/http.py index 688671fe..55474beb 100644 --- a/esmerald/security/http/http.py +++ b/esmerald/security/http/http.py @@ -1,6 +1,6 @@ import binascii from base64 import b64decode -from typing import Optional, Union +from typing import Any, Optional, Union from lilya.requests import Request from lilya.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN @@ -9,7 +9,7 @@ from esmerald.exceptions import HTTPException from esmerald.openapi.models import HTTPBase as HTTPBaseModel, HTTPBearer as HTTPBearerModel -from esmerald.security.base import SecurityBase +from esmerald.security.base import HttpSecurityBase from esmerald.security.utils import get_authorization_scheme_param @@ -39,7 +39,7 @@ class HTTPAuthorizationCredentials(BaseModel): credentials: Annotated[str, Doc("The authorization credentials extracted from the header.")] -class HTTPBase(SecurityBase): +class HTTPBase(HttpSecurityBase): def __init__( self, *, @@ -47,6 +47,7 @@ def __init__( scheme_name: Union[str, None] = None, description: Union[str, None] = None, auto_error: bool = True, + **kwargs: Any, ): """ Base class for HTTP security schemes. @@ -58,7 +59,8 @@ def __init__( auto_error (bool, optional): Whether to automatically raise an error if authentication fails. """ model = HTTPBaseModel(scheme=scheme, description=description) - super().__init__(**model.model_dump()) + model_dump = {**model.model_dump(), **kwargs} + super().__init__(**model_dump) self.scheme_name = scheme_name or self.__class__.__name__ self.__auto_error__ = auto_error diff --git a/esmerald/transformers/utils.py b/esmerald/transformers/utils.py index 93893abd..c6d1a32f 100644 --- a/esmerald/transformers/utils.py +++ b/esmerald/transformers/utils.py @@ -42,6 +42,7 @@ class ParamSetting(NamedTuple): field_info: FieldInfo is_security: bool = False + class Dependency(HashableBaseModel, ArbitraryExtraBaseModel): def __init__( self, key: str, inject: "Inject", dependencies: List["Dependency"], **kwargs: Any diff --git a/tests/security/http/test_security_api_key_cookie_optional.py b/tests/security/http/test_security_api_key_cookie_optional.py index e21a4d43..62f9e0c6 100644 --- a/tests/security/http/test_security_api_key_cookie_optional.py +++ b/tests/security/http/test_security_api_key_cookie_optional.py @@ -87,82 +87,18 @@ def test_openapi_schema(): } } ], - "parameters": [ - { - "name": "current_user", - "in": "query", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": { - "anyOf": [ - {"$ref": "#/components/schemas/User"}, - {"type": "null"}, - ], - "title": "Current User", - }, - } - ], "responses": { "200": { "description": "Successful response", "content": {"application/json": {"schema": {"type": "string"}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, + } }, } } }, "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "User": { - "properties": {"username": {"type": "string", "title": "Username"}}, - "type": "object", - "required": ["username"], - "title": "User", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, "securitySchemes": { - "APIKeyInCookie": { - "type": "apiKey", - "name": "key", - "in": "cookie", - } - }, + "APIKeyInCookie": {"type": "apiKey", "name": "key", "in": "cookie"} + } }, } diff --git a/tests/security/http/test_security_api_key_header_optional.py b/tests/security/http/test_security_api_key_header_optional.py index 0c2173ad..6d1dbc4b 100644 --- a/tests/security/http/test_security_api_key_header_optional.py +++ b/tests/security/http/test_security_api_key_header_optional.py @@ -87,78 +87,18 @@ def test_openapi_schema(): } } ], - "parameters": [ - { - "name": "current_user", - "in": "query", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": { - "anyOf": [ - {"$ref": "#/components/schemas/User"}, - {"type": "null"}, - ], - "title": "Current User", - }, - } - ], "responses": { "200": { "description": "Successful response", "content": {"application/json": {"schema": {"type": "string"}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, + } }, } } }, "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "User": { - "properties": {"username": {"type": "string", "title": "Username"}}, - "type": "object", - "required": ["username"], - "title": "User", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, "securitySchemes": { "APIKeyInHeader": {"type": "apiKey", "name": "key", "in": "header"} - }, + } }, } diff --git a/tests/security/http/test_security_api_key_query_optional.py b/tests/security/http/test_security_api_key_query_optional.py index e150d906..fa2dec7a 100644 --- a/tests/security/http/test_security_api_key_query_optional.py +++ b/tests/security/http/test_security_api_key_query_optional.py @@ -74,78 +74,18 @@ def test_openapi_schema(): } } ], - "parameters": [ - { - "name": "current_user", - "in": "query", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": { - "anyOf": [ - {"$ref": "#/components/schemas/User"}, - {"type": "null"}, - ], - "title": "Current User", - }, - } - ], "responses": { "200": { "description": "Successful response", "content": {"application/json": {"schema": {"type": "string"}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, + } }, } } }, "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "User": { - "properties": {"username": {"type": "string", "title": "Username"}}, - "type": "object", - "required": ["username"], - "title": "User", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, "securitySchemes": { "APIKeyInQuery": {"type": "apiKey", "name": "key", "in": "query"} - }, + } }, } diff --git a/tests/security/http/test_security_http_base_optional.py b/tests/security/http/test_security_http_base_optional.py index 224ac976..97e97b71 100644 --- a/tests/security/http/test_security_http_base_optional.py +++ b/tests/security/http/test_security_http_base_optional.py @@ -22,7 +22,7 @@ def read_current_user( def test_security_http_base(): with create_client(routes=[Gateway(handler=read_current_user)]) as client: - response = client.get("/users/me", headers={"Au thorization": "Other foobar"}) + response = client.get("/users/me", headers={"Authorization": "Other foobar"}) assert response.status_code == 200, response.text assert response.json() == {"scheme": "Other", "credentials": "foobar"} @@ -65,81 +65,14 @@ def test_openapi_schema(): } } ], - "parameters": [ - { - "name": "credentials", - "in": "query", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/HTTPAuthorizationCredentials" - }, - {"type": "null"}, - ], - "title": "Credentials", - }, - } - ], "responses": { "200": { "description": "Successful response", "content": {"application/json": {"schema": {"type": "string"}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, + } }, } } }, - "components": { - "schemas": { - "HTTPAuthorizationCredentials": { - "properties": { - "scheme": {"type": "string", "title": "Scheme"}, - "credentials": {"type": "string", "title": "Credentials"}, - }, - "type": "object", - "required": ["scheme", "credentials"], - "title": "HTTPAuthorizationCredentials", - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, - "securitySchemes": {"HTTPBase": {"type": "http", "scheme": "Other"}}, - }, + "components": {"securitySchemes": {"HTTPBase": {"type": "http", "scheme": "Other"}}}, } diff --git a/tests/security/http/test_security_http_basic_optional.py b/tests/security/http/test_security_http_basic_optional.py new file mode 100644 index 00000000..993c0f43 --- /dev/null +++ b/tests/security/http/test_security_http_basic_optional.py @@ -0,0 +1,100 @@ +from base64 import b64encode +from typing import Any, Optional + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.http import HTTPBasic, HTTPBasicCredentials +from esmerald.testclient import create_client + +security = HTTPBasic(auto_error=False) + + +@get( + "/users/me", + security=[security], + dependencies={"credentials": Inject(security)}, +) +def read_current_user(credentials: Optional[HTTPBasicCredentials] = Injects()) -> Any: + if credentials is None: + return {"msg": "Create an account first"} + return {"username": credentials.username, "password": credentials.password} + + +def test_security_http_basic(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", auth=("john", "secret")) + assert response.status_code == 200, response.text + assert response.json() == {"username": "john", "password": "secret"} + + +def test_security_http_basic_no_credentials(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_security_http_basic_invalid_credentials(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me", headers={"Authorization": "Basic notabase64token"}) + assert response.status_code == 401, response.text + assert response.headers["WWW-Authenticate"] == "Basic" + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_security_http_basic_non_basic_credentials(): + payload = b64encode(b"johnsecret").decode("ascii") + auth_header = f"Basic {payload}" + + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me", headers={"Authorization": auth_header}) + assert response.status_code == 401, response.text + assert response.headers["WWW-Authenticate"] == "Basic" + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_openapi_schema(): + with create_client(routes=[Gateway(handler=read_current_user)], enable_openapi=True) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBasic": { + "type": "http", + "scheme": "basic", + "scheme_name": "HTTPBasic", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": {"securitySchemes": {"HTTPBasic": {"type": "http", "scheme": "basic"}}}, + } diff --git a/tests/security/http/test_security_http_basic_realm.py b/tests/security/http/test_security_http_basic_realm.py new file mode 100644 index 00000000..e020c2d8 --- /dev/null +++ b/tests/security/http/test_security_http_basic_realm.py @@ -0,0 +1,112 @@ +from base64 import b64encode +from typing import Any + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.http import HTTPBasic, HTTPBasicCredentials +from esmerald.testclient import create_client + +security = HTTPBasic(realm="simple") + + +@get("/users/me", security=[security], dependencies={"credentials": Inject(security)}) +def read_current_user(credentials: HTTPBasicCredentials = Injects()) -> Any: + return {"username": credentials.username, "password": credentials.password} + + +def test_security_http_basic(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", auth=("john", "secret")) + assert response.status_code == 200, response.text + assert response.json() == {"username": "john", "password": "secret"} + + +def test_security_http_basic_no_credentials(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me") + assert response.json() == {"detail": "Not authenticated"} + assert response.status_code == 401, response.text + assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"' + + +def test_security_http_basic_invalid_credentials(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", headers={"Authorization": "Basic notabase64token"}) + assert response.status_code == 401, response.text + assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"' + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_security_http_basic_non_basic_credentials(): + payload = b64encode(b"johnsecret").decode("ascii") + auth_header = f"Basic {payload}" + + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", headers={"Authorization": auth_header}) + assert response.status_code == 401, response.text + assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"' + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBasic": { + "type": "http", + "scheme": "basic", + "scheme_name": "HTTPBasic", + "realm": "simple", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": {"securitySchemes": {"HTTPBasic": {"type": "http", "scheme": "basic"}}}, + } diff --git a/tests/security/http/test_security_http_basic_realm_description.py b/tests/security/http/test_security_http_basic_realm_description.py new file mode 100644 index 00000000..629a873e --- /dev/null +++ b/tests/security/http/test_security_http_basic_realm_description.py @@ -0,0 +1,105 @@ +from base64 import b64encode +from typing import Any + +from esmerald import Gateway, Inject, Injects, get +from esmerald.security.http import HTTPBasic, HTTPBasicCredentials +from esmerald.testclient import create_client + +security = HTTPBasic(realm="simple", description="HTTPBasic scheme") + + +@get("/users/me", security=[security], dependencies={"credentials": Inject(security)}) +def read_current_user(credentials: HTTPBasicCredentials = Injects()) -> Any: + return {"username": credentials.username, "password": credentials.password} + + +def test_security_http_basic(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + ) as client: + response = client.get("/users/me", auth=("john", "secret")) + assert response.status_code == 200, response.text + assert response.json() == {"username": "john", "password": "secret"} + + +def test_security_http_basic_no_credentials(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me") + assert response.json() == {"detail": "Not authenticated"} + assert response.status_code == 401, response.text + assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"' + + +def test_security_http_basic_invalid_credentials(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me", headers={"Authorization": "Basic notabase64token"}) + assert response.status_code == 401, response.text + assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"' + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_security_http_basic_non_basic_credentials(): + payload = b64encode(b"johnsecret").decode("ascii") + auth_header = f"Basic {payload}" + + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/users/me", headers={"Authorization": auth_header}) + assert response.status_code == 401, response.text + assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"' + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_openapi_schema(): + with create_client(routes=[Gateway(handler=read_current_user)]) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBasic": { + "type": "http", + "description": "HTTPBasic scheme", + "scheme": "basic", + "scheme_name": "HTTPBasic", + "realm": "simple", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "HTTPBasic": { + "type": "http", + "description": "HTTPBasic scheme", + "scheme": "basic", + } + } + }, + } diff --git a/tests/security/oauth/test_oauth_code_bearer.py b/tests/security/oauth/test_oauth_code_bearer.py index 4a86eea0..77789ce1 100644 --- a/tests/security/oauth/test_oauth_code_bearer.py +++ b/tests/security/oauth/test_oauth_code_bearer.py @@ -88,67 +88,16 @@ def test_openapi_schema(): } } ], - "parameters": [ - { - "name": "token", - "in": "query", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "title": "Token", - }, - } - ], "responses": { "200": { "description": "Successful response", "content": {"application/json": {"schema": {"type": "string"}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, + } }, } } }, "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, "securitySchemes": { "OAuth2AuthorizationCodeBearer": { "type": "oauth2", @@ -160,6 +109,6 @@ def test_openapi_schema(): } }, } - }, + } }, } diff --git a/tests/security/oauth/test_oauth_code_bearer_desc.py b/tests/security/oauth/test_oauth_code_bearer_desc.py index 6fb2fe61..11f349b4 100644 --- a/tests/security/oauth/test_oauth_code_bearer_desc.py +++ b/tests/security/oauth/test_oauth_code_bearer_desc.py @@ -92,67 +92,16 @@ def test_openapi_schema(): } } ], - "parameters": [ - { - "name": "token", - "in": "query", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "title": "Token", - }, - } - ], "responses": { "200": { "description": "Successful response", "content": {"application/json": {"schema": {"type": "string"}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, + } }, } } }, "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, "securitySchemes": { "OAuth2AuthorizationCodeBearer": { "type": "oauth2", @@ -165,6 +114,6 @@ def test_openapi_schema(): } }, } - }, + } }, } diff --git a/tests/security/oauth/test_security_oauth2_password_bearer_optional.py b/tests/security/oauth/test_security_oauth2_password_bearer_optional.py index 24490cec..2c7da034 100644 --- a/tests/security/oauth/test_security_oauth2_password_bearer_optional.py +++ b/tests/security/oauth/test_security_oauth2_password_bearer_optional.py @@ -83,72 +83,21 @@ def test_openapi_schema(): } } ], - "parameters": [ - { - "name": "token", - "in": "query", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "title": "Token", - }, - } - ], "responses": { "200": { "description": "Successful response", "content": {"application/json": {"schema": {"type": "string"}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, + } }, } } }, "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, "securitySchemes": { "OAuth2PasswordBearer": { "type": "oauth2", "flows": {"password": {"tokenUrl": "/token", "scopes": {}}}, } - }, + } }, } diff --git a/tests/security/oauth/test_security_oauth2_password_bearer_optional_desc.py b/tests/security/oauth/test_security_oauth2_password_bearer_optional_desc.py index e40f3e3e..9f1b46ec 100644 --- a/tests/security/oauth/test_security_oauth2_password_bearer_optional_desc.py +++ b/tests/security/oauth/test_security_oauth2_password_bearer_optional_desc.py @@ -86,73 +86,22 @@ def test_openapi_schema(): } } ], - "parameters": [ - { - "name": "token", - "in": "query", - "required": True, - "deprecated": False, - "allowEmptyValue": False, - "allowReserved": False, - "schema": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "title": "Token", - }, - } - ], "responses": { "200": { "description": "Successful response", "content": {"application/json": {"schema": {"type": "string"}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, + } }, } } }, "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "type": "array", - "title": "Detail", - } - }, - "type": "object", - "title": "HTTPValidationError", - }, - "ValidationError": { - "properties": { - "loc": { - "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, - "type": "array", - "title": "Location", - }, - "msg": {"type": "string", "title": "Message"}, - "type": {"type": "string", "title": "Error Type"}, - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError", - }, - }, "securitySchemes": { "OAuth2PasswordBearer": { "type": "oauth2", "description": "OAuth2PasswordBearer security scheme", "flows": {"password": {"tokenUrl": "/token", "scopes": {}}}, } - }, + } }, } From a44fec2e3c486598df64c1818172c6e996ea6b0f Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 18 Nov 2024 18:32:32 +0100 Subject: [PATCH 15/24] Add digest tests --- esmerald/security/http/http.py | 21 ++--- esmerald/transformers/signature.py | 6 +- .../http/test_security_http_bearer.py | 83 +++++++++++++++++ .../test_security_http_bearer_description.py | 90 +++++++++++++++++++ .../test_security_http_bearer_optional.py | 85 ++++++++++++++++++ .../http/test_security_http_digest.py | 79 ++++++++++++++++ .../test_security_http_digest_description.py | 86 ++++++++++++++++++ .../test_security_http_digest_optional.py | 83 +++++++++++++++++ 8 files changed, 517 insertions(+), 16 deletions(-) create mode 100644 tests/security/http/test_security_http_bearer.py create mode 100644 tests/security/http/test_security_http_bearer_description.py create mode 100644 tests/security/http/test_security_http_bearer_optional.py create mode 100644 tests/security/http/test_security_http_digest.py create mode 100644 tests/security/http/test_security_http_digest_description.py create mode 100644 tests/security/http/test_security_http_digest_optional.py diff --git a/esmerald/security/http/http.py b/esmerald/security/http/http.py index 55474beb..53e72ae7 100644 --- a/esmerald/security/http/http.py +++ b/esmerald/security/http/http.py @@ -261,18 +261,15 @@ def __init__( async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]: authorization = request.headers.get("Authorization") - if not authorization: - if self.__auto_error__: - raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") - return None - scheme, credentials = get_authorization_scheme_param(authorization) - if not (scheme and credentials) or scheme.lower() != "digest": + if not (authorization and scheme and credentials): if self.__auto_error__: - raise HTTPException( - status_code=HTTP_403_FORBIDDEN, - detail="Invalid authentication credentials", - ) - return None - + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated") + else: + return None + if scheme.lower() != "digest": + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Invalid authentication credentials", + ) return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) diff --git a/esmerald/transformers/signature.py b/esmerald/transformers/signature.py index f20ef8da..7a2dc08f 100644 --- a/esmerald/transformers/signature.py +++ b/esmerald/transformers/signature.py @@ -154,9 +154,7 @@ class SignatureModel(ArbitraryBaseModel): encoders: ClassVar[Dict["Encoder", Any]] @classmethod - async def parse_encoders( - cls, connection: Union[Request, WebSocket], kwargs: Dict[str, Any] - ) -> Dict[str, Any]: + async def parse_encoders(cls, kwargs: Dict[str, Any]) -> Dict[str, Any]: """ Parses the kwargs into a proper structure for the encoder itself. @@ -226,7 +224,7 @@ async def parse_values_for_connection( """ try: if cls.encoders: - kwargs = await cls.parse_encoders(connection, kwargs) + kwargs = await cls.parse_encoders(kwargs) signature = cls(**kwargs) values = {} for key in cls.model_fields: diff --git a/tests/security/http/test_security_http_bearer.py b/tests/security/http/test_security_http_bearer.py new file mode 100644 index 00000000..59987102 --- /dev/null +++ b/tests/security/http/test_security_http_bearer.py @@ -0,0 +1,83 @@ +from typing import Any + +from esmerald import Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPBearer +from esmerald.testclient import create_client + +security = HTTPBearer() + + +@get( + "/users/me", + security=[security], + dependencies={"credentials": Inject(security)}, +) +def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any: + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def test_security_http_bearer(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Bearer foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Bearer", "credentials": "foobar"} + + +def test_security_http_bearer_no_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_security_http_bearer_incorrect_scheme_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Basic notreally"}) + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_openapi_schema(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBearer": { + "type": "http", + "scheme": "bearer", + "scheme_name": "HTTPBearer", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": {"HTTPBearer": {"type": "http", "scheme": "bearer"}} + }, + } diff --git a/tests/security/http/test_security_http_bearer_description.py b/tests/security/http/test_security_http_bearer_description.py new file mode 100644 index 00000000..43074eac --- /dev/null +++ b/tests/security/http/test_security_http_bearer_description.py @@ -0,0 +1,90 @@ +from typing import Any + +from esmerald import Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPBearer +from esmerald.testclient import create_client + +security = HTTPBearer(description="HTTP Bearer token scheme") + + +@get( + "/users/me", + security=[security], + dependencies={"credentials": Inject(security)}, +) +def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any: + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def test_security_http_bearer(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Bearer foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Bearer", "credentials": "foobar"} + + +def test_security_http_bearer_no_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_security_http_bearer_incorrect_scheme_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Basic notreally"}) + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_openapi_schema(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBearer": { + "type": "http", + "description": "HTTP Bearer token scheme", + "scheme": "bearer", + "scheme_name": "HTTPBearer", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "description": "HTTP Bearer token scheme", + "scheme": "bearer", + } + } + }, + } diff --git a/tests/security/http/test_security_http_bearer_optional.py b/tests/security/http/test_security_http_bearer_optional.py new file mode 100644 index 00000000..1b53691f --- /dev/null +++ b/tests/security/http/test_security_http_bearer_optional.py @@ -0,0 +1,85 @@ +from typing import Any, Optional + +from esmerald import Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPBearer +from esmerald.testclient import create_client + +security = HTTPBearer(auto_error=False) + + +@get( + "/users/me", + security=[security], + dependencies={"credentials": Inject(security)}, +) +def read_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Injects()) -> Any: + if credentials is None: + return {"msg": "Create an account first"} + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def xtest_security_http_bearer(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Bearer foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Bearer", "credentials": "foobar"} + + +def test_security_http_bearer_no_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_security_http_bearer_incorrect_scheme_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Basic notreally"}) + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_openapi_schema(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": "3.5.0", + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPBearer": { + "type": "http", + "scheme": "bearer", + "scheme_name": "HTTPBearer", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": {"HTTPBearer": {"type": "http", "scheme": "bearer"}} + }, + } diff --git a/tests/security/http/test_security_http_digest.py b/tests/security/http/test_security_http_digest.py new file mode 100644 index 00000000..cca0391d --- /dev/null +++ b/tests/security/http/test_security_http_digest.py @@ -0,0 +1,79 @@ +from typing import Any + +from esmerald import Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPDigest +from esmerald.testclient import create_client + +security = HTTPDigest() + + +@get("/users/me", security=[security], dependencies={"credentials": Inject(security)}) +def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any: + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def xtest_security_http_digest(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Digest foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Digest", "credentials": "foobar"} + + +def test_security_http_digest_no_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_security_http_digest_incorrect_scheme_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Other invalidauthorization"}) + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_openapi_schema(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPDigest": { + "type": "http", + "scheme": "digest", + "scheme_name": "HTTPDigest", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": {"HTTPDigest": {"type": "http", "scheme": "digest"}} + }, + } diff --git a/tests/security/http/test_security_http_digest_description.py b/tests/security/http/test_security_http_digest_description.py new file mode 100644 index 00000000..694a9a54 --- /dev/null +++ b/tests/security/http/test_security_http_digest_description.py @@ -0,0 +1,86 @@ +from typing import Any + +from esmerald import Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPDigest +from esmerald.testclient import create_client + +security = HTTPDigest(description="HTTPDigest scheme") + + +@get("/users/me", security=[security], dependencies={"credentials": Inject(security)}) +def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any: + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def test_security_http_digest(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Digest foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Digest", "credentials": "foobar"} + + +def test_security_http_digest_no_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me") + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Not authenticated"} + + +def test_security_http_digest_incorrect_scheme_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Other invalidauthorization"}) + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_openapi_schema(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPDigest": { + "type": "http", + "description": "HTTPDigest scheme", + "scheme": "digest", + "scheme_name": "HTTPDigest", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": { + "HTTPDigest": { + "type": "http", + "description": "HTTPDigest scheme", + "scheme": "digest", + } + } + }, + } diff --git a/tests/security/http/test_security_http_digest_optional.py b/tests/security/http/test_security_http_digest_optional.py new file mode 100644 index 00000000..c3fd03c7 --- /dev/null +++ b/tests/security/http/test_security_http_digest_optional.py @@ -0,0 +1,83 @@ +from typing import Any, Optional + +from esmerald import Inject, Injects, get +from esmerald.security.http import HTTPAuthorizationCredentials, HTTPDigest +from esmerald.testclient import create_client + +security = HTTPDigest(auto_error=False) + + +@get("/users/me", security=[security], dependencies={"credentials": Inject(security)}) +def read_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Injects(), +) -> Any: + if credentials is None: + return {"msg": "Create an account first"} + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + + +def xtest_security_http_digest(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Digest foobar"}) + assert response.status_code == 200, response.text + assert response.json() == {"scheme": "Digest", "credentials": "foobar"} + + +def test_security_http_digest_no_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_security_http_digest_incorrect_scheme_credentials(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/users/me", headers={"Authorization": "Other invalidauthorization"}) + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Invalid authentication credentials"} + + +def test_openapi_schema(): + with create_client(routes=[read_current_user]) as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "deprecated": False, + "security": [ + { + "HTTPDigest": { + "type": "http", + "scheme": "digest", + "scheme_name": "HTTPDigest", + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + } + } + }, + "components": { + "securitySchemes": {"HTTPDigest": {"type": "http", "scheme": "digest"}} + }, + } From 2016d5e6a35e67f01f5075fc31c0e6aca39f398c Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 18 Nov 2024 18:36:17 +0100 Subject: [PATCH 16/24] Add new security docs section --- docs/en/docs/security/first-steps.md | 1 + docs/en/docs/security/index.md | 1 + docs/en/mkdocs.yml | 3 +++ 3 files changed, 5 insertions(+) create mode 100644 docs/en/docs/security/first-steps.md create mode 100644 docs/en/docs/security/index.md diff --git a/docs/en/docs/security/first-steps.md b/docs/en/docs/security/first-steps.md new file mode 100644 index 00000000..3790de05 --- /dev/null +++ b/docs/en/docs/security/first-steps.md @@ -0,0 +1 @@ +# First Steps diff --git a/docs/en/docs/security/index.md b/docs/en/docs/security/index.md new file mode 100644 index 00000000..8dbb2f9b --- /dev/null +++ b/docs/en/docs/security/index.md @@ -0,0 +1 @@ +# Security diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index d5dbbcc1..6c4ea4b2 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -126,6 +126,9 @@ nav: - background-tasks.md - lifespan-events.md - protocols.md + - Security: + - security/index.md + - security/first-steps.md - Advanced & Useful: - extras/index.md - extras/path-params.md From a62ed1004bb11ec9fc0f4f3a68b2d3e2690eff7e Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 18 Nov 2024 18:41:21 +0100 Subject: [PATCH 17/24] Update internal docs --- esmerald/security/http/http.py | 59 ++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/esmerald/security/http/http.py b/esmerald/security/http/http.py index 53e72ae7..9cd9f18a 100644 --- a/esmerald/security/http/http.py +++ b/esmerald/security/http/http.py @@ -90,15 +90,16 @@ class HTTPBasic(HTTPBase): Example: ```python - from typing import Annotated - from fastapi import Depends, FastAPI - from fastapi.security import HTTPBasic, HTTPBasicCredentials + from typing import Any + + from esmerald import Gateway, Inject, Injects, get + from esmerald.security.http import HTTPBasic, HTTPBasicCredentials + from esmerald.testclient import create_client - app = FastAPI() security = HTTPBasic() - @app.get("/users/me") - def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]): + @app.get("/users/me", security=[security], dependencies={"credentials": Inject(security)})) + def read_current_user(credentials: HTTPBasicCredentials = Injects()): return {"username": credentials.username, "password": credentials.password} ``` """ @@ -163,19 +164,20 @@ class HTTPBearer(HTTPBase): Use this class as a dependency to enforce HTTP Bearer token authentication. - Example: - ```python - from typing import Annotated - from fastapi import Depends, FastAPI - from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + ## Example - app = FastAPI() - security = HTTPBearer() + ```python + from typing import Any - @app.get("/users/me") - def read_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]): - return {"scheme": credentials.scheme, "credentials": credentials.credentials} - ``` + from esmerald import Inject, Injects, get + from esmerald.security.http import HTTPAuthorizationCredentials, HTTPBearer + + security = HTTPBearer() + + @app.get("/users/me") + def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any:: + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + ``` """ def __init__( @@ -224,19 +226,20 @@ class HTTPDigest(HTTPBase): Use this class as a dependency to enforce HTTP Digest authentication. - Example: - ```python - from typing import Annotated - from fastapi import Depends, FastAPI - from fastapi.security import HTTPAuthorizationCredentials, HTTPDigest + ## Example: - app = FastAPI() - security = HTTPDigest() + ```python + from typing import Any - @app.get("/users/me") - def read_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]): - return {"scheme": credentials.scheme, "credentials": credentials.credentials} - ``` + from esmerald import Inject, Injects, get + from esmerald.security.http import HTTPAuthorizationCredentials, HTTPDigest + + security = HTTPDigest() + + @get("/users/me", security=[security], dependencies={"credentials": Inject(security)}) + def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any: + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + ``` """ def __init__( From dc85ef934f3647d4b8d7510322824bcca630948b Mon Sep 17 00:00:00 2001 From: tarsil Date: Tue, 19 Nov 2024 09:56:15 +0100 Subject: [PATCH 18/24] Add extra openid test --- .../test_security_openid_connect_optional.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/security/openid/test_security_openid_connect_optional.py diff --git a/tests/security/openid/test_security_openid_connect_optional.py b/tests/security/openid/test_security_openid_connect_optional.py new file mode 100644 index 00000000..2437ddb4 --- /dev/null +++ b/tests/security/openid/test_security_openid_connect_optional.py @@ -0,0 +1,102 @@ +from typing import Any, Optional + +from pydantic import BaseModel + +from esmerald import Gateway, Inject, Injects, Security, get +from esmerald.security.open_id import OpenIdConnect +from esmerald.testclient import create_client + +oid = OpenIdConnect(openIdConnectUrl="/openid", auto_error=False) + + +class User(BaseModel): + username: str + + +def get_current_user(oauth_header: Optional[str] = Security(oid)): + if oauth_header is None: + return None + user = User(username=oauth_header) + return user + + +@get("/users/me", dependencies={"current_user": Inject(get_current_user)}) +def read_current_user(current_user: Optional[User] = Injects()) -> Any: + if current_user is None: + return {"msg": "Create an account first"} + return current_user + + +def test_security_oauth2(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me", headers={"Authorization": "Bearer footokenbar"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "Bearer footokenbar"} + + +def test_security_oauth2_password_other_header(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me", headers={"Authorization": "Other footokenbar"}) + assert response.status_code == 200, response.text + assert response.json() == {"username": "Other footokenbar"} + + +def test_security_oauth2_password_bearer_no_header(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/users/me") + assert response.status_code == 200, response.text + assert response.json() == {"msg": "Create an account first"} + + +def test_openapi_schema(): + with create_client( + routes=[ + Gateway(handler=read_current_user), + ], + enable_openapi=True, + ) as client: + response = client.get("/openapi.json") + + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": { + "title": "Esmerald", + "summary": "Esmerald application", + "description": "Highly scalable, performant, easy to learn and for every application.", + "contact": {"name": "admin", "email": "admin@myapp.com"}, + "version": client.app.version, + }, + "servers": [{"url": "/"}], + "paths": { + "/users/me": { + "get": { + "summary": "Read Current User", + "description": "", + "operationId": "read_current_user_users_me_get", + "responses": { + "200": { + "description": "Successful response", + "content": {"application/json": {"schema": {"type": "string"}}}, + } + }, + "deprecated": False, + } + } + }, + } From ef8b75d7bcb3aec50ff90cab1c1d4f44ddf0519f Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 9 Dec 2024 14:15:43 +0100 Subject: [PATCH 19/24] Docs update --- docs/en/docs/security/first-steps.md | 1 - docs/en/docs/security/index.md | 79 +++++ docs/en/docs/security/interaction.md | 111 ++++++ docs/en/docs/security/introduction.md | 180 ++++++++++ docs/en/docs/security/simple-oauth2.md | 185 ++++++++++ docs/en/mkdocs.yml | 452 +++++++++++++------------ docs_src/security/app.py | 18 + docs_src/security/enhance.py | 28 ++ docs_src/security/post.py | 103 ++++++ esmerald/injector.py | 10 + esmerald/params.py | 6 +- esmerald/security/http/http.py | 27 +- esmerald/transformers/model.py | 2 +- esmerald/utils/dependencies.py | 9 + 14 files changed, 970 insertions(+), 241 deletions(-) delete mode 100644 docs/en/docs/security/first-steps.md create mode 100644 docs/en/docs/security/interaction.md create mode 100644 docs/en/docs/security/introduction.md create mode 100644 docs/en/docs/security/simple-oauth2.md create mode 100644 docs_src/security/app.py create mode 100644 docs_src/security/enhance.py create mode 100644 docs_src/security/post.py diff --git a/docs/en/docs/security/first-steps.md b/docs/en/docs/security/first-steps.md deleted file mode 100644 index 3790de05..00000000 --- a/docs/en/docs/security/first-steps.md +++ /dev/null @@ -1 +0,0 @@ -# First Steps diff --git a/docs/en/docs/security/index.md b/docs/en/docs/security/index.md index 8dbb2f9b..7d73179d 100644 --- a/docs/en/docs/security/index.md +++ b/docs/en/docs/security/index.md @@ -1 +1,80 @@ # Security + +Security, authentication, and authorization can be approached in various ways. + +These topics are often considered complex and challenging to master. + +In many frameworks and systems, managing security and authentication requires significant effort, +often accounting for 50% or more of the total codebase. + +**Esmerald**, however, offers a range of tools that simplify handling security. +These tools enable you to implement secure systems quickly, efficiently, +and in compliance with standards—without needing to dive deeply into all the technical specifications of security. + +Before diving in, let’s explore a few key concepts. + +## Quick Note + +If you don't need to worry about these concepts, terms and terminologies or you are simply familiar with those, you can +jump directly to the next sections. + +## OAuth + +OAuth2 is a comprehensive specification that outlines multiple methods for managing authentication and authorization. + +It is designed to handle a wide range of complex scenarios. + +One of its key features is enabling authentication through a "third party." + +This is the foundation for systems that offer options like "Sign via Facebook", +"Sign in using Google" or "Login via GitHub". + +### OAuth (first version) + +OAuth 1 was an earlier version of the OAuth specification, significantly different from OAuth2. +It was more complex because it included detailed requirements for encrypting communication. + +Today, OAuth 1 is rarely used or popular. + +In contrast, OAuth2 simplifies this aspect by not defining how to encrypt communication. +Instead, it assumes that your application is served over HTTPS, ensuring secure communication by +relying on the encryption provided by the protocol. + +## OpenID Connect + +OpenID Connect is a specification built on top of **OAuth2**. + +It extends OAuth2 by addressing ambiguities in the original specification, aiming to improve interoperability across systems. + +For instance, Google login leverages OpenID Connect, which operates on top of OAuth2. + +However, Facebook login does not support OpenID Connect and instead uses its own customized version of OAuth2. + +## OpenID (not "OpenID Connect") + +There was also an earlier specification called "OpenID," which aimed to address the same challenges as **OpenID Connect**. +However, it was not built on OAuth2 and functioned as a completely separate system. + +Unlike OpenID Connect, OpenID did not achieve widespread adoption and is rarely used today. + +## The OpenAPI + +Did you know that OpenAPI in the past was called **Swagger**? You probably did and this is we still have the *swagger ui* +and the constant use of that name. + +**Esmerald** provides a native integration with **OpenAPI** as well with its automatic documentation generation. + +Why this? Well, using the OpenAPI specification it can also take advantage of the standard-based tools for security. + +The following `security schemes` are supported by OpenAPI: + +* `apiKey` - An application specific key that can come from: + * Cookie parameter + * Header parameter + * Header parameter +* `http` - The standard HTTP authentication system that includes: + * `bearer` - An header `Authorization` followed by a value of `Bearer` with the corresponding token. + * HTTP Basic Auth + * HTTP Digest + +OpenAPI also supports the previously mentioned `OAuth2` and `OpenID Connect`. diff --git a/docs/en/docs/security/interaction.md b/docs/en/docs/security/interaction.md new file mode 100644 index 00000000..2e2d47a8 --- /dev/null +++ b/docs/en/docs/security/interaction.md @@ -0,0 +1,111 @@ +# Interaction & Next Steps + +In the [previous chapter](./introduction.md), the security system—based on **Esmerald's** dependency injection system was providing the `path operation function` with a `token` as a `str`. + +This token was extracted from the `Authorization` header of the incoming request. The security system automatically handled this, so the function didn't need to worry about how the token was retrieved. The function simply received the token as a string, which it could then use for further processing, such as verifying the token's validity or checking user permissions. + +```python hl_lines="9-10" +{!> ../../../docs_src/security/app.py !} +``` + +That’s still not very useful as it is. + +Let’s enhance it by returning the current user instead. + +## Create a user model + +By creating a `user` model you can use `Pydantic`, msgspec or whatever you want since Esmerald supports the [encoders](../encoders.md) +making it versatile enough for your needs. + +For ths example, let us use the native Pydantic support. + +```python +{!> ../../../docs_src/security/enhance.py !} +``` + +## The `get_current_user` dependency + +Let's create a dependency called `get_current_user`. + +And remember, dependencies can have sub-dependencies, right? + +```python hl_lines="17" +{!> ../../../docs_src/security/enhance.py !} +``` + +The `get_current_user` dependency will depend on the same `oauth2_scheme` we created earlier. + +Just like we did before in the *path operation* itself, our new `get_current_user` dependency will receive a `token` as a `str` from the `oauth2_scheme` sub-dependency. + +!!! Warning + You can see a `Security` object there in the sub-dependency, right? Well, yes, that `Security` object that depends + of the `scheme` can only be called using this object. + + In other words, when a sub-dependency is a `oauth2_scheme` type of thing or any security related, **you must** use the `Security` object. + + This special object once its declared, **Esmerald** will know what to do with it and make sure it can be executed + properly. + + Esmerald dependency system is extremely powerful and extremely versatile and therefore some special objects dedicated + to this security approach were added to make our lives simples. + +## Get the user + +The `get_current_user` dependency will use a (fake) utility function we created. This function takes the token as a `str` and returns our Pydantic `User` model. + +```python hl_lines="13-14" +{!> ../../../docs_src/security/enhance.py !} +``` + +## Inject the current user + +Now, we can use the `Inject` and `Injects` with our `get_current_user` dependency in the *path operation*. This is part +of the special Esmerlad dependency inject system that is also multi layered. You can read again about the +[dependency injection with Esmerald](../dependencies.md). + +```python hl_lines="27" +{!> ../../../docs_src/security/enhance.py !} +``` + +Notice that we declare the type of `current_user` as the Pydantic model `User`. + +This ensures that we get type checking and auto-completion support inside the function, making development smoother and more error-free. + +Now, you can directly access the current user in the *path operation functions* and handle the security mechanisms at the **Dependency Injection** level, using `Depends`. + +You can use any model or data for your security requirements (in this case, a Pydantic model `User`), but you're not limited to a specific data model, class, or type. + +For example: +- Want to use an `id` and `email` instead of a `username` in your model? No problem, just use the same tools. +- Prefer a `str` or a `dict`? Or perhaps a database class model instance directly? It all works seamlessly. +- If you have bots, robots, or other systems logging in instead of users, and they only need an access token, that's fine too. + +You can use any model, class, or database structure that fits your application's needs. **Esmerald**'s dependency injection system makes it easy and flexible for all cases. + +## Code size so far + +This example might seem a bit verbose, but remember, we're combining security, data models, utility functions, and *path operations* in the same file. + +Here’s the key takeaway: + +The security and dependency injection setup is written **once**. + +You can make it as complex as you need, but it only needs to be defined in one place. The beauty of **Esmerald** is its flexibility—whether simple or complex, you only write this logic once. + +And once it's set up, you can reuse it across **thousands of endpoints** (*path operations*). + +All of these endpoints (or any portion of them) can take advantage of the same dependencies or any others you create. + +Even with thousands of *path operations*, many of them can be as simple as just a few lines of code. + +```python hl_lines="27" +{!> ../../../docs_src/security/enhance.py !} +``` + +Remember that Esmerald has a flexible dependency injection system and the lines can be cut by a lot avoiding repetition. + +You can now access the current user directly in your *path operation function*. + +We're already halfway there. + +Next, we just need to add a *path operation* that allows the user/client to send their `username` and `password` to get the token. That will be our next step. diff --git a/docs/en/docs/security/introduction.md b/docs/en/docs/security/introduction.md new file mode 100644 index 00000000..ae877ee5 --- /dev/null +++ b/docs/en/docs/security/introduction.md @@ -0,0 +1,180 @@ +# First Introduction + +Let's imagine that you have your backend API in some domain. + +And you have a frontend in another domain or in a different path of the same domain (or in a mobile application). + +And you want to have a way for the frontend to authenticate with the backend, using a username and password. + +We can use OAuth2 to build that with **Esmerald**. + +But let's save you the time of reading the full long specification just to find those little pieces of information you need. + +Let's use the tools provided by **Esmerald** to handle security. + +## Let us dig in + +We will be doing and explaining at the same time what is what. + +## Create an `app.py` + +You can copy the following code into an `app.py` or any file at your choice. + +```python +{!> ../../../docs_src/security/app.py !} +``` + +## Run it + +You can now run the file using, for example, `uvicorn` and it can be like this: + +```shell +$ uvicorn app:app +``` + +## Verify it + +To check if the endpoint is properly configured and working, you can access the OpenAPI documentation at +[http://127.0.0.1:8000/docs/swagger](http://127.0.0.1:8000/docs/swagger). + +You should be able to see something like this: + + + +!!! Tip + As you can see, you already have a brand new shiny **Authorize** button at the top of the page. + The same is applied to the path operation that contains a lock icon as well. + +If you click the **Authorize** button, you will be able to see the type of login to type a `username`, `password` and +other fields as well. + +Lets check and click it! + + + +!!! Note + Typing anything in the form won't make it work, yet. Step by step we will get there, no worries. + +This isn't the frontend interface intended for end users. Instead, it serves as a powerful, interactive tool for documenting your API. + +It’s useful for the frontend team (which might also be you), for third-party applications and systems, and even for your own use. +You can rely on it to debug, review, and test your application efficiently. + +## The `password` flow + +Now, let’s take a step back and clarify what this all means. + +The `password` "flow" is one of the methods (or "flows") defined in OAuth2 for managing security and authentication. + +OAuth2 was originally designed to separate the backend or API from the server responsible for user authentication. + +However, in this scenario, the same Esmerald application will handle both the API and the authentication process. + +Let’s examine it from this simplified perspective: + +Here’s how the password "flow" works step by step: + +1. **User Login**: The user enters their `username` and `password` in the frontend and submits the form by hitting `Enter`. + +2. **Frontend Request**: The frontend (running in the user’s browser) sends the `username` and `password` to a specific URL on the API, typically defined with `tokenUrl="token"`. + +3. **API Validation**: + - The API verifies the provided `username` and `password`. + - If valid, it responds with a "token." + - A **token** is essentially a string containing information that can later be used to authenticate the user. + - Tokens usually have an expiration time: + - After expiration, the user must log in again. + - This limits the risk if the token is stolen since it won’t work indefinitely (in most cases). + +4. **Token Storage**: The frontend temporarily stores the token securely. + +5. **Navigating the App**: When the user navigates to another section of the web app, the frontend may need to fetch additional data from the API. + +6. **Authenticated API Requests**: + - To access protected endpoints, the frontend includes an `Authorization` header in its request. + - The header’s value is `Bearer ` followed by the token. + - For example, if the token is `foobar`, the `Authorization` header would look like this: + + ```plaintext + Authorization: Bearer foobar + ``` + +## **Esmerald** `OAuth2PasswordBearer` + +**Esmerald** offers various tools, at different levels of abstraction, to implement security features. + +In this example, we’ll use **OAuth2** with the **Password** flow, utilizing a **Bearer** token. To do this, we’ll use the `OAuth2PasswordBearer` class. + +!!! info + + A "bearer" token isn’t the only option for authentication. However, it’s the most suitable for our use case and often the best choice for most scenarios. + + Unless you’re an OAuth2 expert and know of another option that better fits your needs, **Esmerald** gives you the flexibility to implement other options as well. + + When creating an instance of the `OAuth2PasswordBearer` class, we provide the `tokenUrl` parameter. This specifies the URL that the frontend (running in the user's browser) will use to send the `username` and `password` in order to obtain the token. + +When we create an instance of the `OAuth2PasswordBearer` class, we provide the `tokenUrl` parameter. This URL is where the client (the frontend running in the user's browser) will send the `username` and `password` in order to obtain a token. + +```python hl_lines="6" +{!> ../../../docs_src/security/app.py !} +``` + +!!! Tip + Here, `tokenUrl="token"` refers to a relative URL, `token`, which we haven’t created yet. Since it’s a relative URL, it’s equivalent to `./token`. + + This means that if your API is hosted at `https://example.com/`, the full URL would be `https://example.com/token`. If your API is at `https://example.com/api/v1/`, then the full URL would be `https://example.com/api/v1/token`. + + Using a relative URL is important, as it ensures your application continues to function correctly, even in more advanced scenarios, like when running **Behind a Proxy**. + +This parameter doesn’t automatically create the `/token` endpoint or path operation. Instead, it simply declares that the URL `/token` will be the endpoint that the client should use to obtain the token. + +This information is then used in OpenAPI and displayed in the interactive API documentation, guiding the client on where to send the request for the token. + +We will create the actual path operation for this endpoint shortly. + +The `oauth2_scheme` variable is an instance of the `OAuth2PasswordBearer` class, but it is also a "callable" object. + +This means that you can use it as a function, like this: + +```Python +oauth2_scheme(some, parameters) +``` + +When called, it will handle the extraction of the token from the request, typically from the `Authorization` header. + +So, it can be used with `Inject()` and `Injects()`. + +### Use it + +Now you can pass that `oauth2_scheme` in a dependency with `Inject` and `Injects` natively from Esmerald. + +```python hl_lines="9-10" +{!> ../../../docs_src/security/app.py !} +``` + +The `security` in the handler is what allows the OpenAPI specification to understand what needs to go in the **Authorize**. + +This dependency will provide a `str` that gets assigned to the `token` parameter of the *path operation function*. + +**Esmerald** will automatically recognize this dependency and use it to define a "security scheme" in the OpenAPI schema. This also makes the security scheme visible in the automatic API documentation, helping both developers and users understand how authentication works for the API. + +!!! info + **Esmerald** knows it can use the `OAuth2PasswordBearer` class (declared as a dependency) to define the security scheme in OpenAPI because `OAuth2PasswordBearer` inherits from `esmerald.security.oauth2.OAuth2`, which, in turn, inherits from `esmerald.security.base.SecurityBase`. + + All security utilities that integrate with OpenAPI and the automatic API documentation inherit from `SecurityBase`. This inheritance structure allows **Esmerald** to automatically recognize and integrate these security features into the OpenAPI schema, ensuring they are properly displayed in the API docs. + +## What does it do + +**Esmerald** will automatically look for the `Authorization` header in the request, check if it contains a value starting with `Bearer ` followed by a token, and return that token as a `str`. + +If it doesn't find an `Authorization` header or if the value doesn't contain a valid `Bearer` token, **Esmerald** will immediately respond with a `401 Unauthorized` error. + +You don't need to manually check for the token or handle the error yourself, **Esmerald** ensures that if your function is executed, the `token` parameter will always contain a valid `str`. + +You can even test this behavior in the interactive documentation to see how it works in action. + + + +That's correct! At this stage, we're not verifying the validity of the token yet. We're simply extracting it from the `Authorization` header and passing it as a string to the path operation function. + +This is an important first step, as it lays the groundwork for authentication. Later, you can implement the logic to validate the token (e.g., checking its signature, expiration, etc.). But for now, this setup ensures that the token is correctly extracted and available for further use. diff --git a/docs/en/docs/security/simple-oauth2.md b/docs/en/docs/security/simple-oauth2.md new file mode 100644 index 00000000..539ee659 --- /dev/null +++ b/docs/en/docs/security/simple-oauth2.md @@ -0,0 +1,185 @@ +# OAuth2 with Password and Bearer + +Now, let's build upon the [previous chapter](./interaction.md) and add the missing parts to complete the security flow. + +The following examples were inspired by the same examples of FastAPI so it is normal if you feel familiar. The reson for +that its to make sure you don't need to have a new learning curve in terms of understanding and flow. + +## The `username` and `password` + +We’re going to use **Esmerald** security utilities to handle the `username` and `password`. + +According to the OAuth2 specification, when using the "password flow" (which we are using), the client/user must send `username` and `password` fields as form data. + +The specification requires these fields to be named exactly as `username` and `password` so names like `user-name` or `email` won’t work in this case. + +However, don’t worry, you can display these fields however you like in the frontend, and your database models can use different names if needed. + +But for the login *path operation*, we need to follow these names to stay compliant with the specification (and to ensure compatibility with tools like the integrated API documentation). + +Additionally, the spec specifies that the `username` and `password` should be sent as form data, so **no JSON** here. + +### The `scope` + +The specification also allows the client to send another form field, `scope`. + +The field name must be `scope` (in singular), but it is actually a string containing "scopes" separated by spaces. + +Each "scope" is a single string without spaces, and they are typically used to define specific security permissions. For example: + +- `users:read` or `users:write` are common scopes. +- `instagram_basic` is used by Facebook/Instagram. +- `https://www.googleapis.com/auth/drive` is used by Google. + +These scopes help specify the level of access or permissions the user or client is requesting. + +!!! Info + In OAuth2, a "scope" is simply a string that declares a specific permission required. + + It doesn't matter if the string includes other characters like `:` or if it's a URL. + + These details are implementation-specific, but for OAuth2, scopes are just strings. + +## The operation to get the `username` and `password` + +Let us use the Esmerald built-ins to perform this operation. + +### OAuth2PasswordRequestForm + +First, import `OAuth2PasswordRequestForm`, and use it as a dependency with `Security`, `Inject` and `Injects` in the *path operation* for `/token`: + +```python hl_lines="5" +{!> ../../../docs_src/security/post.py !} +``` + +!!! Note + The `Inject` and `Injects()` are what makes Esmerald dependency injection quite unique and layer based. + +The `OAuth2PasswordRequestForm` is a class dependency that defines a form body containing the following fields: + +- The `username`. +- The `password`. +- An optional `scope` field, which is a single string made up of multiple strings separated by spaces. +- An optional `grant_type`. + +!!! Tip + According to the OAuth2 specification, the `grant_type` field is *required* and must have a fixed value of `password`. However, `OAuth2PasswordRequestForm` does not enforce this requirement. + + If you need to strictly enforce the `grant_type` field, you can use `OAuth2PasswordRequestFormStrict` instead of `OAuth2PasswordRequestForm`. + +- An optional `client_id` (not needed for our example). +- An optional `client_secret` (also not needed for our example). + +!!! Info + The `OAuth2PasswordRequestForm` is not a special class in **Esmerald**, unlike `OAuth2PasswordBearer`. + + `OAuth2PasswordBearer` informs **Esmerald** that it represents a security scheme, which is why it gets added as such to the OpenAPI schema. + + In contrast, `OAuth2PasswordRequestForm` is simply a convenience class dependency. You could have written it yourself or declared the `Form` parameters directly. + + Since it's a common use case, **Esmerald** provides this class out of the box to make your work easier. + +## The form data + +!!! Tip + The instance of the `OAuth2PasswordRequestForm` dependency class won’t have a `scope` attribute containing the long string separated by spaces. Instead, it will have a `scopes` attribute, which is a list of individual strings representing each scope sent. + + Although we’re not using `scopes` in this example, the functionality is available if you need it. + +Retrieve the user data from the (fake) database using the `username` from the form field. + +If no user is found, raise an `HTTPException` with the message: **"Incorrect username or password"**. + +```python hl_lines="4 79-81" +{!> ../../../docs_src/security/post.py !} +``` + +### Checking the Password + +Now that we have the user data from our database, we need to verify the password. + +First, we will place the user data into the Pydantic `UserDB` model. + +Since storing plaintext passwords is unsafe, we'll use a (fake) password hashing system for verification. + +If the passwords don’t match, we'll return the same error as before. + +#### What is Password Hashing? + +Hashing transforms a value (like a password) into a seemingly random sequence of bytes (a string) that looks like gibberish. + +- Providing the same input (password) always produces the same hash. +- However, it is a one-way process. You cannot reverse a hash back to the original password. + +##### Why Use Password Hashing? + +If your database is compromised, the attacker won't have access to the user's plaintext passwords—only the hashes. + +This protects users because the attacker cannot reuse their passwords on other systems (a common risk since many people reuse passwords). + +```python hl_lines="82-85" +{!> ../../../docs_src/security/post.py !} +``` + +#### About the `**user_dict` + +`UserDB(**user_dict)` means: + +It takes the keys and values from the `user_dict` and passes them directly as key-value arguments to the `UserDB` constructor. This is equivalent to: + +```python +UserDB( + username=user_dict["username"], + email=user_dict["email"], + full_name=user_dict["full_name"], + disabled=user_dict["disabled"], + hashed_password=user_dict["hashed_password"], +) +``` + +## Returning the Token + +The response from the `token` endpoint should be a JSON object containing: + +- A `token_type`. Since we're using "Bearer" tokens, it should be set to `"bearer"`. +- An `access_token`, which is a string containing the actual token. + +In this simplified example, we'll just return the `username` as the token (though this is insecure). + +!!! Tip + In the next chapter, we'll implement a secure version using password hashing and JSON Web Tokens (JWT). + But for now, let's focus on the key details. + +```python hl_lines="87" +{!> ../../../docs_src/security/post.py !} +``` + +!!! Info + According to the spec, the response should include a JSON with an `access_token` and a `token_type`, as shown in this example. + + This is something you must implement in your code, ensuring the correct use of these JSON keys. + + It's almost the only part you need to manage manually to comply with the specifications. For everything else, **Esmerald** takes care of it for you. + +## Updating the Dependencies + +Now, let's update our dependencies. + +We want to retrieve the `current_user` **only** if the user is active. To do this, we will create a new dependency, `get_current_active_user`, which will rely on `get_current_user` as a sub-dependency. + +Both dependencies will raise an HTTP error if the user doesn't exist or if the user is inactive. + +With this update, the endpoint will only return a user if the user exists, is authenticated correctly, and is active. + +```python hl_lines="54-62 65-70" +{!> ../../../docs_src/security/post.py !} +``` + +!!! Info + The additional `WWW-Authenticate` header with the value `Bearer` is part of the OAuth2 specification. + + Any HTTP error with a status code 401 "UNAUTHORIZED" should include this header. For bearer tokens (like in our case), the header's value should be `Bearer`. + + While you can technically omit this header and it will still function, including it ensures compliance with the specification. Additionally, some tools may expect and use this header, either now or in the future, which could be helpful for you or your users. + + That's the advantage of following standards. diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 6c4ea4b2..60f7fa1e 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -6,243 +6,245 @@ theme: custom_dir: ../en/overrides language: en palette: - - scheme: default - primary: green - accent: orange - media: '(prefers-color-scheme: light)' - toggle: - icon: material/lightbulb - name: Switch to dark mode - - scheme: slate - media: '(prefers-color-scheme: dark)' - primary: green - accent: orange - toggle: - icon: material/lightbulb-outline - name: Switch to light mode + - scheme: default + primary: green + accent: orange + media: "(prefers-color-scheme: light)" + toggle: + icon: material/lightbulb + name: Switch to dark mode + - scheme: slate + media: "(prefers-color-scheme: dark)" + primary: green + accent: orange + toggle: + icon: material/lightbulb-outline + name: Switch to light mode favicon: statics/images/favicon.ico logo: statics/images/logo-white.svg features: - - search.suggest - - search.highlight - - content.tabs.link - - content.code.copy - - content.code.annotate - - content.tooltips - - content.code.select - - navigation.indexes - - navigation.path - - navigation.tabs + - search.suggest + - search.highlight + - content.tabs.link + - content.code.copy + - content.code.annotate + - content.tooltips + - content.code.select + - navigation.indexes + - navigation.path + - navigation.tabs repo_name: dymmond/esmerald repo_url: https://github.com/dymmond/esmerald -edit_uri: '' +edit_uri: "" plugins: -- search -- meta-descriptions: - export_csv: false - quiet: false - enable_checks: false - min_length: 50 - max_length: 160 - trim: false -- mkdocstrings: - handlers: - python: - options: - extensions: - - griffe_typingdoc - show_root_heading: true - show_if_no_docstring: true - preload_modules: - - httpx - - lilya - - a2wsgi - inherited_members: true - members_order: source - separate_signature: true - unwrap_annotated: true - filters: - - '!^_' - merge_init_into_class: true - docstring_section_style: spacy - signature_crossrefs: true - show_symbol_type_heading: true - show_symbol_type_toc: true + - search + - meta-descriptions: + export_csv: false + quiet: false + enable_checks: false + min_length: 50 + max_length: 160 + trim: false + - mkdocstrings: + handlers: + python: + options: + extensions: + - griffe_typingdoc + show_root_heading: true + show_if_no_docstring: true + preload_modules: + - httpx + - lilya + - a2wsgi + inherited_members: true + members_order: source + separate_signature: true + unwrap_annotated: true + filters: + - "!^_" + merge_init_into_class: true + docstring_section_style: spacy + signature_crossrefs: true + show_symbol_type_heading: true + show_symbol_type_toc: true markdown_extensions: -- attr_list -- toc: - permalink: true -- mdx_include: - base_path: docs -- admonition -- extra -- pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format '' -- pymdownx.tabbed: - alternate_style: true -- md_in_html + - attr_list + - toc: + permalink: true + - mdx_include: + base_path: docs + - admonition + - extra + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format "" + - pymdownx.tabbed: + alternate_style: true + - md_in_html nav: -- index.md -- Application: - - application/index.md - - Esmerald: application/applications.md - - application/levels.md - - application/settings.md - - Configurations: - - configurations/index.md - - configurations/cors.md - - configurations/csrf.md - - configurations/session.md - - configurations/staticfiles.md - - configurations/template.md - - configurations/jwt.md - - configurations/scheduler.md - - configurations/openapi/config.md -- Features: - - features/index.md - - Routing: - - routing/index.md - - routing/router.md - - routing/routes.md - - routing/handlers.md - - routing/apiview.md - - routing/webhooks.md - - interceptors.md - - permissions.md - - middleware/middleware.md - - dependencies.md - - exceptions.md - - exception-handlers.md - - extensions.md - - password-hashers.md - - requests.md - - context.md - - responses.md - - encoders.md - - msgspec.md - - background-tasks.md - - lifespan-events.md - - protocols.md - - Security: - - security/index.md - - security/first-steps.md - - Advanced & Useful: - - extras/index.md - - extras/path-params.md - - extras/query-params.md - - extras/request-data.md - - extras/upload-files.md - - extras/forms.md - - extras/body-fields.md - - extras/header-fields.md - - extras/cookie-fields.md - - Scheduler: - - scheduler/index.md - - Asyncz: - - scheduler/scheduler.md - - scheduler/handler.md - - Management & Directives: - - directives/index.md - - directives/discovery.md - - directives/directives.md - - directives/custom-directives.md - - directives/shell.md -- Database Integrations: - - databases/index.md - - Saffier: - - databases/saffier/motivation.md - - databases/saffier/models.md - - Middleware: - - databases/saffier/middleware.md - - databases/saffier/example.md - - Edgy: - - databases/edgy/motivation.md - - databases/edgy/models.md - - Middleware: - - databases/edgy/middleware.md - - databases/edgy/example.md - - Mongoz: - - databases/mongoz/motivation.md - - databases/mongoz/documents.md - - Middleware: - - databases/mongoz/middleware.md - - databases/mongoz/example.md -- openapi.md -- Extras: - - wsgi.md - - testclient.md - - Deployment: - - deployment/index.md - - Intro: deployment/intro.md - - Using docker: deployment/docker.md - - external.md -- API Reference: - - references/index.md - - references/esmerald.md - - references/application/settings.md - - references/configurations/cors.md - - references/configurations/csrf.md - - references/configurations/session.md - - references/configurations/static_files.md - - references/configurations/template.md - - references/configurations/jwt.md - - references/configurations/openapi.md - - references/background.md - - references/routing/router.md - - references/routing/gateway.md - - references/routing/websocketgateway.md - - references/routing/webhookgateway.md - - references/routing/include.md - - references/routing/view.md - - references/routing/handlers.md - - references/interceptors.md - - references/permissions.md - - references/middleware/baseauth.md - - references/middleware/middlewares.md - - references/extensions.md - - references/pluggables.md - - references/exceptions.md - - references/request.md - - references/context.md - - references/responses/response.md - - references/responses/json-response.md - - references/responses/template-response.md - - references/responses/orjson-response.md - - references/responses/ujson-response.md - - references/responses/json.md - - references/responses/file.md - - references/responses/redirect.md - - references/responses/stream.md - - references/responses/template.md - - references/responses/orjson.md - - references/responses/ujson.md - - references/responses/openapi-response.md - - references/websockets.md - - references/injector.md - - references/uploadfile.md - - references/status-codes.md - - references/test-client.md -- About: - - about.md - - sponsorship.md - - esmerald-people.md - - examples.md - - contributing.md -- release-notes.md + - index.md + - Application: + - application/index.md + - Esmerald: application/applications.md + - application/levels.md + - application/settings.md + - Configurations: + - configurations/index.md + - configurations/cors.md + - configurations/csrf.md + - configurations/session.md + - configurations/staticfiles.md + - configurations/template.md + - configurations/jwt.md + - configurations/scheduler.md + - configurations/openapi/config.md + - Features: + - features/index.md + - Routing: + - routing/index.md + - routing/router.md + - routing/routes.md + - routing/handlers.md + - routing/apiview.md + - routing/webhooks.md + - interceptors.md + - permissions.md + - middleware/middleware.md + - dependencies.md + - exceptions.md + - exception-handlers.md + - extensions.md + - password-hashers.md + - requests.md + - context.md + - responses.md + - encoders.md + - msgspec.md + - background-tasks.md + - lifespan-events.md + - protocols.md + - Security: + - security/index.md + - security/introduction.md + - security/interaction.md + - security/simple-oauth2.md + - Advanced & Useful: + - extras/index.md + - extras/path-params.md + - extras/query-params.md + - extras/request-data.md + - extras/upload-files.md + - extras/forms.md + - extras/body-fields.md + - extras/header-fields.md + - extras/cookie-fields.md + - Scheduler: + - scheduler/index.md + - Asyncz: + - scheduler/scheduler.md + - scheduler/handler.md + - Management & Directives: + - directives/index.md + - directives/discovery.md + - directives/directives.md + - directives/custom-directives.md + - directives/shell.md + - Database Integrations: + - databases/index.md + - Saffier: + - databases/saffier/motivation.md + - databases/saffier/models.md + - Middleware: + - databases/saffier/middleware.md + - databases/saffier/example.md + - Edgy: + - databases/edgy/motivation.md + - databases/edgy/models.md + - Middleware: + - databases/edgy/middleware.md + - databases/edgy/example.md + - Mongoz: + - databases/mongoz/motivation.md + - databases/mongoz/documents.md + - Middleware: + - databases/mongoz/middleware.md + - databases/mongoz/example.md + - openapi.md + - Extras: + - wsgi.md + - testclient.md + - Deployment: + - deployment/index.md + - Intro: deployment/intro.md + - Using docker: deployment/docker.md + - external.md + - API Reference: + - references/index.md + - references/esmerald.md + - references/application/settings.md + - references/configurations/cors.md + - references/configurations/csrf.md + - references/configurations/session.md + - references/configurations/static_files.md + - references/configurations/template.md + - references/configurations/jwt.md + - references/configurations/openapi.md + - references/background.md + - references/routing/router.md + - references/routing/gateway.md + - references/routing/websocketgateway.md + - references/routing/webhookgateway.md + - references/routing/include.md + - references/routing/view.md + - references/routing/handlers.md + - references/interceptors.md + - references/permissions.md + - references/middleware/baseauth.md + - references/middleware/middlewares.md + - references/extensions.md + - references/pluggables.md + - references/exceptions.md + - references/request.md + - references/context.md + - references/responses/response.md + - references/responses/json-response.md + - references/responses/template-response.md + - references/responses/orjson-response.md + - references/responses/ujson-response.md + - references/responses/json.md + - references/responses/file.md + - references/responses/redirect.md + - references/responses/stream.md + - references/responses/template.md + - references/responses/orjson.md + - references/responses/ujson.md + - references/responses/openapi-response.md + - references/websockets.md + - references/injector.md + - references/uploadfile.md + - references/status-codes.md + - references/test-client.md + - About: + - about.md + - sponsorship.md + - esmerald-people.md + - examples.md + - contributing.md + - release-notes.md extra_css: -- statics/css/extra.css -- statics/css/custom.css + - statics/css/extra.css + - statics/css/custom.css extra: analytics: provider: google property: G-CNBVBB90NT alternate: - - link: / - name: en - English - - link: /ru/ - name: ru - русский язык + - link: / + name: en - English + - link: /ru/ + name: ru - русский язык hooks: -- ../../scripts/hooks.py + - ../../scripts/hooks.py diff --git a/docs_src/security/app.py b/docs_src/security/app.py new file mode 100644 index 00000000..ed3c1628 --- /dev/null +++ b/docs_src/security/app.py @@ -0,0 +1,18 @@ +from typing import Any, Dict + +from esmerald import Inject, Injects, Esmerald, get, Gateway +from esmerald.security.oauth2 import OAuth2PasswordBearer + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +@get("/items", dependencies={"token": Inject(oauth2_scheme)}, security=[oauth2_scheme]) +async def get_items(token: str = Injects()) -> Dict[str, Any]: + return {"token": token} + + +app = Esmerald( + routes=[ + Gateway(handler=get_items), + ] +) diff --git a/docs_src/security/enhance.py b/docs_src/security/enhance.py new file mode 100644 index 00000000..7ae5f97c --- /dev/null +++ b/docs_src/security/enhance.py @@ -0,0 +1,28 @@ +from esmerald import Inject, Injects, get, Security +from esmerald.security.oauth2 import OAuth2PasswordBearer +from pydantic import BaseModel + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +class User(BaseModel): + username: str + email: str | None = None + + +def fake_decode_token(token): + return User(username=token + "fakedecoded", email="john@example.com") + + +async def get_current_user(token: str = Security(oauth2_scheme)): + user = fake_decode_token(token) + return user + + +@get( + "/users/me", + dependencies={"current_user": Inject(get_current_user)}, + security=[oauth2_scheme], +) +async def users_me(current_user: User = Injects()) -> User: + return current_user diff --git a/docs_src/security/post.py b/docs_src/security/post.py new file mode 100644 index 00000000..d6eb1c4f --- /dev/null +++ b/docs_src/security/post.py @@ -0,0 +1,103 @@ +from typing import Dict +from pydantic import BaseModel + +from esmerald import Esmerald, Gateway, HTTPException, Inject, Injects, Security, get, post, status +from esmerald.security.oauth2 import OAuth2PasswordBearer, OAuth2PasswordRequestForm + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +fake_users_db = { + "janedoe": { + "username": "janedoe", + "full_name": "Jane Doe", + "email": "janedoe@example.com", + "hashed_password": "fakehashedsecret", + "disabled": False, + }, + "peter": { + "username": "peter", + "full_name": "Peter Parker", + "email": "pparker@example.com", + "hashed_password": "fakehashedsecret2", + "disabled": True, + }, +} + + +def fake_hash_password(password: str): + return "fakehashed" + password + + +class User(BaseModel): + username: str + email: str | None = None + full_name: str | None = None + disabled: bool | None = None + + +class UserDB(User): + hashed_password: str + + +def get_user(db, username: str): + if username in db: + user_dict = db[username] + return UserDB(**user_dict) + + +def fake_decode_token(token: str): + user = get_user(fake_users_db, token) + return user + + +async def get_current_user(token: str = Security(oauth2_scheme)): + user = fake_decode_token(token) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +async def get_current_active_user( + user: User = Inject(get_current_user), +): + if user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +@post( + "/token", + dependencies={"form_data": Inject(OAuth2PasswordRequestForm)}, + security=[oauth2_scheme], +) +async def login(form_data: OAuth2PasswordRequestForm = Injects()) -> Dict[str, str]: + user_dict = fake_users_db.get(form_data.username) + if not user_dict: + raise HTTPException(status_code=400, detail="Incorrect username or password") + user = UserDB(**user_dict) + hashed_password = fake_hash_password(form_data.password) + if not hashed_password == user.hashed_password: + raise HTTPException(status_code=400, detail="Incorrect username or password") + + return {"access_token": user.username, "token_type": "bearer"} + + +@get("/users/me", dependencies={"current_user": Inject(get_current_active_user)}) +async def read_users_me( + current_user: User = Injects(), +) -> User: + return current_user + + +app = Esmerald( + routes=[ + Gateway(handler=login), + Gateway(handler=read_users_me), + ], + debug=True, +) diff --git a/esmerald/injector.py b/esmerald/injector.py index f0dd12db..0ca379a9 100644 --- a/esmerald/injector.py +++ b/esmerald/injector.py @@ -81,3 +81,13 @@ def __eq__(self, other: Any) -> bool: and other.use_cache == self.use_cache and other.value == self.value ) + + def __hash__(self) -> int: + values: Dict[str, Any] = {} + for key, value in self.__dict__.items(): + values[key] = None + if isinstance(value, (list, set)): + values[key] = tuple(value) + else: + values[key] = value + return hash((type(self),) + tuple(values)) diff --git a/esmerald/params.py b/esmerald/params.py index 39169a1e..65b27ab6 100644 --- a/esmerald/params.py +++ b/esmerald/params.py @@ -426,6 +426,7 @@ def __init__( init_var: bool = True, kw_only: bool = True, include_in_schema: bool = True, + json_schema_extra: Optional[Dict[str, Any]] = None, ) -> None: extra: Dict[str, Any] = {} self.media_type = media_type @@ -439,6 +440,8 @@ def __init__( extra.update(embed=embed) extra.update(allow_none=allow_none) + json_schema_extra = extra if json_schema_extra is None else json_schema_extra.update(extra) + super().__init__( default=default, default_factory=default_factory, @@ -457,7 +460,7 @@ def __init__( min_length=min_length, max_length=max_length, pattern=pattern, - json_schema_extra=extra, + json_schema_extra=json_schema_extra, validate_default=validate_default, validation_alias=validation_alias, discriminator=discriminator, @@ -681,6 +684,7 @@ class Security(Requires): __init__(self, dependency: Optional[Callable[..., Any]] = None, *, scopes: Optional[Sequence[str]] = None, use_cache: bool = True) Initializes the Security class with the given dependency, scopes, and use_cache flag. """ + def __init__( self, dependency: Optional[Callable[..., Any]] = None, diff --git a/esmerald/security/http/http.py b/esmerald/security/http/http.py index 9cd9f18a..3faf51bc 100644 --- a/esmerald/security/http/http.py +++ b/esmerald/security/http/http.py @@ -88,20 +88,21 @@ class HTTPBasic(HTTPBase): Use this class as a dependency to enforce HTTP Basic authentication. - Example: - ```python - from typing import Any + ## Example: - from esmerald import Gateway, Inject, Injects, get - from esmerald.security.http import HTTPBasic, HTTPBasicCredentials - from esmerald.testclient import create_client + ```python + from typing import Any - security = HTTPBasic() + from esmerald import Gateway, Inject, Injects, get + from esmerald.security.http import HTTPBasic, HTTPBasicCredentials + from esmerald.testclient import create_client - @app.get("/users/me", security=[security], dependencies={"credentials": Inject(security)})) - def read_current_user(credentials: HTTPBasicCredentials = Injects()): - return {"username": credentials.username, "password": credentials.password} - ``` + security = HTTPBasic() + + @app.get("/users/me", security=[security], dependencies={"credentials": Inject(security)})) + def get_current_user(credentials: HTTPBasicCredentials = Injects()): + return {"username": credentials.username, "password": credentials.password} + ``` """ def __init__( @@ -175,7 +176,7 @@ class HTTPBearer(HTTPBase): security = HTTPBearer() @app.get("/users/me") - def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any:: + def get_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any:: return {"scheme": credentials.scheme, "credentials": credentials.credentials} ``` """ @@ -237,7 +238,7 @@ class HTTPDigest(HTTPBase): security = HTTPDigest() @get("/users/me", security=[security], dependencies={"credentials": Inject(security)}) - def read_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any: + def get_current_user(credentials: HTTPAuthorizationCredentials = Injects()) -> Any: return {"scheme": credentials.scheme, "credentials": credentials.credentials} ``` """ diff --git a/esmerald/transformers/model.py b/esmerald/transformers/model.py index ea95fc81..6136b664 100644 --- a/esmerald/transformers/model.py +++ b/esmerald/transformers/model.py @@ -254,7 +254,7 @@ def to_dict(self) -> Dict[str, Any]: """ return { "cookies": [param.dict() for param in self.cookies], - "dependencies": [dep.dict() for dep in self.dependencies], + "dependencies": [dep.model_dump() for dep in self.dependencies], "form_data": self.form_data, "headers": [param.dict() for param in self.headers], "path_params": [param.dict() for param in self.path_params], diff --git a/esmerald/utils/dependencies.py b/esmerald/utils/dependencies.py index 4086ef53..3c46d908 100644 --- a/esmerald/utils/dependencies.py +++ b/esmerald/utils/dependencies.py @@ -18,3 +18,12 @@ def is_security_scheme(param: Any) -> bool: if not param: return False return isinstance(param, params.Security) + + +def is_inject(param: Any) -> bool: + """ + Checks if the object is an Inject. + """ + from esmerald.injector import Inject + + return isinstance(param, Inject) From 315103cf544ca5198547e4319624c4e5fcf6e1c4 Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 9 Dec 2024 14:19:03 +0100 Subject: [PATCH 20/24] Update lilya --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a88363a6..c6d2d3cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "email-validator >=2.2.0,<3.0.0", "itsdangerous>=2.1.2,<3.0.0", "jinja2>=3.1.2,<4.0.0", - "lilya>=0.11.2", + "lilya>=0.11.6", "loguru>=0.7.0,<0.8.0", "pydantic>=2.9.1,<3.0.0", "pydantic-settings>=2.0.0,<3.0.0", From 8d74a71261e366307d9c713ac84c76306c0749de Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 9 Dec 2024 14:24:08 +0100 Subject: [PATCH 21/24] Fix pydantic versions --- tests/routing/test_syntax_enum.py | 4 +--- tests/security/oauth/test_security_oauth2_optional_desc.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/routing/test_syntax_enum.py b/tests/routing/test_syntax_enum.py index 76d5a984..20edf470 100644 --- a/tests/routing/test_syntax_enum.py +++ b/tests/routing/test_syntax_enum.py @@ -5,7 +5,7 @@ from esmerald import Gateway, JSONResponse, get from esmerald.testclient import create_client -pydantic_version = ".".join(__version__.split(".")[:2]) +pydantic_version = __version__ class ItemType(str, Enum): @@ -20,14 +20,12 @@ async def item(item_type: ItemType) -> JSONResponse: def test_syntax(): with create_client(routes=[Gateway(handler=item)]) as client: - response = client.get("/item/sold") assert response.json() == {"item_type": "sold"} def test_syntax_fail(): with create_client(routes=[Gateway(handler=item)]) as client: - response = client.get("/item/test") assert response.status_code == 400 diff --git a/tests/security/oauth/test_security_oauth2_optional_desc.py b/tests/security/oauth/test_security_oauth2_optional_desc.py index c28d7724..1ba97cec 100644 --- a/tests/security/oauth/test_security_oauth2_optional_desc.py +++ b/tests/security/oauth/test_security_oauth2_optional_desc.py @@ -6,7 +6,7 @@ from esmerald.security.oauth2 import OAuth2, OAuth2PasswordRequestFormStrict from esmerald.testclient import create_client -pydantic_version = __version__[:3] +pydantic_version = __version__ reusable_oauth2 = OAuth2( flows={ From e29d9f41bf5693c2f82df5c96f6d768b4787fc91 Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 9 Dec 2024 14:34:40 +0100 Subject: [PATCH 22/24] Fix pydantic versions --- pyproject.toml | 4 ++-- tests/routing/test_syntax_enum.py | 2 +- tests/security/oauth/test_security_oauth2_optional_desc.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6d2d3cb..5bbab13a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "jinja2>=3.1.2,<4.0.0", "lilya>=0.11.6", "loguru>=0.7.0,<0.8.0", - "pydantic>=2.9.1,<3.0.0", + "pydantic>=2.10,<3.0.0", "pydantic-settings>=2.0.0,<3.0.0", "python-multipart>=0.0.7", "python-slugify>=8.0.4,<10.0.0", @@ -103,7 +103,7 @@ testing = [ "ujson>=5.7.0,<6", "anyio[trio]>=3.6.2,<5.0.0", "brotli>=1.0.9,<2.0.0", - "edgy[postgres]>=0.21.0", + "edgy[postgres]>=0.23.3", "databasez>=0.9.7", "flask>=1.1.2,<4.0.0", "freezegun>=1.2.2,<2.0.0", diff --git a/tests/routing/test_syntax_enum.py b/tests/routing/test_syntax_enum.py index 20edf470..a20c8c8c 100644 --- a/tests/routing/test_syntax_enum.py +++ b/tests/routing/test_syntax_enum.py @@ -5,7 +5,7 @@ from esmerald import Gateway, JSONResponse, get from esmerald.testclient import create_client -pydantic_version = __version__ +pydantic_version = ".".join(__version__.split(".")[:2]) class ItemType(str, Enum): diff --git a/tests/security/oauth/test_security_oauth2_optional_desc.py b/tests/security/oauth/test_security_oauth2_optional_desc.py index 1ba97cec..901714e4 100644 --- a/tests/security/oauth/test_security_oauth2_optional_desc.py +++ b/tests/security/oauth/test_security_oauth2_optional_desc.py @@ -6,7 +6,7 @@ from esmerald.security.oauth2 import OAuth2, OAuth2PasswordRequestFormStrict from esmerald.testclient import create_client -pydantic_version = __version__ +pydantic_version = ".".join(__version__.split(".")[:2]) reusable_oauth2 = OAuth2( flows={ From 735c0929ac78ccc73e9d89e983698360cf5f3bca Mon Sep 17 00:00:00 2001 From: tarsil Date: Mon, 9 Dec 2024 16:54:00 +0100 Subject: [PATCH 23/24] Add docs for simple fake auth --- docs/en/docs/security/simple-oauth2.md | 59 ++- docs/en/mkdocs.yml | 454 +++++++++--------- docs_src/security/post.py | 13 +- esmerald/transformers/model.py | 12 +- .../test_security_http_bearer_optional.py | 2 +- 5 files changed, 299 insertions(+), 241 deletions(-) diff --git a/docs/en/docs/security/simple-oauth2.md b/docs/en/docs/security/simple-oauth2.md index 539ee659..b53bd30c 100644 --- a/docs/en/docs/security/simple-oauth2.md +++ b/docs/en/docs/security/simple-oauth2.md @@ -171,7 +171,7 @@ Both dependencies will raise an HTTP error if the user doesn't exist or if the u With this update, the endpoint will only return a user if the user exists, is authenticated correctly, and is active. -```python hl_lines="54-62 65-70" +```python hl_lines="54-65" {!> ../../../docs_src/security/post.py !} ``` @@ -183,3 +183,60 @@ With this update, the endpoint will only return a user if the user exists, is au While you can technically omit this header and it will still function, including it ensures compliance with the specification. Additionally, some tools may expect and use this header, either now or in the future, which could be helpful for you or your users. That's the advantage of following standards. + +## Go ahead and test it + +Open the OpenAPI documentation and check it out: [http://localhost:8000/docs/swagger](http://localhost:8000/docs/swagger). + +### Authenticate + +Click the **Authorize** button and use the following credentials: + +* **User**: `janedoe` +* **Password**: `secret`. + +Authorize + +After pressing the authenticate, you should be able to see something like this: + +Done + +### Get the data + +Now it is time to test and get the data using the `GET` method provided in the examples `/users/me`. + +You will get a payload similar to this: + +```json +{ + "username": "johndoe", + "email": "johndoe@example.com", + "full_name": "John Doe", + "disabled": false, + "hashed_password": "fakehashedsecret" +} +``` + +Payload + +Now, if you logout by clicking in the logout icon, you should receive a 401. + +Not authenticated + + +## Inactive users + +Now you can try with an inactive user and see what happens. + +* **User**: `peter` +* **Password**: `secret2`. + +You should have an error like this: + +```json +{ + "detail": "Inactive user" +} +``` + +As you can see, we have now implemented a simple and yet effective authentication. diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 60f7fa1e..6aa732d8 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -6,245 +6,245 @@ theme: custom_dir: ../en/overrides language: en palette: - - scheme: default - primary: green - accent: orange - media: "(prefers-color-scheme: light)" - toggle: - icon: material/lightbulb - name: Switch to dark mode - - scheme: slate - media: "(prefers-color-scheme: dark)" - primary: green - accent: orange - toggle: - icon: material/lightbulb-outline - name: Switch to light mode + - scheme: default + primary: green + accent: orange + media: '(prefers-color-scheme: light)' + toggle: + icon: material/lightbulb + name: Switch to dark mode + - scheme: slate + media: '(prefers-color-scheme: dark)' + primary: green + accent: orange + toggle: + icon: material/lightbulb-outline + name: Switch to light mode favicon: statics/images/favicon.ico logo: statics/images/logo-white.svg features: - - search.suggest - - search.highlight - - content.tabs.link - - content.code.copy - - content.code.annotate - - content.tooltips - - content.code.select - - navigation.indexes - - navigation.path - - navigation.tabs + - search.suggest + - search.highlight + - content.tabs.link + - content.code.copy + - content.code.annotate + - content.tooltips + - content.code.select + - navigation.indexes + - navigation.path + - navigation.tabs repo_name: dymmond/esmerald repo_url: https://github.com/dymmond/esmerald -edit_uri: "" +edit_uri: '' plugins: - - search - - meta-descriptions: - export_csv: false - quiet: false - enable_checks: false - min_length: 50 - max_length: 160 - trim: false - - mkdocstrings: - handlers: - python: - options: - extensions: - - griffe_typingdoc - show_root_heading: true - show_if_no_docstring: true - preload_modules: - - httpx - - lilya - - a2wsgi - inherited_members: true - members_order: source - separate_signature: true - unwrap_annotated: true - filters: - - "!^_" - merge_init_into_class: true - docstring_section_style: spacy - signature_crossrefs: true - show_symbol_type_heading: true - show_symbol_type_toc: true +- search +- meta-descriptions: + export_csv: false + quiet: false + enable_checks: false + min_length: 50 + max_length: 160 + trim: false +- mkdocstrings: + handlers: + python: + options: + extensions: + - griffe_typingdoc + show_root_heading: true + show_if_no_docstring: true + preload_modules: + - httpx + - lilya + - a2wsgi + inherited_members: true + members_order: source + separate_signature: true + unwrap_annotated: true + filters: + - '!^_' + merge_init_into_class: true + docstring_section_style: spacy + signature_crossrefs: true + show_symbol_type_heading: true + show_symbol_type_toc: true markdown_extensions: - - attr_list - - toc: - permalink: true - - mdx_include: - base_path: docs - - admonition - - extra - - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format "" - - pymdownx.tabbed: - alternate_style: true - - md_in_html +- attr_list +- toc: + permalink: true +- mdx_include: + base_path: docs +- admonition +- extra +- pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format '' +- pymdownx.tabbed: + alternate_style: true +- md_in_html nav: - - index.md - - Application: - - application/index.md - - Esmerald: application/applications.md - - application/levels.md - - application/settings.md - - Configurations: - - configurations/index.md - - configurations/cors.md - - configurations/csrf.md - - configurations/session.md - - configurations/staticfiles.md - - configurations/template.md - - configurations/jwt.md - - configurations/scheduler.md - - configurations/openapi/config.md - - Features: - - features/index.md - - Routing: - - routing/index.md - - routing/router.md - - routing/routes.md - - routing/handlers.md - - routing/apiview.md - - routing/webhooks.md - - interceptors.md - - permissions.md - - middleware/middleware.md - - dependencies.md - - exceptions.md - - exception-handlers.md - - extensions.md - - password-hashers.md - - requests.md - - context.md - - responses.md - - encoders.md - - msgspec.md - - background-tasks.md - - lifespan-events.md - - protocols.md - - Security: - - security/index.md - - security/introduction.md - - security/interaction.md - - security/simple-oauth2.md - - Advanced & Useful: - - extras/index.md - - extras/path-params.md - - extras/query-params.md - - extras/request-data.md - - extras/upload-files.md - - extras/forms.md - - extras/body-fields.md - - extras/header-fields.md - - extras/cookie-fields.md - - Scheduler: - - scheduler/index.md - - Asyncz: - - scheduler/scheduler.md - - scheduler/handler.md - - Management & Directives: - - directives/index.md - - directives/discovery.md - - directives/directives.md - - directives/custom-directives.md - - directives/shell.md - - Database Integrations: - - databases/index.md - - Saffier: - - databases/saffier/motivation.md - - databases/saffier/models.md - - Middleware: - - databases/saffier/middleware.md - - databases/saffier/example.md - - Edgy: - - databases/edgy/motivation.md - - databases/edgy/models.md - - Middleware: - - databases/edgy/middleware.md - - databases/edgy/example.md - - Mongoz: - - databases/mongoz/motivation.md - - databases/mongoz/documents.md - - Middleware: - - databases/mongoz/middleware.md - - databases/mongoz/example.md - - openapi.md - - Extras: - - wsgi.md - - testclient.md - - Deployment: - - deployment/index.md - - Intro: deployment/intro.md - - Using docker: deployment/docker.md - - external.md - - API Reference: - - references/index.md - - references/esmerald.md - - references/application/settings.md - - references/configurations/cors.md - - references/configurations/csrf.md - - references/configurations/session.md - - references/configurations/static_files.md - - references/configurations/template.md - - references/configurations/jwt.md - - references/configurations/openapi.md - - references/background.md - - references/routing/router.md - - references/routing/gateway.md - - references/routing/websocketgateway.md - - references/routing/webhookgateway.md - - references/routing/include.md - - references/routing/view.md - - references/routing/handlers.md - - references/interceptors.md - - references/permissions.md - - references/middleware/baseauth.md - - references/middleware/middlewares.md - - references/extensions.md - - references/pluggables.md - - references/exceptions.md - - references/request.md - - references/context.md - - references/responses/response.md - - references/responses/json-response.md - - references/responses/template-response.md - - references/responses/orjson-response.md - - references/responses/ujson-response.md - - references/responses/json.md - - references/responses/file.md - - references/responses/redirect.md - - references/responses/stream.md - - references/responses/template.md - - references/responses/orjson.md - - references/responses/ujson.md - - references/responses/openapi-response.md - - references/websockets.md - - references/injector.md - - references/uploadfile.md - - references/status-codes.md - - references/test-client.md - - About: - - about.md - - sponsorship.md - - esmerald-people.md - - examples.md - - contributing.md - - release-notes.md +- index.md +- Application: + - application/index.md + - Esmerald: application/applications.md + - application/levels.md + - application/settings.md + - Configurations: + - configurations/index.md + - configurations/cors.md + - configurations/csrf.md + - configurations/session.md + - configurations/staticfiles.md + - configurations/template.md + - configurations/jwt.md + - configurations/scheduler.md + - configurations/openapi/config.md +- Features: + - features/index.md + - Routing: + - routing/index.md + - routing/router.md + - routing/routes.md + - routing/handlers.md + - routing/apiview.md + - routing/webhooks.md + - interceptors.md + - permissions.md + - middleware/middleware.md + - dependencies.md + - exceptions.md + - exception-handlers.md + - extensions.md + - password-hashers.md + - requests.md + - context.md + - responses.md + - encoders.md + - msgspec.md + - background-tasks.md + - lifespan-events.md + - protocols.md + - Security: + - security/index.md + - security/introduction.md + - security/interaction.md + - security/simple-oauth2.md + - Advanced & Useful: + - extras/index.md + - extras/path-params.md + - extras/query-params.md + - extras/request-data.md + - extras/upload-files.md + - extras/forms.md + - extras/body-fields.md + - extras/header-fields.md + - extras/cookie-fields.md + - Scheduler: + - scheduler/index.md + - Asyncz: + - scheduler/scheduler.md + - scheduler/handler.md + - Management & Directives: + - directives/index.md + - directives/discovery.md + - directives/directives.md + - directives/custom-directives.md + - directives/shell.md +- Database Integrations: + - databases/index.md + - Saffier: + - databases/saffier/motivation.md + - databases/saffier/models.md + - Middleware: + - databases/saffier/middleware.md + - databases/saffier/example.md + - Edgy: + - databases/edgy/motivation.md + - databases/edgy/models.md + - Middleware: + - databases/edgy/middleware.md + - databases/edgy/example.md + - Mongoz: + - databases/mongoz/motivation.md + - databases/mongoz/documents.md + - Middleware: + - databases/mongoz/middleware.md + - databases/mongoz/example.md +- openapi.md +- Extras: + - wsgi.md + - testclient.md + - Deployment: + - deployment/index.md + - Intro: deployment/intro.md + - Using docker: deployment/docker.md + - external.md +- API Reference: + - references/index.md + - references/esmerald.md + - references/application/settings.md + - references/configurations/cors.md + - references/configurations/csrf.md + - references/configurations/session.md + - references/configurations/static_files.md + - references/configurations/template.md + - references/configurations/jwt.md + - references/configurations/openapi.md + - references/background.md + - references/routing/router.md + - references/routing/gateway.md + - references/routing/websocketgateway.md + - references/routing/webhookgateway.md + - references/routing/include.md + - references/routing/view.md + - references/routing/handlers.md + - references/interceptors.md + - references/permissions.md + - references/middleware/baseauth.md + - references/middleware/middlewares.md + - references/extensions.md + - references/pluggables.md + - references/exceptions.md + - references/request.md + - references/context.md + - references/responses/response.md + - references/responses/json-response.md + - references/responses/template-response.md + - references/responses/orjson-response.md + - references/responses/ujson-response.md + - references/responses/json.md + - references/responses/file.md + - references/responses/redirect.md + - references/responses/stream.md + - references/responses/template.md + - references/responses/orjson.md + - references/responses/ujson.md + - references/responses/openapi-response.md + - references/websockets.md + - references/injector.md + - references/uploadfile.md + - references/status-codes.md + - references/test-client.md +- About: + - about.md + - sponsorship.md + - esmerald-people.md + - examples.md + - contributing.md +- release-notes.md extra_css: - - statics/css/extra.css - - statics/css/custom.css +- statics/css/extra.css +- statics/css/custom.css extra: analytics: provider: google property: G-CNBVBB90NT alternate: - - link: / - name: en - English - - link: /ru/ - name: ru - русский язык + - link: / + name: en - English + - link: /ru/ + name: ru - русский язык hooks: - - ../../scripts/hooks.py +- ../../scripts/hooks.py diff --git a/docs_src/security/post.py b/docs_src/security/post.py index d6eb1c4f..ea54d930 100644 --- a/docs_src/security/post.py +++ b/docs_src/security/post.py @@ -53,6 +53,9 @@ def fake_decode_token(token: str): async def get_current_user(token: str = Security(oauth2_scheme)): user = fake_decode_token(token) + if user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -62,14 +65,6 @@ async def get_current_user(token: str = Security(oauth2_scheme)): return user -async def get_current_active_user( - user: User = Inject(get_current_user), -): - if user.disabled: - raise HTTPException(status_code=400, detail="Inactive user") - return user - - @post( "/token", dependencies={"form_data": Inject(OAuth2PasswordRequestForm)}, @@ -87,7 +82,7 @@ async def login(form_data: OAuth2PasswordRequestForm = Injects()) -> Dict[str, s return {"access_token": user.username, "token_type": "bearer"} -@get("/users/me", dependencies={"current_user": Inject(get_current_active_user)}) +@get("/users/me", dependencies={"current_user": Inject(get_current_user)}) async def read_users_me( current_user: User = Injects(), ) -> User: diff --git a/esmerald/transformers/model.py b/esmerald/transformers/model.py index 6136b664..a8b12887 100644 --- a/esmerald/transformers/model.py +++ b/esmerald/transformers/model.py @@ -17,6 +17,7 @@ get_signature, merge_sets, ) +from esmerald.typing import Undefined from esmerald.utils.constants import CONTEXT, DATA, PAYLOAD, RESERVED_KWARGS from esmerald.utils.dependencies import is_security_scheme from esmerald.utils.schema import is_field_optional @@ -28,6 +29,7 @@ MEDIA_TYPES = [EncodingType.MULTI_PART, EncodingType.URL_ENCODED] MappingUnion = Mapping[Union[int, str], Any] +PydanticUndefined = Undefined class TransformerModel(ArbitraryExtraBaseModel): @@ -358,7 +360,7 @@ def handle_reserved_kwargs( return {**reserved_kwargs, **path_params, **query_params, **headers, **cookies} -def dependency_tree(key: str, dependencies: "Dependencies") -> Dependency: +def dependency_tree(key: str, dependencies: "Dependencies", first_run: bool = True) -> Dependency: """ Recursively build a dependency tree starting from a given key. @@ -369,13 +371,17 @@ def dependency_tree(key: str, dependencies: "Dependencies") -> Dependency: Returns: Dependency: Constructed dependency tree starting from the specified key. """ + inject = dependencies[key] - dependency_keys = [key for key in get_signature(inject).model_fields if key in dependencies] + inject_signature = get_signature(inject) + dependency_keys = [key for key in inject_signature.model_fields if key in dependencies] + return Dependency( key=key, inject=inject, dependencies=[ - dependency_tree(key=key, dependencies=dependencies) for key in dependency_keys + dependency_tree(key=key, dependencies=dependencies, first_run=False) + for key in dependency_keys ], ) diff --git a/tests/security/http/test_security_http_bearer_optional.py b/tests/security/http/test_security_http_bearer_optional.py index 1b53691f..499efa98 100644 --- a/tests/security/http/test_security_http_bearer_optional.py +++ b/tests/security/http/test_security_http_bearer_optional.py @@ -51,7 +51,7 @@ def test_openapi_schema(): "summary": "Esmerald application", "description": "Highly scalable, performant, easy to learn and for every application.", "contact": {"name": "admin", "email": "admin@myapp.com"}, - "version": "3.5.0", + "version": client.app.version, }, "servers": [{"url": "/"}], "paths": { From 6a8cd2e7a8dcb0252841c1ae5444c00655a28e1a Mon Sep 17 00:00:00 2001 From: tarsil Date: Tue, 10 Dec 2024 13:42:32 +0100 Subject: [PATCH 24/24] Simple security docs --- docs/en/docs/security/oauth-jwt.md | 243 +++++++++++++++ docs/en/mkdocs.yml | 455 +++++++++++++++-------------- docs_src/security/hash/app.py | 157 ++++++++++ 3 files changed, 628 insertions(+), 227 deletions(-) create mode 100644 docs/en/docs/security/oauth-jwt.md create mode 100644 docs_src/security/hash/app.py diff --git a/docs/en/docs/security/oauth-jwt.md b/docs/en/docs/security/oauth-jwt.md new file mode 100644 index 00000000..963ce4be --- /dev/null +++ b/docs/en/docs/security/oauth-jwt.md @@ -0,0 +1,243 @@ +# OAuth2 with Password, Bearer with JWT tokens + +Now that we’ve outlined the security flow, let’s secure the application using JWT tokens and secure password hashing. + +The following code is production-ready. You can store hashed passwords in your database and integrate it into your application. + +We’ll build on the foundation from the previous chapter and enhance it further. + +## What is the JWT + +JWT extends for *JSON Web Token* and it is widely adopted and used to secure systems around the world. + +JWT is also a standard and quite lengthy. + +```json +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c +``` + +!!! Info + The previous example was extracted from [https://jwt.io/](https://jwt.io) if you decide to play around + and see what you can do with it. + + +JWT tokens are not encrypted, meaning their contents can be read if intercepted. However, they are signed, ensuring you can verify that the token was issued by you and hasn't been tampered with. + +This allows you to issue a token with a set expiration, for example, one week. If the user returns the next day with the token, you can verify they are still logged into your system. After the token expires, the user will no longer be authorized and must log in again to obtain a new one. + +If someone attempts to modify the token, such as changing the expiration date, the signature validation will fail, exposing the tampering attempt. + +## Installing `PyJWT` + +The following examples will be assuming that you don't know about anything although, +**Esmerald also comes with [JWT integration](../configurations/jwt.md)** and there are details how to leverage it. + +You will be required to install some additional libraries when using the following examples but summarizing it, you +can also achieve the same results by running: + +```shell +$ pip install esmerald[jwt] +``` + +!!! Warning + It is strongly advised to use virtual environments to isolate your packages from the core system ones and avoiding to break them by accident. + + +To use digital signature algorithms like RSA or ECDSA, make sure to install the `cryptography` library by adding the `pyjwt[crypto]` dependency. + +For more details, refer to the [PyJWT Installation Documentation](https://pyjwt.readthedocs.io/en/stable/installation.html). + +Now it is time to install `PyJWT`. + +```shell +$ pip install pyjwt +``` + +## Password Hashing + +Hashing involves transforming content (such as a password) into a seemingly random sequence of bytes (a string) that resembles gibberish. + +The same input (e.g., the same password) will always produce the same hashed output. However, the process is one-way, meaning you cannot reverse the hash to recover the original content. + +### Why hashing is important + +If your database is compromised, the attacker will only have access to hashed passwords, not the plaintext ones. + +This prevents the thief from directly using the passwords on other systems, which is critical since many users reuse the same password across multiple platforms. + +An example of hashing is what Django (and **Esmerald**) offer, the **PBKDF2** (Password-Based Key Derivation Function 1 and 2). + +To help us with this, we will be using `passlib`. + +## Installing `passlib` + +PassLib is an excellent Python library for managing password hashing. + +It supports a variety of secure hashing algorithms and provides utilities for working with them. + +The recommended algorithm is **Bcrypt**, known for its robust security features. + +```shell +$ pip install passlib[bcrypt] +``` + +!!! Tip + PassLib allows you to configure it to read passwords hashed by frameworks like Django, Flask security plugins, and others. + + This enables scenarios such as sharing a database between a Django application and a Esmerald application or gradually migrating a Django + application to Esmerald. + + Users can seamlessly log in from either application, ensuring compatibility and a smooth transition. + +## Hashing and verification of the passwords + +This can be achived by importing everything that is needed from `passlib` package. + +Create a PassLib "context" to handle password hashing and verification. + +!!! Tip + The PassLib context supports multiple hashing algorithms, including deprecated ones, enabling you to verify old hashes while using a secure algorithm like Bcrypt for new passwords. + + This allows compatibility with existing systems (e.g., verifying Django-generated passwords) while ensuring stronger security for newly hashed passwords—all within the same application. + +Create a utility function to hash a user's password, another to check if a given password matches the stored hash, and a third to authenticate the user and return their details. + +```python hl_lines="6 29 64-65 68-69 77-81" +{!> ../../../docs_src/security/hash/app.py !} +``` + +!!! Check + In the new (fake) database, `fake_users_db`, the hashed password will appear as a string like this: `"$2a$12$KplebFTPwFcgGQosJgI4De0PyB2AoRCSxasxHpFoYZPp6uQV/xLzm"`. You can test the username `janedoe` and the + password `hashsecret` against this value and confirm it is correct using any online platform dedicated to this. + +## Handling JWT Tokens + +Import the necessary modules. + +Generate a random secret key to sign the JWT tokens. + +Use the following command to generate a secure random secret key: + +```shell +$ openssl rand -hex 32 +``` + +Here’s a clearer and more concise version of the instructions: + +1. Copy the output of the random secret key generation into the `SECRET_KEY` variable (do not use the example key). +2. Create a variable `ALGORITHM` and set it to `"HS256"`, the algorithm used for signing the JWT token. +3. Define a variable for the token’s expiration time. +4. Define a Pydantic model to use for the response in the token endpoint. +5. Create a utility function to generate a new access token. + +```python hl_lines="4 5 24-26 44-46 84-88" +{!> ../../../docs_src/security/hash/app.py !} +``` + +## Dependencies Update + +Update `get_current_user` to accept the same token as before, but now use JWT tokens. + +Decode the received token, verify its validity, and return the current user. If the token is invalid or a user is disabled, immediately raise an HTTP error. + +```python hl_lines="91-108" +{!> ../../../docs_src/security/hash/app.py !} +``` + +## Update the `/token` handler + +Create a `timedelta` object for the token's expiration time. + +Generate a valid JWT access token and return it. + +```python hl_lines="111-128" +{!> ../../../docs_src/security/hash/app.py !} +``` + +### The technicalities of the subject `sub` + +The JWT specification includes a `sub` key, which represents the subject of the token. Although optional, it is often used to store the user's unique identifier. + +JWTs can be used for more than just identifying users. For example, you might use them to represent entities like a "car" or a "blog post." You can then assign specific permissions to these entities, such as "drive" for the car or "edit" for the blog post. By issuing a JWT to a user or bot, they can perform actions (e.g., drive the car or edit the blog post) without needing an account, relying solely on the JWT generated by your API. + +In more complex scenarios, multiple entities might share the same identifier, such as "foo" representing a user, a car, and a blog post. To prevent ID collisions, you can prefix the `sub` value. For instance, to distinguish a user named "johndoe," the `sub` value could be `username:johndoe`. + +The key point is that the `sub` key should contain a unique identifier across the entire application and must be a string. + +## Time to verify it + +Start the server and navigate to the documentation at [http://127.0.0.1:8000/docs/swagger](http://127.0.0.1:8000/docs). + +You should see a similar interface like the following: + +Interface + +Click the **Authorize** button and use the following credentials: + +* **User**: `janedoe` +* **Password**: `hashsecret`. + +Hash + +Now it time to call the endpoint `/users/me` and you should get a response like the following: + +```json +{ + "username": "janedoe", + "email": "janedoe@example.com", + "full_name": "Jane Doe", + "disabled": false +} +``` + +Me + +When you open the developer tools, you’ll notice that the data sent includes only the JWT token. The password is sent only in the initial request to authenticate the user and obtain the access token. After that, the password is not transmitted in subsequent requests. + +## Advanced usage with `scopes` + +OAuth2 defines "scopes" to specify permissions. + +These scopes can be included in a JWT token to restrict access. + +You can provide this token to a user or a third party to interact with your API under these restrictions. + +Advanced usage of JWT tokens often involves scopes, which define specific permissions or actions that the token holder is authorized to perform. Scopes allow more fine-grained control over what users or entities can do within your application. + +### Example of Using Scopes in JWT: + +1. **Define Scopes**: Scopes are typically added to the payload of the JWT token. For instance, a user might have the scope `read:posts` for viewing posts or `write:posts` for creating new posts. + +2. **Include Scopes in JWT**: When generating a token, include the relevant scopes in the payload. For example: + + ```python + jwt_payload = { + "sub": "username:johndoe", + "scopes": ["read:posts", "write:posts"] + } + ``` + +3. **Check Scopes During Authorization**: In your API, when processing requests, you can check if the JWT token includes the necessary scopes for the requested action. + + Example of checking the `write:posts` scope: + + ```python + def has_scope(required_scope: str, token_scopes: list) -> bool: + return required_scope in token_scopes + + token_scopes = decoded_token.get("scopes", []) + if not has_scope("write:posts", token_scopes): + raise HTTPException(status_code=403, detail="Permission denied") + ``` + +4. **Scope-Based Authorization**: You can use scopes to authorize access to specific resources. For example, only users with the `admin` scope might be allowed to delete posts, while users with `read:posts` can only view them. + +5. **Scope Granularity**: Scopes can be used to manage access on different levels, such as at the API, user, or resource level, giving you fine-grained control over who can do what within your application. + +By using scopes in JWT, you can enhance security and implement role-based access control (RBAC) or permission-based access control for more complex use cases. + +## Notes + +These step by step guides were inspired by **FastAPI** great work of providing simple and yet effective examples for everyone to understand. + +Esmerald adopts a different implementation internally but with the same purposes as any other framework to achieve that. diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 6aa732d8..fddf304c 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -6,245 +6,246 @@ theme: custom_dir: ../en/overrides language: en palette: - - scheme: default - primary: green - accent: orange - media: '(prefers-color-scheme: light)' - toggle: - icon: material/lightbulb - name: Switch to dark mode - - scheme: slate - media: '(prefers-color-scheme: dark)' - primary: green - accent: orange - toggle: - icon: material/lightbulb-outline - name: Switch to light mode + - scheme: default + primary: green + accent: orange + media: "(prefers-color-scheme: light)" + toggle: + icon: material/lightbulb + name: Switch to dark mode + - scheme: slate + media: "(prefers-color-scheme: dark)" + primary: green + accent: orange + toggle: + icon: material/lightbulb-outline + name: Switch to light mode favicon: statics/images/favicon.ico logo: statics/images/logo-white.svg features: - - search.suggest - - search.highlight - - content.tabs.link - - content.code.copy - - content.code.annotate - - content.tooltips - - content.code.select - - navigation.indexes - - navigation.path - - navigation.tabs + - search.suggest + - search.highlight + - content.tabs.link + - content.code.copy + - content.code.annotate + - content.tooltips + - content.code.select + - navigation.indexes + - navigation.path + - navigation.tabs repo_name: dymmond/esmerald repo_url: https://github.com/dymmond/esmerald -edit_uri: '' +edit_uri: "" plugins: -- search -- meta-descriptions: - export_csv: false - quiet: false - enable_checks: false - min_length: 50 - max_length: 160 - trim: false -- mkdocstrings: - handlers: - python: - options: - extensions: - - griffe_typingdoc - show_root_heading: true - show_if_no_docstring: true - preload_modules: - - httpx - - lilya - - a2wsgi - inherited_members: true - members_order: source - separate_signature: true - unwrap_annotated: true - filters: - - '!^_' - merge_init_into_class: true - docstring_section_style: spacy - signature_crossrefs: true - show_symbol_type_heading: true - show_symbol_type_toc: true + - search + - meta-descriptions: + export_csv: false + quiet: false + enable_checks: false + min_length: 50 + max_length: 160 + trim: false + - mkdocstrings: + handlers: + python: + options: + extensions: + - griffe_typingdoc + show_root_heading: true + show_if_no_docstring: true + preload_modules: + - httpx + - lilya + - a2wsgi + inherited_members: true + members_order: source + separate_signature: true + unwrap_annotated: true + filters: + - "!^_" + merge_init_into_class: true + docstring_section_style: spacy + signature_crossrefs: true + show_symbol_type_heading: true + show_symbol_type_toc: true markdown_extensions: -- attr_list -- toc: - permalink: true -- mdx_include: - base_path: docs -- admonition -- extra -- pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format '' -- pymdownx.tabbed: - alternate_style: true -- md_in_html + - attr_list + - toc: + permalink: true + - mdx_include: + base_path: docs + - admonition + - extra + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format "" + - pymdownx.tabbed: + alternate_style: true + - md_in_html nav: -- index.md -- Application: - - application/index.md - - Esmerald: application/applications.md - - application/levels.md - - application/settings.md - - Configurations: - - configurations/index.md - - configurations/cors.md - - configurations/csrf.md - - configurations/session.md - - configurations/staticfiles.md - - configurations/template.md - - configurations/jwt.md - - configurations/scheduler.md - - configurations/openapi/config.md -- Features: - - features/index.md - - Routing: - - routing/index.md - - routing/router.md - - routing/routes.md - - routing/handlers.md - - routing/apiview.md - - routing/webhooks.md - - interceptors.md - - permissions.md - - middleware/middleware.md - - dependencies.md - - exceptions.md - - exception-handlers.md - - extensions.md - - password-hashers.md - - requests.md - - context.md - - responses.md - - encoders.md - - msgspec.md - - background-tasks.md - - lifespan-events.md - - protocols.md - - Security: - - security/index.md - - security/introduction.md - - security/interaction.md - - security/simple-oauth2.md - - Advanced & Useful: - - extras/index.md - - extras/path-params.md - - extras/query-params.md - - extras/request-data.md - - extras/upload-files.md - - extras/forms.md - - extras/body-fields.md - - extras/header-fields.md - - extras/cookie-fields.md - - Scheduler: - - scheduler/index.md - - Asyncz: - - scheduler/scheduler.md - - scheduler/handler.md - - Management & Directives: - - directives/index.md - - directives/discovery.md - - directives/directives.md - - directives/custom-directives.md - - directives/shell.md -- Database Integrations: - - databases/index.md - - Saffier: - - databases/saffier/motivation.md - - databases/saffier/models.md - - Middleware: - - databases/saffier/middleware.md - - databases/saffier/example.md - - Edgy: - - databases/edgy/motivation.md - - databases/edgy/models.md - - Middleware: - - databases/edgy/middleware.md - - databases/edgy/example.md - - Mongoz: - - databases/mongoz/motivation.md - - databases/mongoz/documents.md - - Middleware: - - databases/mongoz/middleware.md - - databases/mongoz/example.md -- openapi.md -- Extras: - - wsgi.md - - testclient.md - - Deployment: - - deployment/index.md - - Intro: deployment/intro.md - - Using docker: deployment/docker.md - - external.md -- API Reference: - - references/index.md - - references/esmerald.md - - references/application/settings.md - - references/configurations/cors.md - - references/configurations/csrf.md - - references/configurations/session.md - - references/configurations/static_files.md - - references/configurations/template.md - - references/configurations/jwt.md - - references/configurations/openapi.md - - references/background.md - - references/routing/router.md - - references/routing/gateway.md - - references/routing/websocketgateway.md - - references/routing/webhookgateway.md - - references/routing/include.md - - references/routing/view.md - - references/routing/handlers.md - - references/interceptors.md - - references/permissions.md - - references/middleware/baseauth.md - - references/middleware/middlewares.md - - references/extensions.md - - references/pluggables.md - - references/exceptions.md - - references/request.md - - references/context.md - - references/responses/response.md - - references/responses/json-response.md - - references/responses/template-response.md - - references/responses/orjson-response.md - - references/responses/ujson-response.md - - references/responses/json.md - - references/responses/file.md - - references/responses/redirect.md - - references/responses/stream.md - - references/responses/template.md - - references/responses/orjson.md - - references/responses/ujson.md - - references/responses/openapi-response.md - - references/websockets.md - - references/injector.md - - references/uploadfile.md - - references/status-codes.md - - references/test-client.md -- About: - - about.md - - sponsorship.md - - esmerald-people.md - - examples.md - - contributing.md -- release-notes.md + - index.md + - Application: + - application/index.md + - Esmerald: application/applications.md + - application/levels.md + - application/settings.md + - Configurations: + - configurations/index.md + - configurations/cors.md + - configurations/csrf.md + - configurations/session.md + - configurations/staticfiles.md + - configurations/template.md + - configurations/jwt.md + - configurations/scheduler.md + - configurations/openapi/config.md + - Features: + - features/index.md + - Routing: + - routing/index.md + - routing/router.md + - routing/routes.md + - routing/handlers.md + - routing/apiview.md + - routing/webhooks.md + - interceptors.md + - permissions.md + - middleware/middleware.md + - dependencies.md + - exceptions.md + - exception-handlers.md + - extensions.md + - password-hashers.md + - requests.md + - context.md + - responses.md + - encoders.md + - msgspec.md + - background-tasks.md + - lifespan-events.md + - protocols.md + - Security: + - security/index.md + - security/introduction.md + - security/interaction.md + - security/simple-oauth2.md + - security/oauth-jwt.md + - Advanced & Useful: + - extras/index.md + - extras/path-params.md + - extras/query-params.md + - extras/request-data.md + - extras/upload-files.md + - extras/forms.md + - extras/body-fields.md + - extras/header-fields.md + - extras/cookie-fields.md + - Scheduler: + - scheduler/index.md + - Asyncz: + - scheduler/scheduler.md + - scheduler/handler.md + - Management & Directives: + - directives/index.md + - directives/discovery.md + - directives/directives.md + - directives/custom-directives.md + - directives/shell.md + - Database Integrations: + - databases/index.md + - Saffier: + - databases/saffier/motivation.md + - databases/saffier/models.md + - Middleware: + - databases/saffier/middleware.md + - databases/saffier/example.md + - Edgy: + - databases/edgy/motivation.md + - databases/edgy/models.md + - Middleware: + - databases/edgy/middleware.md + - databases/edgy/example.md + - Mongoz: + - databases/mongoz/motivation.md + - databases/mongoz/documents.md + - Middleware: + - databases/mongoz/middleware.md + - databases/mongoz/example.md + - openapi.md + - Extras: + - wsgi.md + - testclient.md + - Deployment: + - deployment/index.md + - Intro: deployment/intro.md + - Using docker: deployment/docker.md + - external.md + - API Reference: + - references/index.md + - references/esmerald.md + - references/application/settings.md + - references/configurations/cors.md + - references/configurations/csrf.md + - references/configurations/session.md + - references/configurations/static_files.md + - references/configurations/template.md + - references/configurations/jwt.md + - references/configurations/openapi.md + - references/background.md + - references/routing/router.md + - references/routing/gateway.md + - references/routing/websocketgateway.md + - references/routing/webhookgateway.md + - references/routing/include.md + - references/routing/view.md + - references/routing/handlers.md + - references/interceptors.md + - references/permissions.md + - references/middleware/baseauth.md + - references/middleware/middlewares.md + - references/extensions.md + - references/pluggables.md + - references/exceptions.md + - references/request.md + - references/context.md + - references/responses/response.md + - references/responses/json-response.md + - references/responses/template-response.md + - references/responses/orjson-response.md + - references/responses/ujson-response.md + - references/responses/json.md + - references/responses/file.md + - references/responses/redirect.md + - references/responses/stream.md + - references/responses/template.md + - references/responses/orjson.md + - references/responses/ujson.md + - references/responses/openapi-response.md + - references/websockets.md + - references/injector.md + - references/uploadfile.md + - references/status-codes.md + - references/test-client.md + - About: + - about.md + - sponsorship.md + - esmerald-people.md + - examples.md + - contributing.md + - release-notes.md extra_css: -- statics/css/extra.css -- statics/css/custom.css + - statics/css/extra.css + - statics/css/custom.css extra: analytics: provider: google property: G-CNBVBB90NT alternate: - - link: / - name: en - English - - link: /ru/ - name: ru - русский язык + - link: / + name: en - English + - link: /ru/ + name: ru - русский язык hooks: -- ../../scripts/hooks.py + - ../../scripts/hooks.py diff --git a/docs_src/security/hash/app.py b/docs_src/security/hash/app.py new file mode 100644 index 00000000..b7cc6a4e --- /dev/null +++ b/docs_src/security/hash/app.py @@ -0,0 +1,157 @@ +from datetime import datetime, timedelta, timezone +from typing import Dict, List + +import jwt +from jwt.exceptions import InvalidTokenError +from passlib.context import CryptContext +from pydantic import BaseModel + +from esmerald import ( + Esmerald, + Gateway, + HTTPException, + Inject, + Injects, + Security, + get, + post, + status, +) +from esmerald.params import Form +from esmerald.security.oauth2 import OAuth2PasswordBearer, OAuth2PasswordRequestForm + + +SECRET_KEY = "adec4de83525abdd446b258d0df8a3cc151ee65e95ae8b8ccf51b643df71afcf" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +# Pasword context +password_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") + +fake_users_db = { + "janedoe": { + "username": "janedoe", + "full_name": "Jane Doe", + "email": "janedoe@example.com", + "hashed_password": "$2a$12$KplebFTPwFcgGQosJgI4De0PyB2AoRCSxasxHpFoYZPp6uQV/xLzm", + "disabled": False, + } +} + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: str | None = None + + +class User(BaseModel): + username: str + email: str | None = None + full_name: str | None = None + disabled: bool | None = None + + +class UserDB(User): + hashed_password: str + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return password_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return password_context.hash(password) + + +def get_user(db: Dict[str, Dict[str, str]], username: str) -> User | None: + user_dict = db.get(username) + return User(**user_dict) if user_dict else None + + +def authenticate_user(fake_db, username: str, password: str) -> UserDB | None: + user = get_user(fake_db, username) + if user and verify_password(password, user.hashed_password): + return user + return None + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +async def get_current_user(token: str = Security(oauth2_scheme)) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if not username: + raise credentials_exception + except (InvalidTokenError, KeyError): + raise credentials_exception + + user = get_user(fake_users_db, username) + if not user or user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +@post( + "/token", + dependencies={"form_data": Inject(OAuth2PasswordRequestForm)}, + security=[oauth2_scheme], +) +async def login(form_data: OAuth2PasswordRequestForm = Form()) -> Dict[str, str]: + user = authenticate_user(fake_users_db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + return Token(access_token=access_token, token_type="bearer") + + +@get( + "/users/me", + dependencies={"current_user": Inject(get_current_user)}, + security=[oauth2_scheme], +) +async def me( + current_user: User = Injects(), +) -> User: + return current_user + + +@get( + "/users/me/items", + dependencies={"current_user": Inject(get_current_user)}, + security=[oauth2_scheme], +) +async def get_user_items(current_user: User = Injects()) -> List[Dict[str, str]]: + return [{"item_id": "Foo", "owner": current_user.username}] + + +app = Esmerald( + routes=[ + Gateway(handler=login), + Gateway(handler=me), + Gateway(handler=get_user_items), + ], +)