From 426712a7053e9353e716d7a7b4b23fd4b04c1040 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Thu, 17 Oct 2024 09:38:56 -0300 Subject: [PATCH 01/14] Documentation of auth endpoints --- app/enums.py | 7 +++ app/routers/auth.py | 108 +++++++++++++++++++++++++---------- app/types/errors.py | 17 ++++++ app/types/pydantic_models.py | 1 + 4 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 app/types/errors.py diff --git a/app/enums.py b/app/enums.py index f7383664..f380d187 100644 --- a/app/enums.py +++ b/app/enums.py @@ -12,6 +12,13 @@ class PermitionEnum(str, Enum): HCI_FULL_PERMITION = "full_permition" +class LoginErrorEnum(str, Enum): + BAD_CREDENTIALS = "bad_credentials" + BAD_OTP = "bad_otp" + INACTIVE_EMPLOYEE = "inactive_employee" + REQUIRE_2FA = "require_2fa" + + class AccessErrorEnum(str, Enum): NOT_FOUND = "NOT_FOUND" PERMISSION_DENIED = "PERMISSION_DENIED" diff --git a/app/routers/auth.py b/app/routers/auth.py index cf4e77d2..b611f2d9 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse,JSONResponse from app import config from app.models import User @@ -13,6 +13,10 @@ from app.utils import authenticate_user, generate_user_token, read_bq from app.security import TwoFactorAuth from app.dependencies import assert_user_is_active +from app.enums import LoginErrorEnum +from app.types.errors import ( + AuthenticationErrorModel +) from app.config import ( BIGQUERY_ERGON_TABLE_ID, ) @@ -20,24 +24,34 @@ router = APIRouter(prefix="/auth", tags=["Autenticação"]) -@router.post("/token") +@router.post( + "/token", + response_model=Token, + responses={ + 401: {"model": AuthenticationErrorModel} + } +) async def login_without_2fa( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -> Token: user = await authenticate_user(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"}, + return JSONResponse( + status_code=401, + content={ + "message": "Incorrect Username or Password", + "type": LoginErrorEnum.BAD_CREDENTIALS, + }, ) if user.is_2fa_required: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="2FA required. Use the /2fa/login/ endpoint", - headers={"WWW-Authenticate": "Bearer"}, + return JSONResponse( + status_code=401, + content={ + "message": "2FA required. Use the /2fa/login/ endpoint", + "type": LoginErrorEnum.REQUIRE_2FA, + }, ) return { @@ -47,32 +61,48 @@ async def login_without_2fa( } -@router.post("/2fa/is-2fa-active/") +@router.post( + "/2fa/is-2fa-active/", + response_model=bool, + responses={ + 401: {"model": AuthenticationErrorModel} + } +) async def is_2fa_active( form_data: LoginForm, ) -> bool: user = await authenticate_user(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"}, + return JSONResponse( + status_code=401, + content={ + "message": "Incorrect Username or Password", + "type": LoginErrorEnum.BAD_CREDENTIALS, + }, ) return user.is_2fa_activated -@router.post("/2fa/login/") +@router.post( + "/2fa/login/", + response_model=Token, + responses={ + 401: {"model": AuthenticationErrorModel} + } +) async def login_with_2fa( form_data: LoginFormWith2FA, ) -> Token: user = await authenticate_user(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"}, + return JSONResponse( + status_code=401, + content={ + "message": "Incorrect Username or Password", + "type": LoginErrorEnum.BAD_CREDENTIALS, + }, ) # ---------------------------------------- @@ -83,10 +113,12 @@ async def login_with_2fa( is_valid = two_factor_auth.verify_totp_code(form_data.totp_code) if not is_valid: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect OTP", - headers={"WWW-Authenticate": "Bearer"}, + return JSONResponse( + status_code=401, + content={ + "message": "Incorrect OTP", + "type": LoginErrorEnum.BAD_OTP, + }, ) if not user.is_2fa_activated: user.is_2fa_activated = True @@ -106,10 +138,12 @@ async def login_with_2fa( ) # If has ERGON register and is an inactive employee: Unauthorized if len(ergon_register) > 0 and ergon_register[0].get("status_ativo", False) is False: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User is not an active employee", - headers={"WWW-Authenticate": "Bearer"}, + return JSONResponse( + status_code=401, + content={ + "message": "User is not an active employee", + "type": LoginErrorEnum.NOT_ACTIVE_EMPLOYEE, + }, ) return { @@ -119,7 +153,13 @@ async def login_with_2fa( } -@router.post("/2fa/enable/") +@router.post( + "/2fa/enable/", + response_model=Enable2FA, + responses={ + 400: {"model": str} + } +) async def enable_2fa( current_user: Annotated[User, Depends(assert_user_is_active)], ) -> Enable2FA: @@ -135,7 +175,15 @@ async def enable_2fa( return {"secret_key": two_factor_auth.secret_key} -@router.post("/2fa/generate-qrcode/") +@router.post( + "/2fa/generate-qrcode/", + response_model=bytes, + responses={ + 400: {"model": str}, + 401: {"model": AuthenticationErrorModel}, + 404: {"model": str} + } +) async def generate_qrcode( form_data: LoginForm, ) -> bytes: diff --git a/app/types/errors.py b/app/types/errors.py new file mode 100644 index 00000000..7a5e0a13 --- /dev/null +++ b/app/types/errors.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from pydantic import BaseModel + +from app.enums import ( + LoginErrorEnum, + AccessErrorEnum +) + + +class AuthenticationErrorModel(BaseModel): + message: str + type: LoginErrorEnum + + +class AccessErrorEnum(BaseModel): + message: str + type: AccessErrorEnum \ No newline at end of file diff --git a/app/types/pydantic_models.py b/app/types/pydantic_models.py index 496e1861..8727020a 100644 --- a/app/types/pydantic_models.py +++ b/app/types/pydantic_models.py @@ -4,6 +4,7 @@ from pydantic import BaseModel + class User2FA(BaseModel): id: int username: str From 43a53625d5ae6fb784eedb5212fa63454127414f Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Thu, 17 Oct 2024 10:12:12 -0300 Subject: [PATCH 02/14] feat: Add docs in frontend endpoints --- app/routers/frontend.py | 22 +++++++++++++++++++--- app/types/errors.py | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/routers/frontend.py b/app/routers/frontend.py index 78a75680..5d003104 100644 --- a/app/routers/frontend.py +++ b/app/routers/frontend.py @@ -21,6 +21,9 @@ BIGQUERY_PATIENT_SUMMARY_TABLE_ID, BIGQUERY_PATIENT_ENCOUNTERS_TABLE_ID, ) +from app.types.errors import ( + AccessErrorModel +) router = APIRouter(prefix="/frontend", tags=["Frontend Application"]) redis_session = get_redis_session() @@ -47,7 +50,14 @@ async def get_user_info( @router_request( - method="GET", router=router, path="/patient/header/{cpf}", response_model=PatientHeader + method="GET", + router=router, + path="/patient/header/{cpf}", + response_model=PatientHeader, + responses={ + 404: {"model": AccessErrorModel}, + 403: {"model": AccessErrorModel} + } ) @rate_limiter(limit=5, seconds=60, redis=redis_session) async def get_patient_header( @@ -77,7 +87,10 @@ async def get_patient_header( @router_request( - method="GET", router=router, path="/patient/summary/{cpf}", response_model=PatientSummary + method="GET", + router=router, + path="/patient/summary/{cpf}", + response_model=PatientSummary ) @rate_limiter(limit=5, seconds=60, redis=redis_session) async def get_patient_summary( @@ -106,7 +119,10 @@ async def get_patient_summary( @router_request( - method="GET", router=router, path="/patient/encounters/{cpf}", response_model=List[Encounter] + method="GET", + router=router, + path="/patient/encounters/{cpf}", + response_model=List[Encounter] ) @rate_limiter(limit=5, seconds=60, redis=redis_session) async def get_patient_encounters( diff --git a/app/types/errors.py b/app/types/errors.py index 7a5e0a13..a8b76f08 100644 --- a/app/types/errors.py +++ b/app/types/errors.py @@ -12,6 +12,6 @@ class AuthenticationErrorModel(BaseModel): type: LoginErrorEnum -class AccessErrorEnum(BaseModel): +class AccessErrorModel(BaseModel): message: str type: AccessErrorEnum \ No newline at end of file From 7174c5bcddffa45fb99aa9c45b8e53c4b9da5663 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Thu, 17 Oct 2024 16:28:57 -0300 Subject: [PATCH 03/14] fix: request limiter using path without query params --- app/config/base.py | 11 ++--- app/config/dev.py | 11 +++++ app/config/prod.py | 8 ++++ app/decorators.py | 7 ++-- app/lifespan.py | 91 +++++++++++++++++++++++++++++++++++++++++ app/main.py | 16 ++------ app/routers/auth.py | 2 +- app/routers/frontend.py | 20 ++++----- app/utils.py | 28 +++++-------- docker-compose.yaml | 13 ++++++ poetry.lock | 17 +++++++- pyproject.toml | 1 + 12 files changed, 173 insertions(+), 52 deletions(-) create mode 100644 app/lifespan.py diff --git a/app/config/base.py b/app/config/base.py index 09ca220a..165a9de7 100644 --- a/app/config/base.py +++ b/app/config/base.py @@ -19,13 +19,6 @@ ) BIGQUERY_ERGON_TABLE_ID = getenv_or_action("BIGQUERY_ERGON_TABLE_ID", action="raise") -# Redis -REDIS_HOST = getenv_or_action("REDIS_HOST", action="ignore") -REDIS_PASSWORD = getenv_or_action("REDIS_PASSWORD", action="ignore") -REDIS_PORT = getenv_or_action("REDIS_PORT", action="ignore") -if REDIS_PORT: - REDIS_PORT = int(REDIS_PORT) - # JWT configuration JWT_SECRET_KEY = getenv_or_action("JWT_SECRET_KEY", default=token_bytes(32).hex()) JWT_ALGORITHM = getenv_or_action("JWT_ALGORITHM", default="HS256") @@ -33,6 +26,10 @@ getenv_or_action("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", default="30") ) +# Request Limit Configuration +REQUEST_LIMIT_MAX = int(getenv_or_action("REQUEST_LIMIT_MAX", action="raise")) +REQUEST_LIMIT_WINDOW_SIZE = int(getenv_or_action("REQUEST_LIMIT_WINDOW_SIZE", action="raise")) + # Timezone configuration TIMEZONE = "America/Sao_Paulo" diff --git a/app/config/dev.py b/app/config/dev.py index 65b68d8e..5fa9e1da 100644 --- a/app/config/dev.py +++ b/app/config/dev.py @@ -6,17 +6,28 @@ # ====================== # DATABASE # ====================== +# DBO DATABASE_HOST = getenv_or_action("DATABASE_HOST", default="localhost") DATABASE_PORT = getenv_or_action("DATABASE_PORT", default="5432") DATABASE_USER = getenv_or_action("DATABASE_USER", default="postgres") DATABASE_PASSWORD = getenv_or_action("DATABASE_PASSWORD", default="postgres") DATABASE_NAME = getenv_or_action("DATABASE_NAME", default="postgres") +# REDIS +REDIS_HOST = getenv_or_action("REDIS_HOST", action="ignore") +REDIS_PASSWORD = getenv_or_action("REDIS_PASSWORD", action="ignore") +REDIS_PORT = getenv_or_action("REDIS_PORT", action="ignore") +if REDIS_PORT: + REDIS_PORT = int(REDIS_PORT) + # Allow to run API to use the development db from outside container IN_DEBUGGER = getenv_or_action("IN_DEBUGGER", default="false").lower() == "true" if IN_DEBUGGER and DATABASE_HOST == "db": print("Running in debugger mode, changing DATABASE_HOST to localhost") DATABASE_HOST = "localhost" +if IN_DEBUGGER and REDIS_HOST == "redis": + print("Running in debugger mode, changing REDIS_HOST to localhost") + REDIS_HOST = "localhost" # ====================== # CORS diff --git a/app/config/prod.py b/app/config/prod.py index b2b478ab..4848884d 100644 --- a/app/config/prod.py +++ b/app/config/prod.py @@ -6,12 +6,20 @@ LOG_LEVEL = getenv_or_action("LOG_LEVEL", action="ignore", default="INFO") # Database configuration +# DBO DATABASE_HOST = getenv_or_action("DATABASE_HOST", action="raise") DATABASE_PORT = getenv_or_action("DATABASE_PORT", action="raise") DATABASE_USER = getenv_or_action("DATABASE_USER", action="raise") DATABASE_PASSWORD = getenv_or_action("DATABASE_PASSWORD", action="raise") DATABASE_NAME = getenv_or_action("DATABASE_NAME", action="raise") +# REDIS +REDIS_HOST = getenv_or_action("REDIS_HOST", action="ignore") +REDIS_PASSWORD = getenv_or_action("REDIS_PASSWORD", action="ignore") +REDIS_PORT = getenv_or_action("REDIS_PORT", action="ignore") +if REDIS_PORT: + REDIS_PORT = int(REDIS_PORT) + # JWT configuration if getenv_or_action("JWT_ALGORITHM", action="ignore"): JWT_ALGORITHM = getenv_or_action("JWT_ALGORITHM") diff --git a/app/decorators.py b/app/decorators.py index a4f7b2b9..336394e4 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import json from functools import wraps -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional, Sequence, Union -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from app.models import User, UserHistory @@ -15,13 +15,14 @@ def router_request( path: str, response_model: Any = None, responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, + dependencies: Sequence[Depends] | None = None, ): def decorator(f): router_method = getattr(router, method.lower()) if not router_method: raise AttributeError(f"Method {method} is not valid.") - @router_method(path=path, response_model=response_model, responses=responses) + @router_method(path=path, response_model=response_model, responses=responses, dependencies=dependencies) @wraps(f) async def wrapper(*args, **kwargs): user: User = None diff --git a/app/lifespan.py b/app/lifespan.py new file mode 100644 index 00000000..fc86f645 --- /dev/null +++ b/app/lifespan.py @@ -0,0 +1,91 @@ +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from types import ModuleType +from typing import Dict, Iterable, Optional, Union +from contextlib import asynccontextmanager + +import redis.asyncio as redis +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi_limiter import FastAPILimiter + +from tortoise import Tortoise, connections +from tortoise.exceptions import DoesNotExist, IntegrityError +from tortoise.log import logger + +from app.db import TORTOISE_ORM +from app.config import ( + REDIS_HOST, + REDIS_PASSWORD, + REDIS_PORT, +) +from app.utils import request_limiter_identifier + + + +def register_tortoise( + app: FastAPI, + config: Optional[dict] = None, + config_file: Optional[str] = None, + db_url: Optional[str] = None, + modules: Optional[Dict[str, Iterable[Union[str, ModuleType]]]] = None, + generate_schemas: bool = False, + add_exception_handlers: bool = False, +) -> AbstractAsyncContextManager: + async def init_orm() -> None: # pylint: disable=W0612 + await Tortoise.init(config=config, config_file=config_file, db_url=db_url, modules=modules) + logger.info("Tortoise-ORM started, %s, %s", connections._get_storage(), Tortoise.apps) + if generate_schemas: + logger.info("Tortoise-ORM generating schema") + await Tortoise.generate_schemas() + + async def close_orm() -> None: # pylint: disable=W0612 + await connections.close_all() + logger.info("Tortoise-ORM shutdown") + + class Manager(AbstractAsyncContextManager): + async def __aenter__(self) -> "Manager": + await init_orm() + return self + + async def __aexit__(self, *args, **kwargs) -> None: + await close_orm() + + if add_exception_handlers: + + @app.exception_handler(DoesNotExist) + async def doesnotexist_exception_handler(request: Request, exc: DoesNotExist): + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + @app.exception_handler(IntegrityError) + async def integrityerror_exception_handler(request: Request, exc: IntegrityError): + return JSONResponse( + status_code=422, + content={"detail": [{"loc": [], "msg": str(exc), "type": "IntegrityError"}]}, + ) + + return Manager() + + +@asynccontextmanager +async def api_lifespan(app: FastAPI): + # do sth before db inited + redis_connection = redis.from_url( + f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}", + encoding="utf8" + ) + await FastAPILimiter.init( + redis=redis_connection, + identifier=request_limiter_identifier, + ) + + async with register_tortoise( + app, + config=TORTOISE_ORM, + generate_schemas=False, + add_exception_handlers=True, + ): + # do sth while db connected + yield + + # do sth after db closed + await FastAPILimiter.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py index 8a9dd08d..88374e08 100644 --- a/app/main.py +++ b/app/main.py @@ -5,14 +5,12 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from loguru import logger -from tortoise.contrib.fastapi import register_tortoise from app import config -from app.db import TORTOISE_ORM +from app.lifespan import api_lifespan from app.routers import auth, entities_raw, frontend, misc from app.utils import prepare_gcp_credential - logger.remove() logger.add(sys.stdout, level=config.LOG_LEVEL) @@ -27,7 +25,8 @@ prepare_gcp_credential() app = FastAPI( - title="Unificador de Prontuários - SMSRio", + title="Histórico Clínico Integrado - SMSRIO", + lifespan=api_lifespan ) logger.debug("Configuring CORS with the following settings:") @@ -50,11 +49,4 @@ app.include_router(entities_raw.router) app.include_router(auth.router) app.include_router(frontend.router) -app.include_router(misc.router) - -register_tortoise( - app, - config=TORTOISE_ORM, - generate_schemas=False, - add_exception_handlers=True, -) +app.include_router(misc.router) \ No newline at end of file diff --git a/app/routers/auth.py b/app/routers/auth.py index b611f2d9..78ae4569 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -66,7 +66,7 @@ async def login_without_2fa( response_model=bool, responses={ 401: {"model": AuthenticationErrorModel} - } + }, ) async def is_2fa_active( form_data: LoginForm, diff --git a/app/routers/frontend.py b/app/routers/frontend.py index 5d003104..6bc729ea 100644 --- a/app/routers/frontend.py +++ b/app/routers/frontend.py @@ -2,8 +2,7 @@ import asyncio from typing import Annotated, List from fastapi import APIRouter, Depends, Request - -from fastapi_simple_rate_limiter import rate_limiter +from fastapi_limiter.depends import RateLimiter from app.decorators import router_request from app.dependencies import assert_user_is_active, assert_cpf_is_valid @@ -14,19 +13,20 @@ Encounter, UserInfo, ) -from app.utils import read_bq, validate_user_access_to_patient_data, get_redis_session +from app.utils import read_bq, validate_user_access_to_patient_data from app.config import ( BIGQUERY_PROJECT, BIGQUERY_PATIENT_HEADER_TABLE_ID, BIGQUERY_PATIENT_SUMMARY_TABLE_ID, BIGQUERY_PATIENT_ENCOUNTERS_TABLE_ID, + REQUEST_LIMIT_MAX, + REQUEST_LIMIT_WINDOW_SIZE, ) from app.types.errors import ( AccessErrorModel ) router = APIRouter(prefix="/frontend", tags=["Frontend Application"]) -redis_session = get_redis_session() @router.get("/user") @@ -57,9 +57,9 @@ async def get_user_info( responses={ 404: {"model": AccessErrorModel}, 403: {"model": AccessErrorModel} - } + }, + dependencies=[Depends(RateLimiter(times=REQUEST_LIMIT_MAX, seconds=REQUEST_LIMIT_WINDOW_SIZE))] ) -@rate_limiter(limit=5, seconds=60, redis=redis_session) async def get_patient_header( user: Annotated[User, Depends(assert_user_is_active)], cpf: Annotated[str, Depends(assert_cpf_is_valid)], @@ -90,9 +90,9 @@ async def get_patient_header( method="GET", router=router, path="/patient/summary/{cpf}", - response_model=PatientSummary + response_model=PatientSummary, + dependencies=[Depends(RateLimiter(times=REQUEST_LIMIT_MAX, seconds=REQUEST_LIMIT_WINDOW_SIZE))] ) -@rate_limiter(limit=5, seconds=60, redis=redis_session) async def get_patient_summary( user: Annotated[User, Depends(assert_user_is_active)], cpf: Annotated[str, Depends(assert_cpf_is_valid)], @@ -122,9 +122,9 @@ async def get_patient_summary( method="GET", router=router, path="/patient/encounters/{cpf}", - response_model=List[Encounter] + response_model=List[Encounter], + dependencies=[Depends(RateLimiter(times=REQUEST_LIMIT_MAX, seconds=REQUEST_LIMIT_WINDOW_SIZE))] ) -@rate_limiter(limit=5, seconds=60, redis=redis_session) async def get_patient_encounters( user: Annotated[User, Depends(assert_user_is_active)], cpf: Annotated[str, Depends(assert_cpf_is_valid)], diff --git a/app/utils.py b/app/utils.py index 8ef92772..e7b9b271 100644 --- a/app/utils.py +++ b/app/utils.py @@ -10,7 +10,7 @@ from google.oauth2 import service_account from asyncer import asyncify from loguru import logger -from fastapi_simple_rate_limiter.database import create_redis_session +from fastapi import Request from fastapi.responses import JSONResponse from passlib.context import CryptContext @@ -20,9 +20,6 @@ from app.config import ( BIGQUERY_PROJECT, BIGQUERY_PATIENT_HEADER_TABLE_ID, - REDIS_HOST, - REDIS_PASSWORD, - REDIS_PORT, ) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -246,18 +243,13 @@ async def validate_user_access_to_patient_data(user: User, cpf: str) -> tuple[bo return True, None -def get_redis_session(): - """ - Establishes a Redis session using the configured host, port, and password. - Returns: - redis.Redis: A Redis session object if the host, port, and password are available in envs. - None: If any of the host, port, or password are missing. - """ +async def request_limiter_identifier(request: Request): + forwarded = request.headers.get("X-Forwarded-For") - if REDIS_HOST and REDIS_PORT and REDIS_PASSWORD: - return create_redis_session(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD) - else: - logger.warning( - "Could not establish a Redis session because one or more of the required environment variables are missing." # noqa - ) - return None + if forwarded: + return forwarded.split(",")[0] + + path = request.scope["path"] + endpoint_name = path[::-1].split("/", 1)[1][::-1] + + return request.client.host + ":" + endpoint_name diff --git a/docker-compose.yaml b/docker-compose.yaml index d559157e..d084174d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,16 +10,29 @@ services: - no-new-privileges:true ports: - "5432:5432" + + redis: + image: redis:7 + command: redis-server --appendonly yes + ports: + - "6379:6379" + volumes: + - redis-data:/data + api: build: . depends_on: - db + - redis environment: ENVIRONMENT: dev INFISICAL_ADDRESS: ${INFISICAL_ADDRESS} INFISICAL_TOKEN: ${INFISICAL_TOKEN} + REDIS_HOST: redis + REDIS_PORT: 6379 ports: - "8000:80" volumes: postgres-data: + redis-data: diff --git a/poetry.lock b/poetry.lock index 6e999795..63f33200 100644 --- a/poetry.lock +++ b/poetry.lock @@ -613,6 +613,21 @@ typing-extensions = ">=4.8.0" [package.extras] all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "fastapi-limiter" +version = "0.1.6" +description = "A request rate limiter for fastapi" +optional = false +python-versions = ">=3.9,<4.0" +files = [ + {file = "fastapi_limiter-0.1.6-py3-none-any.whl", hash = "sha256:2e53179a4208b8f2c8795e38bb001324d3dc37d2800ff49fd28ec5caabf7a240"}, + {file = "fastapi_limiter-0.1.6.tar.gz", hash = "sha256:6f5fde8efebe12eb33861bdffb91009f699369a3c2862cdc7c1d9acf912ff443"}, +] + +[package.dependencies] +fastapi = "*" +redis = ">=4.2.0rc1" + [[package]] name = "fastapi-simple-rate-limiter" version = "0.0.4" @@ -2887,4 +2902,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "5fc1242cb6d7fb62f1eb325524eab82ec4506e439623f47e82bf1dbcf4ba0a8e" +content-hash = "e9211bbf8ae8de8d309d30f2ae5cdf63ae597aa866318804c3a5017b801e5970" diff --git a/pyproject.toml b/pyproject.toml index dc6887e4..dea70620 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ asyncer = "^0.0.8" qrcode = "^7.4.2" pyotp = "^2.9.0" fastapi-simple-rate-limiter = "^0.0.4" +fastapi-limiter = "^0.1.6" [tool.poetry.group.dev.dependencies] From 697730e03a66ddf9148e16ff6a44fc7d7903a4c0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 19:30:07 +0000 Subject: [PATCH 04/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- app/lifespan.py | 3 ++- app/utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/lifespan.py b/app/lifespan.py index fc86f645..d3c3bdd0 100644 --- a/app/lifespan.py +++ b/app/lifespan.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from contextlib import AbstractAsyncContextManager, asynccontextmanager from types import ModuleType from typing import Dict, Iterable, Optional, Union @@ -70,7 +71,7 @@ async def integrityerror_exception_handler(request: Request, exc: IntegrityError async def api_lifespan(app: FastAPI): # do sth before db inited redis_connection = redis.from_url( - f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}", + f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}", encoding="utf8" ) await FastAPILimiter.init( diff --git a/app/utils.py b/app/utils.py index e7b9b271..38be1f8c 100644 --- a/app/utils.py +++ b/app/utils.py @@ -248,7 +248,7 @@ async def request_limiter_identifier(request: Request): if forwarded: return forwarded.split(",")[0] - + path = request.scope["path"] endpoint_name = path[::-1].split("/", 1)[1][::-1] From 11cfb6d8a85c9265433ae6d9eceec4b6727fe420 Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Fri, 18 Oct 2024 10:41:51 -0300 Subject: [PATCH 05/14] feat: Add Redis service to API workflow --- .github/workflows/api.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/api.yaml b/.github/workflows/api.yaml index c0c9321d..0d7e83dc 100644 --- a/.github/workflows/api.yaml +++ b/.github/workflows/api.yaml @@ -48,6 +48,13 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + redis: + image: redis:7 + command: redis-server --appendonly yes + ports: + - "6379:6379" + volumes: + - redis-data:/data steps: - name: Checkout uses: actions/checkout@v3 From ac5397df01f5f0f3097278852591d53561f02a49 Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Fri, 18 Oct 2024 10:58:09 -0300 Subject: [PATCH 06/14] Using lifespan manager to trigger startup and shutdown events --- poetry.lock | 16 +++++++++++++++- pyproject.toml | 1 + tests/conftest.py | 6 ++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 63f33200..955aedc5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -74,6 +74,20 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] +[[package]] +name = "asgi-lifespan" +version = "2.1.0" +description = "Programmatic startup/shutdown of ASGI apps." +optional = false +python-versions = ">=3.7" +files = [ + {file = "asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308"}, + {file = "asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f"}, +] + +[package.dependencies] +sniffio = "*" + [[package]] name = "async-timeout" version = "4.0.3" @@ -2902,4 +2916,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "e9211bbf8ae8de8d309d30f2ae5cdf63ae597aa866318804c3a5017b801e5970" +content-hash = "fa36cb1477a80860e9b3571a284ff588647175ef2ed4ff2e663497425524e9cf" diff --git a/pyproject.toml b/pyproject.toml index dea70620..14d4d8a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ qrcode = "^7.4.2" pyotp = "^2.9.0" fastapi-simple-rate-limiter = "^0.0.4" fastapi-limiter = "^0.1.6" +asgi-lifespan = "^2.1.0" [tool.poetry.group.dev.dependencies] diff --git a/tests/conftest.py b/tests/conftest.py index 2fb183cd..6ec73bd3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import random from httpx import AsyncClient from tortoise import Tortoise +from asgi_lifespan import LifespanManager import scripts.database_init_table import scripts.create_user @@ -36,8 +37,9 @@ def event_loop(): @pytest.fixture(scope="session") async def client(): - async with AsyncClient(app=app, base_url="http://test") as client_object: - yield client_object + async with LifespanManager(app): + async with AsyncClient(app=app, base_url="http://test") as client_object: + yield client_object @pytest.fixture(scope="session", autouse=True) From cf9eac44eb4813abe6ca33d279bd06d0f7733814 Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Fri, 18 Oct 2024 15:03:41 -0300 Subject: [PATCH 07/14] Fix login error message for inactive employees --- app/routers/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routers/auth.py b/app/routers/auth.py index 78ae4569..03eb1c7c 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -142,7 +142,7 @@ async def login_with_2fa( status_code=401, content={ "message": "User is not an active employee", - "type": LoginErrorEnum.NOT_ACTIVE_EMPLOYEE, + "type": LoginErrorEnum.INACTIVE_EMPLOYEE, }, ) From 079883ad30345d95b5d339501857d60dc04a7085 Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Fri, 18 Oct 2024 15:26:10 -0300 Subject: [PATCH 08/14] Refactor request_limiter_identifier function in utils.py --- app/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/utils.py b/app/utils.py index 38be1f8c..574bba5b 100644 --- a/app/utils.py +++ b/app/utils.py @@ -247,9 +247,13 @@ async def request_limiter_identifier(request: Request): forwarded = request.headers.get("X-Forwarded-For") if forwarded: + logger.debug(f"(Forwarded) Request Limiter Identifier: {forwarded.split(",")[0]}") return forwarded.split(",")[0] path = request.scope["path"] endpoint_name = path[::-1].split("/", 1)[1][::-1] + identifier = request.client.host + ":" + endpoint_name + logger.debug(f"Request Limiter Identifier: {identifier}") + return request.client.host + ":" + endpoint_name From 08d7ca19a4a3e4f6800517c898f2bd49ea817a4d Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Fri, 18 Oct 2024 16:04:42 -0300 Subject: [PATCH 09/14] Refactor request_limiter_identifier function in utils.py --- app/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/utils.py b/app/utils.py index 574bba5b..69dfca7a 100644 --- a/app/utils.py +++ b/app/utils.py @@ -247,7 +247,8 @@ async def request_limiter_identifier(request: Request): forwarded = request.headers.get("X-Forwarded-For") if forwarded: - logger.debug(f"(Forwarded) Request Limiter Identifier: {forwarded.split(",")[0]}") + identifier = forwarded.split(',')[0] + logger.debug(f"(Forwarded) Request Limiter Identifier: {identifier}") return forwarded.split(",")[0] path = request.scope["path"] From edae7e00b6f861ec4c0181cb3f54e0ead45fbdf4 Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Fri, 18 Oct 2024 16:18:19 -0300 Subject: [PATCH 10/14] change level of log message to info --- app/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/utils.py b/app/utils.py index 69dfca7a..7abc4dcf 100644 --- a/app/utils.py +++ b/app/utils.py @@ -248,13 +248,13 @@ async def request_limiter_identifier(request: Request): if forwarded: identifier = forwarded.split(',')[0] - logger.debug(f"(Forwarded) Request Limiter Identifier: {identifier}") + logger.info(f"(Forwarded) Request Limiter Identifier: {identifier}") return forwarded.split(",")[0] path = request.scope["path"] endpoint_name = path[::-1].split("/", 1)[1][::-1] identifier = request.client.host + ":" + endpoint_name - logger.debug(f"Request Limiter Identifier: {identifier}") + logger.info(f"Request Limiter Identifier: {identifier}") return request.client.host + ":" + endpoint_name From b568084cf1b638ddc89147b963817a9817ef42df Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Fri, 18 Oct 2024 16:31:55 -0300 Subject: [PATCH 11/14] Add docs in identifier method and improve forwarding case handle --- app/utils.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/app/utils.py b/app/utils.py index 7abc4dcf..39c95454 100644 --- a/app/utils.py +++ b/app/utils.py @@ -244,17 +244,32 @@ async def validate_user_access_to_patient_data(user: User, cpf: str) -> tuple[bo async def request_limiter_identifier(request: Request): + """ + Generates a unique identifier for rate limiting based on the request's client host and + endpoint name. + Args: + request (Request): The incoming HTTP request object. + Returns: + str: A unique identifier in the format "host:endpoint_name". + Logs: + - The path and endpoint name of the request. + - The client host, either from the "X-Forwarded-For" header or directly from the request. + - The generated unique identifier. + """ forwarded = request.headers.get("X-Forwarded-For") - if forwarded: - identifier = forwarded.split(',')[0] - logger.info(f"(Forwarded) Request Limiter Identifier: {identifier}") - return forwarded.split(",")[0] - path = request.scope["path"] endpoint_name = path[::-1].split("/", 1)[1][::-1] + logger.info(f"Request Limiter :: Path/Endpoint: {path}/{endpoint_name}") + + if forwarded: + host = forwarded.split(',')[0] + logger.info(f"(Forwarded) Request Limiter :: Host: {host}") + else: + host = request.client.host + logger.info(f"Request Limiter :: Host: {host}") - identifier = request.client.host + ":" + endpoint_name - logger.info(f"Request Limiter Identifier: {identifier}") + identifier = host + ":" + endpoint_name + logger.info(f"Request Limiter :: ID: {identifier}") - return request.client.host + ":" + endpoint_name + return identifier From b170414528d218060d317d28b8da42a8601e85a3 Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Fri, 18 Oct 2024 16:38:39 -0300 Subject: [PATCH 12/14] Remove some logs --- app/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/utils.py b/app/utils.py index 39c95454..b6ad7a84 100644 --- a/app/utils.py +++ b/app/utils.py @@ -260,14 +260,11 @@ async def request_limiter_identifier(request: Request): path = request.scope["path"] endpoint_name = path[::-1].split("/", 1)[1][::-1] - logger.info(f"Request Limiter :: Path/Endpoint: {path}/{endpoint_name}") if forwarded: host = forwarded.split(',')[0] - logger.info(f"(Forwarded) Request Limiter :: Host: {host}") else: host = request.client.host - logger.info(f"Request Limiter :: Host: {host}") identifier = host + ":" + endpoint_name logger.info(f"Request Limiter :: ID: {identifier}") From f3ca3fd686634d81edd6318f30ecac71c5f77f6c Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Tue, 22 Oct 2024 16:22:23 -0300 Subject: [PATCH 13/14] Update frontend.py --- app/types/frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/types/frontend.py b/app/types/frontend.py index 32e16ba1..fa1bbe50 100644 --- a/app/types/frontend.py +++ b/app/types/frontend.py @@ -70,7 +70,7 @@ class Encounter(BaseModel): clinical_motivation: Optional[str] clinical_outcome: Optional[str] clinical_exams: List[ClinicalExam] - procedures: List[Procedure] + procedures: Optional[str] filter_tags: List[str] From 4fdf00bc7ef4be43b941c65b5454d65a756a0b04 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Fri, 25 Oct 2024 14:34:31 -0300 Subject: [PATCH 14/14] chore: Update CCO's tag description in frontend.py --- app/routers/frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routers/frontend.py b/app/routers/frontend.py index 6bc729ea..97761d2c 100644 --- a/app/routers/frontend.py +++ b/app/routers/frontend.py @@ -172,7 +172,7 @@ async def get_metadata(_: Annotated[User, Depends(assert_user_is_active)]) -> di {"tag": "HOSPITAL", "description": "Hospital"}, {"tag": "CENTRO SAUDE ESCOLA", "description": "Centro Saúde Escola"}, {"tag": "UPA", "description": "UPA"}, - {"tag": "CCO", "description": "CCO"}, + {"tag": "CCO", "description": "Centro Carioca do Olho"}, {"tag": "MATERNIDADE", "description": "Maternidade"}, {"tag": "CER", "description": "CER"}, {"tag": "POLICLINICA", "description": "Policlínica"},