Complete OAuth-based authentication with JWT session management:
Core Services:
- JWT service for access/refresh token creation and verification
- Token store with Redis-backed refresh token revocation
- User service for CRUD operations and OAuth-based creation
- Google and Discord OAuth services with full flow support
API Endpoints:
- GET /api/auth/{google,discord} - Start OAuth flows
- GET /api/auth/{google,discord}/callback - Handle OAuth callbacks
- POST /api/auth/refresh - Exchange refresh token for new access token
- POST /api/auth/logout - Revoke single refresh token
- POST /api/auth/logout-all - Revoke all user sessions
- GET/PATCH /api/users/me - User profile management
- GET /api/users/me/linked-accounts - List OAuth providers
- GET /api/users/me/sessions - Count active sessions
Infrastructure:
- Pydantic schemas for auth/user request/response models
- FastAPI dependencies (get_current_user, get_current_premium_user)
- OAuthLinkedAccount model for multi-provider support
- Alembic migration for oauth_linked_accounts table
Dependencies added: email-validator, fakeredis (dev), respx (dev)
84 new tests, 1058 total passing
130 lines
3.4 KiB
Python
130 lines
3.4 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
|
|
|
|
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"}
|
|
"""
|
|
|
|
from fastapi import APIRouter
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.api.deps import CurrentUser, DbSession
|
|
from app.schemas.user import UserResponse, UserUpdate
|
|
from app.services.token_store import token_store
|
|
from app.services.user_service import 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)
|