Fix security
This commit is contained in:
@@ -10,6 +10,7 @@ OPENAI_API_KEY=sk-...
|
|||||||
|
|
||||||
# Telegram Bot
|
# Telegram Bot
|
||||||
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
||||||
|
BOT_API_SECRET=change_me_random_secret_for_bot_api
|
||||||
|
|
||||||
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
|
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
|
||||||
S3_ENABLED=false
|
S3_ENABLED=false
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status, Header
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import decode_access_token
|
from app.core.security import decode_access_token
|
||||||
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
|
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
|
||||||
@@ -145,3 +146,21 @@ async def require_creator(
|
|||||||
# Type aliases for cleaner dependency injection
|
# Type aliases for cleaner dependency injection
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_bot_secret(
|
||||||
|
x_bot_secret: str | None = Header(None, alias="X-Bot-Secret")
|
||||||
|
) -> None:
|
||||||
|
"""Verify that request comes from trusted bot using secret key."""
|
||||||
|
if not settings.BOT_API_SECRET:
|
||||||
|
# If secret is not configured, skip check (for development)
|
||||||
|
return
|
||||||
|
|
||||||
|
if x_bot_secret != settings.BOT_API_SECRET:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid or missing bot secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BotSecretDep = Annotated[None, Depends(verify_bot_secret)]
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic
|
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse)
|
@router.post("/register", response_model=TokenResponse)
|
||||||
async def register(data: UserRegister, db: DbSession):
|
@limiter.limit("5/minute")
|
||||||
|
async def register(request: Request, data: UserRegister, db: DbSession):
|
||||||
# Check if login already exists
|
# Check if login already exists
|
||||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||||
if result.scalar_one_or_none():
|
if result.scalar_one_or_none():
|
||||||
@@ -34,12 +36,13 @@ async def register(data: UserRegister, db: DbSession):
|
|||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
user=UserPublic.model_validate(user),
|
user=UserPrivate.model_validate(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(data: UserLogin, db: DbSession):
|
@limiter.limit("10/minute")
|
||||||
|
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||||
# Find user
|
# Find user
|
||||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -55,10 +58,11 @@ async def login(data: UserLogin, db: DbSession):
|
|||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
user=UserPublic.model_validate(user),
|
user=UserPrivate.model_validate(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserPublic)
|
@router.get("/me", response_model=UserPrivate)
|
||||||
async def get_me(current_user: CurrentUser):
|
async def get_me(current_user: CurrentUser):
|
||||||
return UserPublic.model_validate(current_user)
|
"""Get current user's full profile (including private data)"""
|
||||||
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@ from app.api.deps import (
|
|||||||
require_participant, require_organizer, require_creator,
|
require_participant, require_organizer, require_creator,
|
||||||
get_participant,
|
get_participant,
|
||||||
)
|
)
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
|
||||||
|
# Optional auth for endpoints that need it conditionally
|
||||||
|
optional_auth = HTTPBearer(auto_error=False)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||||
@@ -188,6 +193,15 @@ async def create_marathon(
|
|||||||
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
marathon = await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# For private marathons, require participation (or admin/creator)
|
||||||
|
if not marathon.is_public and not current_user.is_admin:
|
||||||
|
participation = await get_participation(db, current_user.id, marathon_id)
|
||||||
|
if not participation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this private marathon",
|
||||||
|
)
|
||||||
|
|
||||||
# Count participants and approved games
|
# Count participants and approved games
|
||||||
participants_count = await db.scalar(
|
participants_count = await db.scalar(
|
||||||
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
||||||
@@ -428,7 +442,16 @@ async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db:
|
|||||||
|
|
||||||
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
||||||
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# For private marathons, require participation (or admin)
|
||||||
|
if not marathon.is_public and not current_user.is_admin:
|
||||||
|
participation = await get_participation(db, current_user.id, marathon_id)
|
||||||
|
if not participation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this private marathon",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
@@ -497,8 +520,42 @@ async def set_participant_role(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
||||||
async def get_leaderboard(marathon_id: int, db: DbSession):
|
async def get_leaderboard(
|
||||||
await get_marathon_or_404(db, marathon_id)
|
marathon_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get marathon leaderboard.
|
||||||
|
Public marathons: no auth required.
|
||||||
|
Private marathons: requires auth + participation check.
|
||||||
|
"""
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# For private marathons, require authentication and participation
|
||||||
|
if not marathon.is_public:
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required for private marathon leaderboard",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = int(payload.get("sub"))
|
||||||
|
participant = await get_participant(db, user_id, marathon_id)
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this marathon",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser, BotSecretDep
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_telegram_link_token, verify_telegram_link_token
|
from app.core.security import create_telegram_link_token, verify_telegram_link_token
|
||||||
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
|
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
|
||||||
@@ -94,7 +94,7 @@ async def generate_link_token(current_user: CurrentUser):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/confirm-link", response_model=TelegramLinkResponse)
|
@router.post("/confirm-link", response_model=TelegramLinkResponse)
|
||||||
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
|
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
|
||||||
"""Confirm Telegram account linking (called by bot)."""
|
"""Confirm Telegram account linking (called by bot)."""
|
||||||
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
|
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
|
||||||
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
|
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
|
||||||
@@ -145,7 +145,7 @@ async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
|
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
|
||||||
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
|
async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Get user by Telegram ID."""
|
"""Get user by Telegram ID."""
|
||||||
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
|
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
|
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
|
||||||
async def unlink_telegram(telegram_id: int, db: DbSession):
|
async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Unlink Telegram account."""
|
"""Unlink Telegram account."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.telegram_id == telegram_id)
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
@@ -187,7 +187,7 @@ async def unlink_telegram(telegram_id: int, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
|
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
|
||||||
async def get_user_marathons(telegram_id: int, db: DbSession):
|
async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Get user's marathons by Telegram ID."""
|
"""Get user's marathons by Telegram ID."""
|
||||||
# Get user
|
# Get user
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -231,7 +231,7 @@ async def get_user_marathons(telegram_id: int, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
|
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
|
||||||
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
|
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Get marathon details for user by Telegram ID."""
|
"""Get marathon details for user by Telegram ID."""
|
||||||
# Get user
|
# Get user
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -341,7 +341,7 @@ async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
|
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
|
||||||
async def get_user_stats(telegram_id: int, db: DbSession):
|
async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Get user's overall statistics by Telegram ID."""
|
"""Get user's overall statistics by Telegram ID."""
|
||||||
# Get user
|
# Get user
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.models import User, Participant, Assignment, Marathon
|
|||||||
from app.models.assignment import AssignmentStatus
|
from app.models.assignment import AssignmentStatus
|
||||||
from app.models.marathon import MarathonStatus
|
from app.models.marathon import MarathonStatus
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
UserPublic, UserUpdate, TelegramLink, MessageResponse,
|
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
||||||
PasswordChange, UserStats, UserProfilePublic,
|
PasswordChange, UserStats, UserProfilePublic,
|
||||||
)
|
)
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
@@ -17,7 +17,8 @@ router = APIRouter(prefix="/users", tags=["users"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserPublic)
|
@router.get("/{user_id}", response_model=UserPublic)
|
||||||
async def get_user(user_id: int, db: DbSession):
|
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
|
"""Get user profile. Requires authentication."""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -58,23 +59,25 @@ async def get_user_avatar(user_id: int, db: DbSession):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me", response_model=UserPublic)
|
@router.patch("/me", response_model=UserPrivate)
|
||||||
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
|
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Update current user's profile"""
|
||||||
if data.nickname is not None:
|
if data.nickname is not None:
|
||||||
current_user.nickname = data.nickname
|
current_user.nickname = data.nickname
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
return UserPublic.model_validate(current_user)
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/avatar", response_model=UserPublic)
|
@router.post("/me/avatar", response_model=UserPrivate)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
):
|
):
|
||||||
|
"""Upload current user's avatar"""
|
||||||
# Validate file
|
# Validate file
|
||||||
if not file.content_type.startswith("image/"):
|
if not file.content_type.startswith("image/"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -115,7 +118,7 @@ async def upload_avatar(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
return UserPublic.model_validate(current_user)
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/telegram", response_model=MessageResponse)
|
@router.post("/me/telegram", response_model=MessageResponse)
|
||||||
@@ -193,8 +196,8 @@ async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}/stats", response_model=UserStats)
|
@router.get("/{user_id}/stats", response_model=UserStats)
|
||||||
async def get_user_stats(user_id: int, db: DbSession):
|
async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
"""Получить статистику пользователя"""
|
"""Получить статистику пользователя. Requires authentication."""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -207,8 +210,8 @@ async def get_user_stats(user_id: int, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
||||||
async def get_user_profile(user_id: int, db: DbSession):
|
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
"""Получить публичный профиль пользователя со статистикой"""
|
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class Settings(BaseSettings):
|
|||||||
TELEGRAM_BOT_TOKEN: str = ""
|
TELEGRAM_BOT_TOKEN: str = ""
|
||||||
TELEGRAM_BOT_USERNAME: str = ""
|
TELEGRAM_BOT_USERNAME: str = ""
|
||||||
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
||||||
|
BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_URL: str = "http://localhost:3000"
|
FRONTEND_URL: str = "http://localhost:3000"
|
||||||
|
|||||||
5
backend/app/core/rate_limit.py
Normal file
5
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
# Rate limiter using client IP address as key
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -14,6 +17,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import engine, Base, async_session_maker
|
from app.core.database import engine, Base, async_session_maker
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
from app.api.v1 import router as api_router
|
from app.api.v1 import router as api_router
|
||||||
from app.services.event_scheduler import event_scheduler
|
from app.services.event_scheduler import event_scheduler
|
||||||
from app.services.dispute_scheduler import dispute_scheduler
|
from app.services.dispute_scheduler import dispute_scheduler
|
||||||
@@ -49,6 +53,10 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from app.schemas.user import (
|
|||||||
UserLogin,
|
UserLogin,
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
UserPublic,
|
UserPublic,
|
||||||
UserWithTelegram,
|
UserPrivate,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
TelegramLink,
|
TelegramLink,
|
||||||
PasswordChange,
|
PasswordChange,
|
||||||
@@ -88,7 +88,7 @@ __all__ = [
|
|||||||
"UserLogin",
|
"UserLogin",
|
||||||
"UserUpdate",
|
"UserUpdate",
|
||||||
"UserPublic",
|
"UserPublic",
|
||||||
"UserWithTelegram",
|
"UserPrivate",
|
||||||
"TokenResponse",
|
"TokenResponse",
|
||||||
"TelegramLink",
|
"TelegramLink",
|
||||||
"PasswordChange",
|
"PasswordChange",
|
||||||
|
|||||||
@@ -29,30 +29,30 @@ class UserUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserPublic(UserBase):
|
class UserPublic(UserBase):
|
||||||
|
"""Public user info visible to other users - minimal data"""
|
||||||
id: int
|
id: int
|
||||||
login: str
|
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
role: str = "user"
|
role: str = "user"
|
||||||
telegram_id: int | None = None
|
telegram_avatar_url: str | None = None # Only TG avatar is public
|
||||||
telegram_username: str | None = None
|
|
||||||
telegram_first_name: str | None = None
|
|
||||||
telegram_last_name: str | None = None
|
|
||||||
telegram_avatar_url: str | None = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class UserWithTelegram(UserPublic):
|
class UserPrivate(UserPublic):
|
||||||
|
"""Full user info visible only to the user themselves"""
|
||||||
|
login: str
|
||||||
telegram_id: int | None = None
|
telegram_id: int | None = None
|
||||||
telegram_username: str | None = None
|
telegram_username: str | None = None
|
||||||
|
telegram_first_name: str | None = None
|
||||||
|
telegram_last_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
user: UserPublic
|
user: UserPrivate
|
||||||
|
|
||||||
|
|
||||||
class TelegramLink(BaseModel):
|
class TelegramLink(BaseModel):
|
||||||
|
|||||||
@@ -31,5 +31,8 @@ python-magic==0.4.27
|
|||||||
# S3 Storage
|
# S3 Storage
|
||||||
boto3==1.34.0
|
boto3==1.34.0
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
slowapi==0.1.9
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class Settings(BaseSettings):
|
|||||||
TELEGRAM_BOT_TOKEN: str
|
TELEGRAM_BOT_TOKEN: str
|
||||||
API_URL: str = "http://backend:8000"
|
API_URL: str = "http://backend:8000"
|
||||||
BOT_USERNAME: str = "" # Will be set dynamically on startup
|
BOT_USERNAME: str = "" # Will be set dynamically on startup
|
||||||
|
BOT_API_SECRET: str = "" # Secret for backend API communication
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class APIClient:
|
|||||||
session = await self._get_session()
|
session = await self._get_session()
|
||||||
url = f"{self.base_url}/api/v1{endpoint}"
|
url = f"{self.base_url}/api/v1{endpoint}"
|
||||||
|
|
||||||
|
# Add bot secret header for authentication
|
||||||
|
headers = kwargs.pop("headers", {})
|
||||||
|
if settings.BOT_API_SECRET:
|
||||||
|
headers["X-Bot-Secret"] = settings.BOT_API_SECRET
|
||||||
|
|
||||||
logger.info(f"[APIClient] {method} {url}")
|
logger.info(f"[APIClient] {method} {url}")
|
||||||
if 'json' in kwargs:
|
if 'json' in kwargs:
|
||||||
logger.info(f"[APIClient] Request body: {kwargs['json']}")
|
logger.info(f"[APIClient] Request body: {kwargs['json']}")
|
||||||
@@ -39,7 +44,7 @@ class APIClient:
|
|||||||
logger.info(f"[APIClient] Request params: {kwargs['params']}")
|
logger.info(f"[APIClient] Request params: {kwargs['params']}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.request(method, url, **kwargs) as response:
|
async with session.request(method, url, headers=headers, **kwargs) as response:
|
||||||
logger.info(f"[APIClient] Response status: {response.status}")
|
logger.info(f"[APIClient] Response status: {response.status}")
|
||||||
response_text = await response.text()
|
response_text = await response.text()
|
||||||
logger.info(f"[APIClient] Response body: {response_text[:500]}")
|
logger.info(f"[APIClient] Response body: {response_text[:500]}")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
|
||||||
|
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
# S3 Storage
|
# S3 Storage
|
||||||
S3_ENABLED: ${S3_ENABLED:-false}
|
S3_ENABLED: ${S3_ENABLED:-false}
|
||||||
@@ -81,6 +82,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
- API_URL=http://backend:8000
|
- API_URL=http://backend:8000
|
||||||
|
- BOT_API_SECRET=${BOT_API_SECRET:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
// User types
|
// User types
|
||||||
export type UserRole = 'user' | 'admin'
|
export type UserRole = 'user' | 'admin'
|
||||||
|
|
||||||
export interface User {
|
// Public user info (visible to other users)
|
||||||
|
export interface UserPublic {
|
||||||
id: number
|
id: number
|
||||||
login: string
|
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
role: UserRole
|
role: UserRole
|
||||||
telegram_id: number | null
|
|
||||||
telegram_username: string | null
|
|
||||||
telegram_first_name: string | null
|
|
||||||
telegram_last_name: string | null
|
|
||||||
telegram_avatar_url: string | null
|
telegram_avatar_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full user info (only for own profile from /auth/me)
|
||||||
|
export interface User extends UserPublic {
|
||||||
|
login?: string // Only visible to self
|
||||||
|
telegram_id?: number | null // Only visible to self
|
||||||
|
telegram_username?: string | null // Only visible to self
|
||||||
|
telegram_first_name?: string | null // Only visible to self
|
||||||
|
telegram_last_name?: string | null // Only visible to self
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
token_type: string
|
token_type: string
|
||||||
|
|||||||
20
nginx.conf
20
nginx.conf
@@ -17,6 +17,10 @@ http {
|
|||||||
# File upload limit (15 MB)
|
# File upload limit (15 MB)
|
||||||
client_max_body_size 15M;
|
client_max_body_size 15M;
|
||||||
|
|
||||||
|
# Rate limiting zones
|
||||||
|
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
|
||||||
|
|
||||||
upstream backend {
|
upstream backend {
|
||||||
server backend:8000;
|
server backend:8000;
|
||||||
}
|
}
|
||||||
@@ -37,8 +41,22 @@ http {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Backend API
|
# Auth API - strict rate limit (10 req/min with burst of 5)
|
||||||
|
location /api/v1/auth {
|
||||||
|
limit_req zone=api_auth burst=5 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API - general rate limit (60 req/min with burst of 20)
|
||||||
location /api {
|
location /api {
|
||||||
|
limit_req zone=api_general burst=20 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
Reference in New Issue
Block a user