diff --git a/.env.example b/.env.example index 0d9095b..af8ba84 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ OPENAI_API_KEY=sk-... # Telegram Bot 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_ENABLED=false diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 7a330a2..bded2ac 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,10 +1,11 @@ from typing import Annotated -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import settings from app.core.database import get_db from app.core.security import decode_access_token from app.models import User, Participant, Marathon, UserRole, ParticipantRole @@ -145,3 +146,21 @@ async def require_creator( # Type aliases for cleaner dependency injection CurrentUser = Annotated[User, Depends(get_current_user)] 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)] diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index b420286..f3147b4 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,16 +1,18 @@ -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, HTTPException, status, Request from sqlalchemy import select from app.api.deps import DbSession, CurrentUser 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.schemas import UserRegister, UserLogin, TokenResponse, UserPublic +from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate router = APIRouter(prefix="/auth", tags=["auth"]) @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 result = await db.execute(select(User).where(User.login == data.login.lower())) if result.scalar_one_or_none(): @@ -34,12 +36,13 @@ async def register(data: UserRegister, db: DbSession): return TokenResponse( access_token=access_token, - user=UserPublic.model_validate(user), + user=UserPrivate.model_validate(user), ) @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 result = await db.execute(select(User).where(User.login == data.login.lower())) user = result.scalar_one_or_none() @@ -55,10 +58,11 @@ async def login(data: UserLogin, db: DbSession): return TokenResponse( 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): - return UserPublic.model_validate(current_user) + """Get current user's full profile (including private data)""" + return UserPrivate.model_validate(current_user) diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index a04bbcb..faead45 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -1,7 +1,8 @@ from datetime import timedelta import secrets 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.orm import selectinload @@ -10,6 +11,10 @@ from app.api.deps import ( require_participant, require_organizer, require_creator, 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 ( Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge, 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): 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 participants_count = await db.scalar( 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]) 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( select(Participant) @@ -497,8 +520,42 @@ async def set_participant_role( @router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry]) -async def get_leaderboard(marathon_id: int, db: DbSession): - await get_marathon_or_404(db, marathon_id) +async def get_leaderboard( + 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( select(Participant) diff --git a/backend/app/api/v1/telegram.py b/backend/app/api/v1/telegram.py index aa70ced..48d24e7 100644 --- a/backend/app/api/v1/telegram.py +++ b/backend/app/api/v1/telegram.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from sqlalchemy import select, func 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.security import create_telegram_link_token, verify_telegram_link_token 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) -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).""" logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========") 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) -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.""" 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) -async def unlink_telegram(telegram_id: int, db: DbSession): +async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep): """Unlink Telegram account.""" result = await db.execute( 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]) -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 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) -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 user 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) -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 result = await db.execute( diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 852d49a..64186ce 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -8,7 +8,7 @@ from app.models import User, Participant, Assignment, Marathon from app.models.assignment import AssignmentStatus from app.models.marathon import MarathonStatus from app.schemas import ( - UserPublic, UserUpdate, TelegramLink, MessageResponse, + UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse, PasswordChange, UserStats, UserProfilePublic, ) from app.services.storage import storage_service @@ -17,7 +17,8 @@ router = APIRouter(prefix="/users", tags=["users"]) @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)) 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): + """Update current user's profile""" if data.nickname is not None: current_user.nickname = data.nickname await db.commit() 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( current_user: CurrentUser, db: DbSession, file: UploadFile = File(...), ): + """Upload current user's avatar""" # Validate file if not file.content_type.startswith("image/"): raise HTTPException( @@ -115,7 +118,7 @@ async def upload_avatar( await db.commit() await db.refresh(current_user) - return UserPublic.model_validate(current_user) + return UserPrivate.model_validate(current_user) @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) -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)) user = result.scalar_one_or_none() 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) -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)) user = result.scalar_one_or_none() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1147a5a..1a12cb4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -22,6 +22,7 @@ class Settings(BaseSettings): TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_BOT_USERNAME: str = "" TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10 + BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication # Frontend FRONTEND_URL: str = "http://localhost:3000" diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..8bf13d7 --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index b9737e7..4c92886 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,10 @@ import logging 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 logging.basicConfig( @@ -14,6 +17,7 @@ from pathlib import Path from app.core.config import settings 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.services.event_scheduler import event_scheduler from app.services.dispute_scheduler import dispute_scheduler @@ -49,6 +53,10 @@ app = FastAPI( lifespan=lifespan, ) +# Rate limiting +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + # CORS app.add_middleware( CORSMiddleware, diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index b84875d..295b2c6 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -3,7 +3,7 @@ from app.schemas.user import ( UserLogin, UserUpdate, UserPublic, - UserWithTelegram, + UserPrivate, TokenResponse, TelegramLink, PasswordChange, @@ -88,7 +88,7 @@ __all__ = [ "UserLogin", "UserUpdate", "UserPublic", - "UserWithTelegram", + "UserPrivate", "TokenResponse", "TelegramLink", "PasswordChange", diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index bf54d33..62e866d 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -29,30 +29,30 @@ class UserUpdate(BaseModel): class UserPublic(UserBase): + """Public user info visible to other users - minimal data""" id: int - login: str avatar_url: str | None = None role: str = "user" - telegram_id: int | None = None - telegram_username: str | None = None - telegram_first_name: str | None = None - telegram_last_name: str | None = None - telegram_avatar_url: str | None = None + telegram_avatar_url: str | None = None # Only TG avatar is public created_at: datetime class Config: 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_username: str | None = None + telegram_first_name: str | None = None + telegram_last_name: str | None = None class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" - user: UserPublic + user: UserPrivate class TelegramLink(BaseModel): diff --git a/backend/requirements.txt b/backend/requirements.txt index 7bc9357..7f3bf10 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -31,5 +31,8 @@ python-magic==0.4.27 # S3 Storage boto3==1.34.0 +# Rate limiting +slowapi==0.1.9 + # Utils python-dotenv==1.0.0 diff --git a/bot/config.py b/bot/config.py index ef8a17b..33caf30 100644 --- a/bot/config.py +++ b/bot/config.py @@ -5,6 +5,7 @@ class Settings(BaseSettings): TELEGRAM_BOT_TOKEN: str API_URL: str = "http://backend:8000" BOT_USERNAME: str = "" # Will be set dynamically on startup + BOT_API_SECRET: str = "" # Secret for backend API communication class Config: env_file = ".env" diff --git a/bot/services/api_client.py b/bot/services/api_client.py index e86ae07..7f37e84 100644 --- a/bot/services/api_client.py +++ b/bot/services/api_client.py @@ -32,6 +32,11 @@ class APIClient: session = await self._get_session() 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}") if 'json' in kwargs: logger.info(f"[APIClient] Request body: {kwargs['json']}") @@ -39,7 +44,7 @@ class APIClient: logger.info(f"[APIClient] Request params: {kwargs['params']}") 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}") response_text = await response.text() logger.info(f"[APIClient] Response body: {response_text[:500]}") diff --git a/docker-compose.yml b/docker-compose.yml index e5d98f7..7779dcc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot} + BOT_API_SECRET: ${BOT_API_SECRET:-} DEBUG: ${DEBUG:-false} # S3 Storage S3_ENABLED: ${S3_ENABLED:-false} @@ -81,6 +82,7 @@ services: environment: - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - API_URL=http://backend:8000 + - BOT_API_SECRET=${BOT_API_SECRET:-} depends_on: - backend restart: unless-stopped diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3d72680..aa016e6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,20 +1,25 @@ // User types export type UserRole = 'user' | 'admin' -export interface User { +// Public user info (visible to other users) +export interface UserPublic { id: number - login: string nickname: string avatar_url: string | null 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 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 { access_token: string token_type: string diff --git a/nginx.conf b/nginx.conf index 33f5b20..ddde8b7 100644 --- a/nginx.conf +++ b/nginx.conf @@ -17,6 +17,10 @@ http { # File upload limit (15 MB) 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 { server backend:8000; } @@ -37,8 +41,22 @@ http { 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 { + limit_req zone=api_general burst=20 nodelay; + limit_req_status 429; + proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr;