mantimon-tcg/backend/app/api/users.py
Cal Corum 3ec670753b Fix security and validation issues from code review
Critical fixes:
- Add admin API key authentication for admin endpoints
- Add race condition protection via unique partial index for starter decks
- Make starter deck selection atomic with combined method

Moderate fixes:
- Fix DI pattern violation in validate_deck_endpoint
- Add card ID format validation (regex pattern)
- Add card quantity validation (1-99 range)
- Fix exception chaining with from None (B904)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:16:07 -06:00

267 lines
7.8 KiB
Python

"""User API router for Mantimon TCG.
This module provides endpoints for user profile management:
- Get current user profile
- Update profile (display name, avatar)
- List linked OAuth accounts
- Session management
- Starter deck selection (one-time for new players)
All endpoints require authentication.
Example:
# Get current user
GET /api/users/me
Authorization: Bearer <access_token>
# Update profile
PATCH /api/users/me
{"display_name": "NewName"}
# Select starter deck
POST /api/users/me/starter-deck
{"starter_type": "grass"}
"""
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from app.api.deps import CurrentUser, DbSession, DeckServiceDep
from app.schemas.deck import DeckResponse, StarterDeckSelectRequest, StarterStatusResponse
from app.schemas.user import UserResponse, UserUpdate
from app.services.deck_service import DeckLimitExceededError, StarterAlreadySelectedError
from app.services.token_store import token_store
from app.services.user_service import AccountLinkingError, user_service
router = APIRouter(prefix="/users", tags=["users"])
class LinkedAccountResponse(BaseModel):
"""Response for a linked OAuth account."""
provider: str = Field(..., description="OAuth provider name")
email: str | None = Field(None, description="Email from this provider")
linked_at: str = Field(..., description="When account was linked (ISO format)")
class SessionsResponse(BaseModel):
"""Response for active sessions count."""
active_sessions: int = Field(..., description="Number of active sessions")
@router.get("/me", response_model=UserResponse)
async def get_current_user_profile(
user: CurrentUser,
) -> UserResponse:
"""Get the current user's profile.
Returns:
User profile information.
"""
return UserResponse.model_validate(user)
@router.patch("/me", response_model=UserResponse)
async def update_current_user_profile(
user: CurrentUser,
db: DbSession,
update_data: UserUpdate,
) -> UserResponse:
"""Update the current user's profile.
Only provided fields are updated.
Args:
update_data: Fields to update (display_name, avatar_url).
Returns:
Updated user profile.
"""
updated_user = await user_service.update(db, user, update_data)
return UserResponse.model_validate(updated_user)
@router.get("/me/linked-accounts", response_model=list[LinkedAccountResponse])
async def get_linked_accounts(
user: CurrentUser,
) -> list[LinkedAccountResponse]:
"""Get all OAuth accounts linked to the current user.
Returns the primary OAuth provider plus any additional linked accounts.
Returns:
List of linked OAuth accounts.
"""
accounts = []
# Add primary OAuth account
accounts.append(
LinkedAccountResponse(
provider=user.oauth_provider,
email=user.email,
linked_at=user.created_at.isoformat(),
)
)
# Add additional linked accounts
for linked in user.linked_accounts:
accounts.append(
LinkedAccountResponse(
provider=linked.provider,
email=linked.email,
linked_at=linked.linked_at.isoformat(),
)
)
return accounts
@router.get("/me/sessions", response_model=SessionsResponse)
async def get_active_sessions(
user: CurrentUser,
) -> SessionsResponse:
"""Get the number of active sessions for the current user.
Each session corresponds to a valid refresh token.
Returns:
Number of active sessions.
"""
from uuid import UUID
user_id = UUID(user.id) if isinstance(user.id, str) else user.id
count = await token_store.get_active_session_count(user_id)
return SessionsResponse(active_sessions=count)
@router.delete("/me/link/{provider}", status_code=status.HTTP_204_NO_CONTENT)
async def unlink_oauth_account(
user: CurrentUser,
db: DbSession,
provider: str,
) -> None:
"""Unlink an OAuth provider from the current user's account.
Cannot unlink the primary OAuth provider (the one used to create the account).
Args:
provider: OAuth provider name to unlink ('google' or 'discord').
Raises:
HTTPException: 400 if trying to unlink primary provider.
HTTPException: 404 if provider is not linked.
"""
provider = provider.lower()
if provider not in ("google", "discord"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown provider: {provider}",
)
try:
unlinked = await user_service.unlink_oauth_account(db, user, provider)
if not unlinked:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{provider.title()} is not linked to your account",
)
except AccountLinkingError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
# =============================================================================
# Starter Deck Endpoints
# =============================================================================
@router.get("/me/starter-status", response_model=StarterStatusResponse)
async def get_starter_status(
user: CurrentUser,
deck_service: DeckServiceDep,
) -> StarterStatusResponse:
"""Check if user has already selected a starter deck.
New users need to select a starter deck before playing.
This endpoint checks if they've already made that selection.
Returns:
StarterStatusResponse with has_starter flag and starter_type.
"""
has_starter, starter_type = await deck_service.has_starter_deck(user.id)
return StarterStatusResponse(
has_starter=has_starter,
starter_type=starter_type,
)
@router.post("/me/starter-deck", response_model=DeckResponse, status_code=status.HTTP_201_CREATED)
async def select_starter_deck(
request: StarterDeckSelectRequest,
user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Select and create a starter deck for a new user.
This is a one-time operation that atomically:
1. Validates the starter type
2. Creates the starter deck (protected by unique constraint)
3. Grants all starter deck cards to the user's collection
Race conditions are handled by a database unique constraint that prevents
multiple starter decks per user.
Available starter types: grass, fire, water, psychic, lightning
Args:
request: Starter deck selection with type and optional config.
Returns:
The created starter deck.
Raises:
400: If starter already selected or invalid starter_type.
"""
try:
deck = await deck_service.select_and_grant_starter_deck(
user_id=user.id,
starter_type=request.starter_type,
deck_config=request.deck_config,
max_decks=user.max_decks,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
except StarterAlreadySelectedError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
except DeckLimitExceededError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)