implemented rate limit and set up redis container

This commit is contained in:
2026-09-05 13:06:04 +03:00
parent 5ca1e2af15
commit da8284ae64
12 changed files with 139 additions and 20 deletions
+7 -3
View File
@@ -1,10 +1,14 @@
SECRET_KEY = "change_me" SECRET_KEY = "change_me"
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15 ACCESS_TOKEN_EXPIRE_MINUTES = 15 #int
REFRESH_TOKEN_EXPIRE_DAYS= 45 REFRESH_TOKEN_EXPIRE_DAYS= 45 #int
DB_USER="change_me" DB_USER="change_me"
DB_PASSWORD="change_me" DB_PASSWORD="change_me"
DB_POSTGRESS="change_me" DB_POSTGRESS="change_me"
DB_HOST="change_me" DB_HOST="change_me"
DB_PORT="change_me" DB_PORT="change_me"
REDIS_PASSWORD="change_me"
REDIS_PORT=change_me #int
REDIS_HOST="change_me"
+24 -3
View File
@@ -12,7 +12,7 @@ services:
init: true #Manage processes and reap zombies init: true #Manage processes and reap zombies
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
environment: environment:
- DB_HOST=psql - DB_HOST=psql #rewrite DB_HOST var to communicate inside the docker network
volumes: volumes:
- type: bind - type: bind
source: ../src source: ../src
@@ -37,6 +37,8 @@ services:
depends_on: depends_on:
psql: psql:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
ports: ports:
- "80:8000" - "80:8000"
entrypoint: ["./entrypoint.sh", "--dev"] entrypoint: ["./entrypoint.sh", "--dev"]
@@ -72,6 +74,8 @@ services:
depends_on: depends_on:
psql: psql:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
ports: ports:
- "80:8000" - "80:8000"
@@ -98,8 +102,25 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
ports: ports:
- "5432:5432" - "${DB_PORT}:5432"
redis:
image: redis:latest
profiles: ["prod", "dev", "redis"]
container_name: redis
init: true
ipc: private
ports:
- '${REDIS_PORT}:6379'
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"]
interval: 1s
timeout: 3s
retries: 5
restart: unless-stopped
networks: networks:
backend: backend:
name: "${BACKEND_NETWORK:-backend_network}" name: "${BACKEND_NETWORK:-backend_network}"
+2
View File
@@ -4,6 +4,7 @@ from pathlib import Path
# import uvicorn # import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from src.cache.redis_client import redis_client
from src.database.users.crud import Seed from src.database.users.crud import Seed
from src.logging.logger import LoggingMiddleware, ProcessingTimeMiddleware from src.logging.logger import LoggingMiddleware, ProcessingTimeMiddleware
from src.web.protected_routes.auth_routes import router as protected_router from src.web.protected_routes.auth_routes import router as protected_router
@@ -17,6 +18,7 @@ async def lifespan(app: FastAPI):
create_dirs() create_dirs()
await create_first_user() await create_first_user()
yield yield
await redis_client.close()
app=FastAPI(root_path="/", lifespan=lifespan) app=FastAPI(root_path="/", lifespan=lifespan)
+2 -2
View File
@@ -17,11 +17,11 @@ help:
## ##
.PHONY: run .PHONY: run
run: ## Run dev local application run: ## Run dev local application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db up -d && ${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db --profile redis up -d && ${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload
.PHONY: down .PHONY: down
down: ## Down dev local db down: ## Down dev local db
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db down docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db --profile redis down
.PHONY: run-dev .PHONY: run-dev
run-dev: ## Run dev application run-dev: ## Run dev application
Generated
+21 -1
View File
@@ -2012,6 +2012,26 @@ files = [
{file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"},
] ]
[[package]]
name = "redis"
version = "8.1.0"
description = "Python client for Redis database and key-value store"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb"},
{file = "redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25"},
]
[package.extras]
circuit-breaker = ["pybreaker (>=1.4.0)"]
hiredis = ["hiredis (>=3.2.0)"]
jwt = ["pyjwt (>=2.13.0)"]
ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"]
otel = ["opentelemetry-api (>=1.39.1)", "opentelemetry-exporter-otlp-proto-http (>=1.39.1)", "opentelemetry-sdk (>=1.39.1)"]
xxhash = ["xxhash (>=3.6.0,<3.7.0)"]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.34.2" version = "2.34.2"
@@ -2400,4 +2420,4 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.13" python-versions = ">=3.13"
content-hash = "f507694d0ef7cad070da5cc13f689e93240a272af43b3dd64b1a9ec2b3bbbea4" content-hash = "30e907fcd3da88ca5c08edcb5cf49e0011f4cc702052583dd4496ca89b40e26b"
+4 -1
View File
@@ -25,6 +25,7 @@ dependencies = [
"aiofiles (>=25.1.0,<26.0.0)", "aiofiles (>=25.1.0,<26.0.0)",
"asyncpg (>=0.31.0,<0.32.0)", "asyncpg (>=0.31.0,<0.32.0)",
"psycopg2-binary (>=2.9.12,<3.0.0)", "psycopg2-binary (>=2.9.12,<3.0.0)",
"redis (>=8.1.0,<9.0.0)",
] ]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
@@ -54,4 +55,6 @@ omit = [
[tool.coverage.report] [tool.coverage.report]
exclude_lines = [ exclude_lines = [
"pragma: no cover", "pragma: no cover",
] ]
[tool.ruff.lint]
ignore=["B008"]
+1
View File
@@ -0,0 +1 @@
#redis dir
+29
View File
@@ -0,0 +1,29 @@
from fastapi import Request
from src.cache.redis_client import redis_client
from src.errors.http_errors.errors import Errors
class RateLimit:
def __init__(self) -> None:
self.errors=Errors()
async def rate_limit(self, ip:str)->None:
key=f"action attempt {ip}"
attempts = await redis_client.incrby(key)
if attempts == 1:
await redis_client.expire(key, 60)
if attempts>5:
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=60)
async def check_rate_limit(self, request: Request) -> None:
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
await self.rate_limit(client_ip)
rate_limiter=RateLimit()
+23
View File
@@ -0,0 +1,23 @@
import redis.asyncio as redis
from src.models.configs_read.env import env_settings
class RedisClient:
def __init__(self) -> None:
self.client = redis.Redis(
host=env_settings.REDIS_HOST,
port=env_settings.REDIS_PORT,
password=env_settings.REDIS_PASSWORD,
decode_responses=True,
max_connections=10,
)
def __getattr__(self, name):
return getattr(self.client, name)
async def close(self) -> None:
await self.client.aclose()
redis_client = RedisClient()
+4
View File
@@ -16,6 +16,10 @@ class Env(Base):
DB_HOST:str DB_HOST:str
DB_PORT:str DB_PORT:str
REDIS_PASSWORD:str
REDIS_PORT:int
REDIS_HOST:str
model_config=SettingsConfigDict(env_file="configs/.env", extra=None) model_config=SettingsConfigDict(env_file="configs/.env", extra=None)
env_settings=Env() # type: ignore[call-arg] env_settings=Env() # type: ignore[call-arg]
+17 -5
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Cookie, Depends, Request, Response from fastapi import APIRouter, Cookie, Depends, Request, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from src.cache.rate_limit import rate_limiter
from src.models.configs_read.env import env_settings from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import UserOut from src.models.pydantic_models.model import UserOut
from src.service.auth.auth import CurrentUserService, auth_service from src.service.auth.auth import CurrentUserService, auth_service
@@ -12,14 +13,19 @@ oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/pro
def require_permissions(*permissions: str): #permissions check dependency def require_permissions(*permissions: str): #permissions check dependency
async def checker( async def checker(
token: str = Depends(oauth2_schema), token: str = Depends(oauth2_schema),
auth: CurrentUserService = Depends(auth_service), #noqa: B008 auth: CurrentUserService = Depends(auth_service),
) -> UserOut: ) -> UserOut:
return UserOut.model_validate(await auth.get_current_user(token, *permissions)) return UserOut.model_validate(await auth.get_current_user(token, *permissions))
return checker return checker
@router.post("/token") @router.post("/token")
async def get_access_token(request: Request,response:Response,auth:CurrentUserService=Depends(auth_service), form_data:OAuth2PasswordRequestForm=Depends())->dict: # noqa: B008 async def get_access_token(request: Request,
response:Response,
auth:CurrentUserService=Depends(auth_service),
form_data:OAuth2PasswordRequestForm=Depends(),
_:None = Depends(rate_limiter.check_rate_limit)
)->dict:
access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request) access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
@@ -35,7 +41,10 @@ async def get_access_token(request: Request,response:Response,auth:CurrentUserSe
@router.post("/refresh") @router.post("/refresh")
async def get_refresh_token(request:Request,response:Response, refresh_token: str = Cookie(), auth:CurrentUserService=Depends(auth_service))->dict: # noqa: B008 async def get_refresh_token(request:Request,
response:Response,
refresh_token: str = Cookie(),
auth:CurrentUserService=Depends(auth_service))->dict:
access_token, refresh_token= await auth.refresh_token(refresh_token=refresh_token,request=request) access_token, refresh_token= await auth.refresh_token(refresh_token=refresh_token,request=request)
@@ -51,10 +60,13 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st
return {"access_token":access_token, "token_type": "bearer"} return {"access_token":access_token, "token_type": "bearer"}
@router.get("/logout") @router.get("/logout")
async def logout(response:Response,refresh_token: str = Cookie(),auth:CurrentUserService=Depends(auth_service),current_user:UserOut=Depends(require_permissions()))->bool: # noqa: B008 async def logout(response:Response,
refresh_token: str = Cookie(),
auth:CurrentUserService=Depends(auth_service),
current_user:UserOut=Depends(require_permissions()))->bool:
response.delete_cookie("refresh_token") response.delete_cookie("refresh_token")
return await auth.logout(refresh_token) return await auth.logout(refresh_token)
@router.get("") @router.get("")
async def protected(current_user:UserOut=Depends(require_permissions()))->dict: # noqa: B008 async def protected(current_user:UserOut=Depends(require_permissions()))->dict:
return {"protected router": "Hello, this is a protected router"} return {"protected router": "Hello, this is a protected router"}
@@ -7,22 +7,22 @@ from src.web.protected_routes.auth_routes import require_permissions
router=APIRouter(prefix="/user") router=APIRouter(prefix="/user")
@router.get("/get_by_email") @router.get("/get_by_email")
async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: # noqa: B008 async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
return await crud.get_user_by_email(email) return await crud.get_user_by_email(email)
@router.post("/create_user") @router.post("/create_user")
async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008 async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
return await crud.create_user(data) return await crud.create_user(data)
@router.post("/delete_user_soft") @router.post("/delete_user_soft")
async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008 async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool:
return await crud.delete_user_soft(email) return await crud.delete_user_soft(email)
@router.post("/delete_user_hard") @router.post("/delete_user_hard")
async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008 async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool:
return await crud.delete_user_hard(email, current_user) return await crud.delete_user_hard(email, current_user)
@router.patch("/patch_user") @router.patch("/patch_user")
async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008 async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
return await crud.update_user(email, data) return await crud.update_user(email, data)