From 720cc156d0944f9e5ddf6b2aac93576b4edc68a6 Mon Sep 17 00:00:00 2001 From: Tvrtko Sternak Date: Tue, 12 Nov 2024 13:42:14 +0000 Subject: [PATCH] Implement HTTPBasic security --- docs/docs/SUMMARY.md | 1 + .../api/openapi/security/HTTPBasic.md | 11 ++ docs/docs/en/user-guide/api/security.md | 28 +++- .../external_rest_apis/security_examples.py | 11 ++ fastagency/api/openapi/security.py | 37 +++++ .../security/test_http_basic_client.py | 147 ++++++++++++++++++ ...p_client.py => test_http_bearer_client.py} | 0 .../security/test_unsupported_security.py | 2 +- 8 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 docs/docs/en/api/fastagency/api/openapi/security/HTTPBasic.md create mode 100644 tests/api/openapi/security/test_http_basic_client.py rename tests/api/openapi/security/{test_http_client.py => test_http_bearer_client.py} (100%) diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index cef3cdbd..46b347a0 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -79,6 +79,7 @@ search: - [APIKeyQuery](api/fastagency/api/openapi/security/APIKeyQuery.md) - [BaseSecurity](api/fastagency/api/openapi/security/BaseSecurity.md) - [BaseSecurityParameters](api/fastagency/api/openapi/security/BaseSecurityParameters.md) + - [HTTPBasic](api/fastagency/api/openapi/security/HTTPBasic.md) - [HTTPBearer](api/fastagency/api/openapi/security/HTTPBearer.md) - [OAuth2PasswordBearer](api/fastagency/api/openapi/security/OAuth2PasswordBearer.md) - [UnsuportedSecurityStub](api/fastagency/api/openapi/security/UnsuportedSecurityStub.md) diff --git a/docs/docs/en/api/fastagency/api/openapi/security/HTTPBasic.md b/docs/docs/en/api/fastagency/api/openapi/security/HTTPBasic.md new file mode 100644 index 00000000..7b0f41ec --- /dev/null +++ b/docs/docs/en/api/fastagency/api/openapi/security/HTTPBasic.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: fastagency.api.openapi.security.HTTPBasic diff --git a/docs/docs/en/user-guide/api/security.md b/docs/docs/en/user-guide/api/security.md index 7b15b3d5..b2cfa9fb 100644 --- a/docs/docs/en/user-guide/api/security.md +++ b/docs/docs/en/user-guide/api/security.md @@ -9,13 +9,16 @@ FastAgency currently supports the following security schemas: 1. **HTTP Bearer Token** An HTTP authentication scheme using Bearer tokens, commonly used for securing RESTful APIs. -2. **API Key** +2. **HTTP Basic Auth** + HTTP authenticcation scheme using username/password pair. + +3. **API Key** API keys can be provided in: - HTTP header - Query parameters - Cookies -3. **OAuth2 (Password Flow)** +4. **OAuth2 (Password Flow)** A flow where the token authority and API service reside on the same address. This is useful for scenarios where the client credentials are exchanged for a token directly. ## Defining Security Schemas in OpenAPI @@ -37,6 +40,21 @@ In your OpenAPI schema: } ``` +### HTTP Basic auth +In your OpenAPI schema: +```json +{ + "components": { + "securitySchemes": { + "BasicAuth": { + "type": "http", + "scheme": "basic" + } + } + } +} +``` + ### API Key (in Header, Query, or Cookie) In your OpenAPI schema: ```json @@ -96,6 +114,12 @@ To configure bearer token authentication, provide the token when initializing th {! docs_src/user_guide/external_rest_apis/security_examples.py [ln:66-70] !} ``` +### Using HTTP Basic Auth +To configure basic authentication, provide the username and password when initializing the API client: +```python hl_lines="5" +{! docs_src/user_guide/external_rest_apis/security_examples.py [ln:77-81] !} +``` + ### Using API Key (in Header, Query, or Cookie) You can configure the client to send an API key in the appropriate location (header, query, or cookie): diff --git a/docs/docs_src/user_guide/external_rest_apis/security_examples.py b/docs/docs_src/user_guide/external_rest_apis/security_examples.py index 7f165ebe..e9719709 100644 --- a/docs/docs_src/user_guide/external_rest_apis/security_examples.py +++ b/docs/docs_src/user_guide/external_rest_apis/security_examples.py @@ -70,3 +70,14 @@ def configure_http_bearer_client( api_client.set_security_params(HTTPBearer.Parameters(value=api_key)) # API key return api_client + +def configure_http_basic_client( + openapi_url: str, username: str, password: str +) -> OpenAPI: + from fastagency.api.openapi import OpenAPI + from fastagency.api.openapi.security import HTTPBasic + + api_client = OpenAPI.create(openapi_url=openapi_url) # API openapi specification url + api_client.set_security_params(HTTPBasic.Parameters(username=username, password=password)) # username/password + + return api_client diff --git a/fastagency/api/openapi/security.py b/fastagency/api/openapi/security.py index 446dfc56..1fa1e70e 100644 --- a/fastagency/api/openapi/security.py +++ b/fastagency/api/openapi/security.py @@ -1,3 +1,4 @@ +import base64 import logging from typing import Any, ClassVar, Literal, Optional, Protocol @@ -229,6 +230,42 @@ def get_security_class(self) -> type[BaseSecurity]: return HTTPBearer +class HTTPBasic(BaseSecurity): + """HTTP Bearer security class.""" + + type: ClassVar[Literal["http"]] = "http" + in_value: ClassVar[Literal["basic"]] = "basic" + + @classmethod + def is_supported(cls, type: str, schema_parameters: dict[str, Any]) -> bool: + return cls.type == type and cls.in_value == schema_parameters.get("scheme") + + class Parameters(BaseModel): # BaseSecurityParameters + """HTTP Basic security parameters class.""" + + username: str + password: str + + def apply( + self, + q_params: dict[str, Any], + body_dict: dict[str, Any], + security: BaseSecurity, + ) -> None: + if "headers" not in body_dict: + body_dict["headers"] = {} + + credentials = f"{self.username}:{self.password}" + encoded_credentials = base64.b64encode(credentials.encode("utf-8")).decode( + "utf-8" + ) + + body_dict["headers"]["Authorization"] = f"Basic {encoded_credentials}" + + def get_security_class(self) -> type[BaseSecurity]: + return HTTPBasic + + class OAuth2PasswordBearer(BaseSecurity): """OAuth2 Password Bearer security class.""" diff --git a/tests/api/openapi/security/test_http_basic_client.py b/tests/api/openapi/security/test_http_basic_client.py new file mode 100644 index 00000000..7e580bbf --- /dev/null +++ b/tests/api/openapi/security/test_http_basic_client.py @@ -0,0 +1,147 @@ +import json +from typing import Annotated + +import pytest +import requests +from fastapi import Depends, FastAPI, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +from docs.docs_src.user_guide.external_rest_apis.security_examples import ( + configure_http_basic_client, +) + + +def create_http_basic_fastapi_app(host: str, port: int) -> FastAPI: + app = FastAPI( + title="OAuth2", + servers=[ + {"url": f"http://{host}:{port}", "description": "Local development server"} + ], + ) + + security = HTTPBasic() + + # Dependency to verify the username/password + def verify_auth( + credentials: Annotated[HTTPBasicCredentials, Depends(security)], + ) -> None: + if (credentials.username != "john") or ( + credentials.password != "supersecret" # pragma: allowlist secret + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid username or password", + ) + + # Secured endpoint + @app.get("/hello") + def get_hello( + credentials: HTTPBasicCredentials = Depends(verify_auth), # noqa: B008 + ) -> dict[str, str]: + return {"message": "Hello, authenticated user!"} + + return app + + +@pytest.mark.parametrize( + "fastapi_openapi_url", + [(create_http_basic_fastapi_app)], + indirect=["fastapi_openapi_url"], +) +def test_openapi_schema(fastapi_openapi_url: str) -> None: + with requests.get(fastapi_openapi_url, timeout=10) as response: + response.raise_for_status() + openapi_json = json.loads(response.text) + + expected_schema = { + "openapi": "3.1.0", + "info": {"title": "OAuth2", "version": "0.1.0"}, + "servers": [ + { + "url": f"{fastapi_openapi_url.split('/openapi.json')[0]}", + "description": "Local development server", + } + ], + "paths": { + "/hello": { + "get": { + "summary": "Get Hello", + "operationId": "get_hello_hello_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": {"type": "string"}, + "type": "object", + "title": "Response Get Hello Hello Get", + } + } + }, + } + }, + "security": [{"HTTPBasic": []}], + } + } + }, + "components": { + "securitySchemes": {"HTTPBasic": {"type": "http", "scheme": "basic"}} + }, + } + + assert openapi_json == expected_schema + + +@pytest.mark.parametrize( + "fastapi_openapi_url", + [ + (create_http_basic_fastapi_app), + ], + indirect=["fastapi_openapi_url"], +) +def test_http_bearer_token_correct(fastapi_openapi_url: str) -> None: + api_client = configure_http_basic_client( + fastapi_openapi_url, + username="john", + password="supersecret", # pragma: allowlist secret + ) + + expected = [ + "get_hello_hello_get", + ] + + functions = list(api_client._get_functions_to_register()) + assert [f.__name__ for f in functions] == expected + + hello_get_f = functions[0] + + response = hello_get_f() + + assert response == {"message": "Hello, authenticated user!"} + + +@pytest.mark.parametrize( + "fastapi_openapi_url", + [ + (create_http_basic_fastapi_app), + ], + indirect=["fastapi_openapi_url"], +) +def test_http_bearer_token_wrong(fastapi_openapi_url: str) -> None: + api_client = configure_http_basic_client( + fastapi_openapi_url, + username="john", + password="wrongsecret", # pragma: allowlist secret + ) + + expected = [ + "get_hello_hello_get", + ] + + functions = list(api_client._get_functions_to_register()) + assert [f.__name__ for f in functions] == expected + + hello_get_f = functions[0] + + assert hello_get_f() == {"detail": "Invalid username or password"} diff --git a/tests/api/openapi/security/test_http_client.py b/tests/api/openapi/security/test_http_bearer_client.py similarity index 100% rename from tests/api/openapi/security/test_http_client.py rename to tests/api/openapi/security/test_http_bearer_client.py diff --git a/tests/api/openapi/security/test_unsupported_security.py b/tests/api/openapi/security/test_unsupported_security.py index 26d4b0dc..d3d98978 100644 --- a/tests/api/openapi/security/test_unsupported_security.py +++ b/tests/api/openapi/security/test_unsupported_security.py @@ -5,7 +5,7 @@ from fastagency.api.openapi.client import OpenAPI from fastagency.api.openapi.security import HTTPBearer, UnsuportedSecurityStub -from .test_http_client import create_http_bearer_fastapi_app +from .test_http_bearer_client import create_http_bearer_fastapi_app @pytest.fixture