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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user