Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba5e90c516 | ||
|
|
5cdcd342dc | ||
|
|
9fd44c0ad9 | ||
|
|
c4a6a88d05 | ||
|
|
da8284ae64 | ||
|
|
5ca1e2af15 | ||
|
|
d65c4117ff |
@@ -1,10 +1,16 @@
|
|||||||
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"
|
||||||
|
|
||||||
|
PROD_MODE=bool
|
||||||
+24
-3
@@ -1,4 +1,4 @@
|
|||||||
name: excel-project
|
name: disexcel
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend-dev:
|
backend-dev:
|
||||||
@@ -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,7 +102,24 @@ 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:
|
||||||
|
|||||||
+28
-4
@@ -1,4 +1,5 @@
|
|||||||
# --- Stage 1: Python Backend dev ---
|
# --- Stage 1: Python Backend dev ---
|
||||||
|
|
||||||
FROM python:3.14-slim AS dev
|
FROM python:3.14-slim AS dev
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="The-DisExcel-project-dev"
|
LABEL org.opencontainers.image.title="The-DisExcel-project-dev"
|
||||||
@@ -14,6 +15,28 @@ RUN pip install --no-cache-dir --break-system-packages poetry \
|
|||||||
&& poetry config virtualenvs.create false \
|
&& poetry config virtualenvs.create false \
|
||||||
&& poetry install --no-root --no-interaction
|
&& poetry install --no-root --no-interaction
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stage 1: Python Backend builder ---
|
||||||
|
|
||||||
|
FROM python:3.14-slim AS builder
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="The-DisExcel-project-dev"
|
||||||
|
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
|
||||||
|
|
||||||
|
WORKDIR /home/excel-project
|
||||||
|
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
|
||||||
|
RUN python -m venv "$VIRTUAL_ENV"
|
||||||
|
|
||||||
|
COPY pyproject.toml poetry.lock ./
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir poetry \
|
||||||
|
&& poetry config virtualenvs.create false \
|
||||||
|
&& poetry install --no-root --no-interaction --only main \
|
||||||
|
&& pip uninstall -y poetry poetry-core poetry-plugin-export
|
||||||
|
|
||||||
# --- Stage 2: Python Backend prod ---
|
# --- Stage 2: Python Backend prod ---
|
||||||
|
|
||||||
FROM python:3.14-slim AS prod
|
FROM python:3.14-slim AS prod
|
||||||
@@ -23,15 +46,16 @@ LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_Di
|
|||||||
|
|
||||||
WORKDIR /home/excel-project
|
WORKDIR /home/excel-project
|
||||||
|
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
COPY pyproject.toml poetry.lock main.py docker/entrypoint.sh alembic.ini ./
|
COPY pyproject.toml poetry.lock main.py docker/entrypoint.sh alembic.ini ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
|
||||||
RUN chmod +x ./entrypoint.sh
|
RUN chmod +x ./entrypoint.sh
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --break-system-packages poetry \
|
|
||||||
&& poetry config virtualenvs.create false \
|
|
||||||
&& poetry install --no-root --no-interaction --only main
|
|
||||||
|
|
||||||
RUN groupadd --gid 1000 appuser \
|
RUN groupadd --gid 1000 appuser \
|
||||||
&& useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser \
|
&& useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser \
|
||||||
&& chown -R appuser:appuser /home/excel-project
|
&& chown -R appuser:appuser /home/excel-project
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
+6
-1
@@ -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]
|
||||||
@@ -48,10 +49,14 @@ omit = [
|
|||||||
"*/database/*",
|
"*/database/*",
|
||||||
"*/errors/*",
|
"*/errors/*",
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"*/docker/*"
|
"*/docker/*",
|
||||||
|
"*/rate_limit.py",
|
||||||
|
"*/logger.py"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
exclude_lines = [
|
exclude_lines = [
|
||||||
"pragma: no cover",
|
"pragma: no cover",
|
||||||
]
|
]
|
||||||
|
[tool.ruff.lint]
|
||||||
|
ignore=["B008"]
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
#redis dir
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
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, client_ip:str) -> None:
|
||||||
|
await self.rate_limit(client_ip)
|
||||||
|
|
||||||
|
|
||||||
|
rate_limiter=RateLimit()
|
||||||
Vendored
+23
@@ -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()
|
||||||
|
|
||||||
@@ -134,7 +134,6 @@ class Seed:
|
|||||||
|
|
||||||
existing = (await session.execute(select(User).limit(1))).scalar_one_or_none()
|
existing = (await session.execute(select(User).limit(1))).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
print("Seed skipped: one user is already exist")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
admin_permission = Permissions(permission="admin")
|
admin_permission = Permissions(permission="admin")
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ class Env(Base):
|
|||||||
DB_HOST:str
|
DB_HOST:str
|
||||||
DB_PORT:str
|
DB_PORT:str
|
||||||
|
|
||||||
|
REDIS_PASSWORD:str
|
||||||
|
REDIS_PORT:int
|
||||||
|
REDIS_HOST:str
|
||||||
|
|
||||||
|
PROD_MODE:bool
|
||||||
|
|
||||||
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,7 +17,12 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|||||||
|
|
||||||
from src.models.configs_read.env import env_settings
|
from src.models.configs_read.env import env_settings
|
||||||
|
|
||||||
engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}")
|
engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}",
|
||||||
|
pool_size=20, # сколько соединений держать открытыми постоянно
|
||||||
|
max_overflow=10, # сколько доп. соединений можно создать при пиковой нагрузке
|
||||||
|
pool_timeout=30, # сколько ждать свободное соединение, прежде чем упасть с ошибкой
|
||||||
|
pool_pre_ping=True, # проверять соединение перед использованием (ловит "протухшие" соединения)
|
||||||
|
)
|
||||||
|
|
||||||
'''remember as a boilerplate, or just cp/pst'''
|
'''remember as a boilerplate, or just cp/pst'''
|
||||||
class Model(DeclarativeBase):
|
class Model(DeclarativeBase):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
|
from src.cache.redis_client import redis_client
|
||||||
from src.database.auth.refresh_tokens import JwtCrudActions
|
from src.database.auth.refresh_tokens import JwtCrudActions
|
||||||
from src.database.users.crud import UsersCrudActions
|
from src.database.users.crud import UsersCrudActions
|
||||||
from src.errors.http_errors.errors import Errors
|
from src.errors.http_errors.errors import Errors
|
||||||
@@ -52,7 +53,12 @@ class CurrentUserService:
|
|||||||
async def get_current_user(self, token:str, *permissions: str)->UserOut:
|
async def get_current_user(self, token:str, *permissions: str)->UserOut:
|
||||||
|
|
||||||
payload= await self.jwt_service.jwt_decode(token)
|
payload= await self.jwt_service.jwt_decode(token)
|
||||||
|
|
||||||
sub=payload.get("sub")
|
sub=payload.get("sub")
|
||||||
|
jti_access=payload.get("jti")
|
||||||
|
|
||||||
|
if jti_access and await redis_client.get(f"revoked_access_token:{jti_access}"):
|
||||||
|
raise self.error.credentials_error(detail="Token has been revoked")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sub=UUID(sub)
|
sub=UUID(sub)
|
||||||
@@ -167,21 +173,34 @@ class CurrentUserService:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def logout(self, refresh_token:str)->bool:
|
async def logout(self, refresh_token:str, access_token:str)->bool:
|
||||||
|
|
||||||
'''decode current refresh token'''
|
'''decode current refresh token'''
|
||||||
payload=await self.jwt_service.jwt_decode(refresh_token)
|
payload_refresh=await self.jwt_service.jwt_decode(refresh_token)
|
||||||
|
|
||||||
if (jti:=payload.get("jti")) is None:
|
'''decode current access token'''
|
||||||
raise self.error.credentials_error(detail="Jwt token is incorrect")
|
payload_access=await self.jwt_service.jwt_decode(access_token)
|
||||||
|
|
||||||
|
|
||||||
|
if (jti_refresh:=payload_refresh.get("jti")) is None:
|
||||||
|
raise self.error.credentials_error(detail="Jwt refresh token is incorrect")
|
||||||
|
|
||||||
|
if (jti_access:=payload_access.get("jti")) is None or (exp_access:=payload_access.get("exp")) is None:
|
||||||
|
raise self.error.credentials_error(detail="Jwt access token is incorrect")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jti=UUID(jti)
|
jti_refresh=UUID(jti_refresh)
|
||||||
|
jti_access=UUID(jti_access)
|
||||||
except (ValueError, TypeError, AttributeError) as e:
|
except (ValueError, TypeError, AttributeError) as e:
|
||||||
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
|
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
|
||||||
|
|
||||||
|
if jti_access and exp_access:
|
||||||
|
exp_datetime = datetime.fromtimestamp(exp_access, tz=UTC)
|
||||||
|
remaining_ttl = max(int((exp_datetime - datetime.now(UTC)).total_seconds()), 1)
|
||||||
|
await redis_client.set(f"revoked_access_token:{jti_access}", "1", ex=remaining_ttl) #revoke tokens and set them to redis until their exp ends
|
||||||
|
|
||||||
'''logout by assigning revoked flag'''
|
'''logout by assigning revoked flag'''
|
||||||
if await self.jwt_db_actions.logout(jti):
|
if await self.jwt_db_actions.logout(jti_refresh):
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
raise self.error.not_found_error(detail="Refresh Token Not Found")
|
raise self.error.not_found_error(detail="Refresh Token Not Found")
|
||||||
|
|||||||
@@ -35,14 +35,17 @@ class JwtService:
|
|||||||
if not (data.get("sub")) or data.get("sub") == "":
|
if not (data.get("sub")) or data.get("sub") == "":
|
||||||
raise self.error.credentials_error(detail="Jwt token is incorrect")
|
raise self.error.credentials_error(detail="Jwt token is incorrect")
|
||||||
|
|
||||||
|
|
||||||
async def create_access_token(self, data:dict)->str:
|
async def create_access_token(self, data:dict)->str:
|
||||||
|
|
||||||
user_info=data.copy()
|
user_info=data.copy()
|
||||||
|
jti=str(uuid4())
|
||||||
|
|
||||||
await self._validate_sub(user_info)
|
await self._validate_sub(user_info)
|
||||||
|
|
||||||
user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
"token_type":"access"})
|
"token_type":"access",
|
||||||
|
"jti":jti})
|
||||||
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM)
|
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Cookie, Depends, Request, Response
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, 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,22 +13,32 @@ 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(),
|
||||||
|
)->dict:
|
||||||
|
|
||||||
|
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
|
||||||
|
|
||||||
|
try:
|
||||||
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)
|
||||||
|
except HTTPException:
|
||||||
|
await rate_limiter.rate_limit(client_ip)
|
||||||
|
raise
|
||||||
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="refresh_token",
|
key="refresh_token",
|
||||||
value=refresh_token,
|
value=refresh_token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=env_settings.PROD_MODE,
|
||||||
samesite="strict",
|
samesite="strict",
|
||||||
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
|
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
|
||||||
)
|
)
|
||||||
@@ -35,7 +46,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)
|
||||||
|
|
||||||
@@ -43,7 +57,7 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st
|
|||||||
key="refresh_token",
|
key="refresh_token",
|
||||||
value=refresh_token,
|
value=refresh_token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=env_settings.PROD_MODE,
|
||||||
samesite="strict",
|
samesite="strict",
|
||||||
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
|
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
|
||||||
)
|
)
|
||||||
@@ -51,10 +65,15 @@ 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(),
|
||||||
|
access_token: str = Depends(oauth2_schema),
|
||||||
|
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, access_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)
|
||||||
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from src.models.configs_read.env import env_settings
|
||||||
from src.service.auth.jwt import HashService, JwtService
|
from src.service.auth.jwt import HashService, JwtService
|
||||||
|
from src.service.users_crud.users_crud import CrudService
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
@@ -12,3 +15,16 @@ async def jwt_service()->JwtService:
|
|||||||
async def hash_service()->HashService:
|
async def hash_service()->HashService:
|
||||||
hash_service=HashService()
|
hash_service=HashService()
|
||||||
return hash_service
|
return hash_service
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def crud_service():
|
||||||
|
test_engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}",
|
||||||
|
pool_size=20,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_timeout=30,
|
||||||
|
pool_pre_ping=True
|
||||||
|
)
|
||||||
|
crud_service = CrudService()
|
||||||
|
crud_service.crud_db_actions.Session = async_sessionmaker(bind=test_engine)
|
||||||
|
yield crud_service
|
||||||
|
await test_engine.dispose()
|
||||||
+40
-2
@@ -4,7 +4,7 @@ import requests_async
|
|||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
|
||||||
class TestAuth:
|
class TestPermissions:
|
||||||
|
|
||||||
async def test_get_access_token_positive(self, target_url:str)->None:
|
async def test_get_access_token_positive(self, target_url:str)->None:
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ class TestAuth:
|
|||||||
|
|
||||||
response = await requests_async.post(f"{target_url}/protected/token")
|
response = await requests_async.post(f"{target_url}/protected/token")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
assert exc_info.value.response.status_code != 403
|
assert exc_info.value.response.status_code != 403
|
||||||
assert exc_info.value.response.status_code != 401
|
assert exc_info.value.response.status_code != 401
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ class TestAuth:
|
|||||||
assert exc_info.value.response.status_code != 403
|
assert exc_info.value.response.status_code != 403
|
||||||
assert exc_info.value.response.status_code != 401
|
assert exc_info.value.response.status_code != 401
|
||||||
|
|
||||||
async def test_get_root_positive(self, target_url:str)->None:
|
async def test_get_root_unauthorized(self, target_url:str)->None:
|
||||||
|
|
||||||
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info:
|
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info:
|
||||||
|
|
||||||
@@ -44,3 +45,40 @@ class TestAuth:
|
|||||||
assert exc_info.value.response.status_code != 403
|
assert exc_info.value.response.status_code != 403
|
||||||
assert exc_info.value.response.status_code != 401
|
assert exc_info.value.response.status_code != 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedis:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("wrong_user_data, expected_status",[
|
||||||
|
pytest.param({"username":"Wrong_user", "password":"Wrong_password"},429,id="Wrong_user_creds")
|
||||||
|
])
|
||||||
|
async def test_rate_limit_positive(self, wrong_user_data:dict, target_url:str, expected_status:int):
|
||||||
|
|
||||||
|
with allure.step("logging with invalid creds"):
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
with pytest.raises(HTTPStatusError) as exc_info:
|
||||||
|
response = await requests_async.post(target_url + "/protected/token", data=wrong_user_data)
|
||||||
|
response.raise_for_status()
|
||||||
|
assert exc_info.value.response.status_code == 401, f"Attempt {i+1} should be 401"
|
||||||
|
|
||||||
|
with allure.step("verify rate_limit works"),pytest.raises(HTTPStatusError) as exc_info:
|
||||||
|
response = await requests_async.post(target_url + "/protected/token", data=wrong_user_data)
|
||||||
|
response.raise_for_status()
|
||||||
|
assert exc_info.value.response.status_code == expected_status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
|
||||||
|
async def test_logout_revokes_access_token(self,test_user_fixture, target_url:str)->None:
|
||||||
|
|
||||||
|
session=test_user_fixture[0]
|
||||||
|
|
||||||
|
with allure.step("logout"):
|
||||||
|
|
||||||
|
response = await session.get(f"{target_url}/protected/logout")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with allure.step("verify token is revoked"), pytest.raises(HTTPStatusError) as exc_info:
|
||||||
|
response = await session.get(f"{target_url}/protected")
|
||||||
|
response.raise_for_status()
|
||||||
|
assert exc_info.value.response.status_code==401
|
||||||
|
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
|
from src.cache.redis_client import RedisClient
|
||||||
from src.service.auth.auth import CurrentUserService
|
from src.service.auth.auth import CurrentUserService
|
||||||
from src.service.users_crud.users_crud import CrudService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def current_user_service()->CurrentUserService:
|
async def current_user_service(monkeypatch):
|
||||||
current_user_service=CurrentUserService()
|
|
||||||
return current_user_service
|
test_redis = RedisClient()
|
||||||
|
monkeypatch.setattr("src.service.auth.auth.redis_client", test_redis)
|
||||||
|
|
||||||
|
service = CurrentUserService()
|
||||||
|
|
||||||
|
yield service
|
||||||
|
await test_redis.aclose()
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def requests(mocker):
|
async def requests(mocker):
|
||||||
fake_request = mocker.MagicMock(spec=Request)
|
fake_request = mocker.MagicMock(spec=Request)
|
||||||
fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"}
|
fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"}
|
||||||
return fake_request
|
return fake_request
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def crud_service()->CrudService:
|
|
||||||
crud_service=CrudService()
|
|
||||||
return crud_service
|
|
||||||
@@ -118,7 +118,8 @@ class TestAuth:
|
|||||||
|
|
||||||
with allure.step("create fake refresh token"):
|
with allure.step("create fake refresh token"):
|
||||||
|
|
||||||
token=await jwt_service.create_refresh_token({"sub":str(uuid4())})
|
refresh_token=await jwt_service.create_refresh_token({"sub":str(uuid4())})
|
||||||
|
access_token=await jwt_service.create_access_token({"sub":str(uuid4)})
|
||||||
|
|
||||||
with allure.step("patching db call functions"):
|
with allure.step("patching db call functions"):
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ class TestAuth:
|
|||||||
|
|
||||||
with allure.step("test logout with fake data"):
|
with allure.step("test logout with fake data"):
|
||||||
|
|
||||||
status=await current_user_service.logout(token[0])
|
status=await current_user_service.logout(refresh_token[0], access_token)
|
||||||
assert status is True
|
assert status is True
|
||||||
|
|
||||||
@pytest.mark.parametrize("jti,db_result, expected_exception, expected_status",[
|
@pytest.mark.parametrize("jti,db_result, expected_exception, expected_status",[
|
||||||
@@ -148,11 +149,11 @@ class TestAuth:
|
|||||||
|
|
||||||
with allure.step("create fake refresh token"):
|
with allure.step("create fake refresh token"):
|
||||||
|
|
||||||
token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
|
refresh_token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
|
||||||
|
access_token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(minutes=30)})
|
||||||
with allure.step("test logout with fake data"), pytest.raises(expected_exception) as exc_info:
|
with allure.step("test logout with fake data"), pytest.raises(expected_exception) as exc_info:
|
||||||
|
|
||||||
await current_user_service.logout(token)
|
await current_user_service.logout(refresh_token, access_token)
|
||||||
|
|
||||||
if expected_exception is HTTPException:
|
if expected_exception is HTTPException:
|
||||||
assert exc_info.value.status_code==expected_status
|
assert exc_info.value.status_code==expected_status
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import pytest_asyncio
|
|
||||||
|
|
||||||
from src.service.users_crud.users_crud import CrudService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def crud_service()->CrudService:
|
|
||||||
crud_service=CrudService()
|
|
||||||
return crud_service
|
|
||||||
Reference in New Issue
Block a user