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
310 lines
9.1 KiB
Python
310 lines
9.1 KiB
Python
"""User service for Mantimon TCG.
|
|
|
|
This module provides async CRUD operations for user accounts,
|
|
including OAuth-based user creation and premium status management.
|
|
|
|
All database operations use async SQLAlchemy sessions.
|
|
|
|
Example:
|
|
from app.services.user_service import user_service
|
|
|
|
# Get user by ID
|
|
user = await user_service.get_by_id(db, user_id)
|
|
|
|
# Create from OAuth
|
|
user = await user_service.create_from_oauth(db, oauth_info)
|
|
|
|
# Update premium status
|
|
user = await user_service.update_premium(db, user_id, premium_until)
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models.user import User
|
|
from app.schemas.user import OAuthUserInfo, UserCreate, UserUpdate
|
|
|
|
|
|
class UserService:
|
|
"""Service for user account operations.
|
|
|
|
Provides async methods for user CRUD, OAuth-based creation,
|
|
and premium subscription management.
|
|
"""
|
|
|
|
async def get_by_id(self, db: AsyncSession, user_id: UUID) -> User | None:
|
|
"""Get a user by their ID.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
user_id: The user's UUID.
|
|
|
|
Returns:
|
|
User if found, None otherwise.
|
|
|
|
Example:
|
|
user = await user_service.get_by_id(db, user_id)
|
|
if user:
|
|
print(f"Found user: {user.display_name}")
|
|
"""
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
return result.scalar_one_or_none()
|
|
|
|
async def get_by_email(self, db: AsyncSession, email: str) -> User | None:
|
|
"""Get a user by their email address.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
email: The user's email address.
|
|
|
|
Returns:
|
|
User if found, None otherwise.
|
|
|
|
Example:
|
|
user = await user_service.get_by_email(db, "player@example.com")
|
|
"""
|
|
result = await db.execute(select(User).where(User.email == email))
|
|
return result.scalar_one_or_none()
|
|
|
|
async def get_by_oauth(
|
|
self,
|
|
db: AsyncSession,
|
|
provider: str,
|
|
oauth_id: str,
|
|
) -> User | None:
|
|
"""Get a user by their OAuth provider and ID.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
provider: OAuth provider name (google, discord).
|
|
oauth_id: Unique ID from the OAuth provider.
|
|
|
|
Returns:
|
|
User if found, None otherwise.
|
|
|
|
Example:
|
|
user = await user_service.get_by_oauth(db, "google", "123456789")
|
|
"""
|
|
result = await db.execute(
|
|
select(User).where(
|
|
User.oauth_provider == provider,
|
|
User.oauth_id == oauth_id,
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def create(self, db: AsyncSession, user_data: UserCreate) -> User:
|
|
"""Create a new user.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
user_data: User creation data.
|
|
|
|
Returns:
|
|
The created User instance.
|
|
|
|
Example:
|
|
user_data = UserCreate(
|
|
email="player@example.com",
|
|
display_name="Player1",
|
|
oauth_provider="google",
|
|
oauth_id="123456789"
|
|
)
|
|
user = await user_service.create(db, user_data)
|
|
"""
|
|
user = User(
|
|
email=user_data.email,
|
|
display_name=user_data.display_name,
|
|
avatar_url=user_data.avatar_url,
|
|
oauth_provider=user_data.oauth_provider,
|
|
oauth_id=user_data.oauth_id,
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
async def create_from_oauth(
|
|
self,
|
|
db: AsyncSession,
|
|
oauth_info: OAuthUserInfo,
|
|
) -> User:
|
|
"""Create a new user from OAuth provider info.
|
|
|
|
Convenience method that converts OAuthUserInfo to UserCreate.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
oauth_info: Normalized OAuth user information.
|
|
|
|
Returns:
|
|
The created User instance.
|
|
|
|
Example:
|
|
oauth_info = OAuthUserInfo(
|
|
provider="google",
|
|
oauth_id="123456789",
|
|
email="player@example.com",
|
|
name="Player One",
|
|
avatar_url="https://..."
|
|
)
|
|
user = await user_service.create_from_oauth(db, oauth_info)
|
|
"""
|
|
user_data = oauth_info.to_user_create()
|
|
return await self.create(db, user_data)
|
|
|
|
async def get_or_create_from_oauth(
|
|
self,
|
|
db: AsyncSession,
|
|
oauth_info: OAuthUserInfo,
|
|
) -> tuple[User, bool]:
|
|
"""Get existing user or create new one from OAuth info.
|
|
|
|
First checks for existing user by OAuth provider+ID, then by email
|
|
(for account linking), and finally creates a new user if not found.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
oauth_info: Normalized OAuth user information.
|
|
|
|
Returns:
|
|
Tuple of (User, created) where created is True if new user.
|
|
|
|
Example:
|
|
user, created = await user_service.get_or_create_from_oauth(db, oauth_info)
|
|
if created:
|
|
print("Welcome, new user!")
|
|
else:
|
|
print("Welcome back!")
|
|
"""
|
|
# First, check by OAuth provider + ID (exact match)
|
|
user = await self.get_by_oauth(db, oauth_info.provider, oauth_info.oauth_id)
|
|
if user:
|
|
return user, False
|
|
|
|
# Check by email for potential account linking
|
|
# If user exists with same email but different OAuth, update their OAuth
|
|
user = await self.get_by_email(db, oauth_info.email)
|
|
if user:
|
|
# Update OAuth credentials for existing user
|
|
# This links the new OAuth provider to the existing account
|
|
user.oauth_provider = oauth_info.provider
|
|
user.oauth_id = oauth_info.oauth_id
|
|
# Optionally update avatar if not set
|
|
if not user.avatar_url and oauth_info.avatar_url:
|
|
user.avatar_url = oauth_info.avatar_url
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user, False
|
|
|
|
# Create new user
|
|
user = await self.create_from_oauth(db, oauth_info)
|
|
return user, True
|
|
|
|
async def update(
|
|
self,
|
|
db: AsyncSession,
|
|
user: User,
|
|
update_data: UserUpdate,
|
|
) -> User:
|
|
"""Update user profile fields.
|
|
|
|
Only updates fields that are provided (not None).
|
|
|
|
Args:
|
|
db: Async database session.
|
|
user: The user to update.
|
|
update_data: Fields to update.
|
|
|
|
Returns:
|
|
The updated User instance.
|
|
|
|
Example:
|
|
update_data = UserUpdate(display_name="New Name")
|
|
user = await user_service.update(db, user, update_data)
|
|
"""
|
|
if update_data.display_name is not None:
|
|
user.display_name = update_data.display_name
|
|
if update_data.avatar_url is not None:
|
|
user.avatar_url = update_data.avatar_url
|
|
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
async def update_last_login(self, db: AsyncSession, user: User) -> User:
|
|
"""Update the user's last login timestamp.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
user: The user to update.
|
|
|
|
Returns:
|
|
The updated User instance.
|
|
|
|
Example:
|
|
user = await user_service.update_last_login(db, user)
|
|
"""
|
|
user.last_login = datetime.now(UTC)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
async def update_premium(
|
|
self,
|
|
db: AsyncSession,
|
|
user: User,
|
|
premium_until: datetime | None,
|
|
) -> User:
|
|
"""Update user's premium subscription status.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
user: The user to update.
|
|
premium_until: When premium expires, or None to remove premium.
|
|
|
|
Returns:
|
|
The updated User instance.
|
|
|
|
Example:
|
|
# Grant 30 days of premium
|
|
expires = datetime.now(UTC) + timedelta(days=30)
|
|
user = await user_service.update_premium(db, user, expires)
|
|
|
|
# Remove premium
|
|
user = await user_service.update_premium(db, user, None)
|
|
"""
|
|
if premium_until is not None:
|
|
user.is_premium = True
|
|
user.premium_until = premium_until
|
|
else:
|
|
user.is_premium = False
|
|
user.premium_until = None
|
|
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
async def delete(self, db: AsyncSession, user: User) -> None:
|
|
"""Delete a user account.
|
|
|
|
This will cascade delete all related data (decks, collection, etc.)
|
|
based on the model relationships.
|
|
|
|
Args:
|
|
db: Async database session.
|
|
user: The user to delete.
|
|
|
|
Example:
|
|
await user_service.delete(db, user)
|
|
"""
|
|
await db.delete(user)
|
|
await db.commit()
|
|
|
|
|
|
# Global service instance
|
|
user_service = UserService()
|