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
159 lines
4.4 KiB
Python
159 lines
4.4 KiB
Python
"""User model for Mantimon TCG.
|
|
|
|
This module defines the User model for player accounts with OAuth support
|
|
and premium subscription tracking.
|
|
|
|
Example:
|
|
user = User(
|
|
email="player@example.com",
|
|
display_name="Player1",
|
|
oauth_provider="google",
|
|
oauth_id="123456789"
|
|
)
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import Boolean, DateTime, Index, String
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db.base import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from app.db.models.campaign import CampaignProgress
|
|
from app.db.models.collection import Collection
|
|
from app.db.models.deck import Deck
|
|
from app.db.models.oauth_account import OAuthLinkedAccount
|
|
|
|
|
|
class User(Base):
|
|
"""User account model with OAuth and premium support.
|
|
|
|
Attributes:
|
|
id: Unique identifier (UUID).
|
|
email: User's email address (unique).
|
|
display_name: Public display name.
|
|
avatar_url: URL to user's avatar image.
|
|
oauth_provider: OAuth provider name ('google', 'discord').
|
|
oauth_id: Unique ID from the OAuth provider.
|
|
is_premium: Whether user has active premium subscription.
|
|
premium_until: When premium subscription expires.
|
|
created_at: Account creation timestamp.
|
|
last_login: Last login timestamp.
|
|
|
|
Relationships:
|
|
decks: User's deck configurations.
|
|
collection: User's card collection.
|
|
campaign_progress: User's campaign state.
|
|
linked_accounts: Additional linked OAuth providers.
|
|
"""
|
|
|
|
__tablename__ = "users"
|
|
|
|
# Basic info
|
|
email: Mapped[str] = mapped_column(
|
|
String(255),
|
|
unique=True,
|
|
nullable=False,
|
|
index=True,
|
|
doc="User's email address",
|
|
)
|
|
display_name: Mapped[str] = mapped_column(
|
|
String(50),
|
|
nullable=False,
|
|
doc="Public display name",
|
|
)
|
|
avatar_url: Mapped[str | None] = mapped_column(
|
|
String(500),
|
|
nullable=True,
|
|
doc="URL to avatar image",
|
|
)
|
|
|
|
# OAuth
|
|
oauth_provider: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
doc="OAuth provider name (google, discord)",
|
|
)
|
|
oauth_id: Mapped[str] = mapped_column(
|
|
String(255),
|
|
nullable=False,
|
|
doc="Unique ID from OAuth provider",
|
|
)
|
|
|
|
# Premium subscription
|
|
is_premium: Mapped[bool] = mapped_column(
|
|
Boolean,
|
|
default=False,
|
|
nullable=False,
|
|
doc="Whether user has active premium",
|
|
)
|
|
premium_until: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
doc="Premium subscription expiration",
|
|
)
|
|
|
|
# Activity tracking
|
|
last_login: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
doc="Last login timestamp",
|
|
)
|
|
|
|
# Relationships
|
|
decks: Mapped[list["Deck"]] = relationship(
|
|
"Deck",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
lazy="selectin",
|
|
)
|
|
collection: Mapped[list["Collection"]] = relationship(
|
|
"Collection",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
lazy="selectin",
|
|
)
|
|
campaign_progress: Mapped["CampaignProgress | None"] = relationship(
|
|
"CampaignProgress",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
uselist=False,
|
|
lazy="selectin",
|
|
)
|
|
linked_accounts: Mapped[list["OAuthLinkedAccount"]] = relationship(
|
|
"OAuthLinkedAccount",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
lazy="selectin",
|
|
)
|
|
|
|
# Indexes
|
|
__table_args__ = (Index("ix_users_oauth", "oauth_provider", "oauth_id", unique=True),)
|
|
|
|
@property
|
|
def has_active_premium(self) -> bool:
|
|
"""Check if premium subscription is currently active.
|
|
|
|
Returns:
|
|
True if user has premium and it hasn't expired.
|
|
"""
|
|
if not self.is_premium:
|
|
return False
|
|
if self.premium_until is None:
|
|
return False
|
|
return datetime.now(self.premium_until.tzinfo) < self.premium_until
|
|
|
|
@property
|
|
def max_decks(self) -> int:
|
|
"""Get maximum number of decks user can have.
|
|
|
|
Returns:
|
|
5 for free users, unlimited (999) for premium.
|
|
"""
|
|
return 999 if self.has_active_premium else 5
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<User(id={self.id!r}, email={self.email!r})>"
|