Fix security
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
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
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user