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
392 lines
12 KiB
Python
392 lines
12 KiB
Python
"""Authentication API router for Mantimon TCG.
|
|
|
|
This module provides endpoints for OAuth authentication:
|
|
- OAuth login redirects (Google, Discord)
|
|
- OAuth callbacks (token exchange)
|
|
- Token refresh
|
|
- Logout
|
|
|
|
OAuth Flow:
|
|
1. Client calls GET /auth/{provider} to get redirect URL
|
|
2. Client redirects user to OAuth provider
|
|
3. Provider redirects back to GET /auth/{provider}/callback
|
|
4. Server exchanges code for tokens, creates/fetches user
|
|
5. Server returns JWT access + refresh tokens
|
|
|
|
Example:
|
|
# Start OAuth flow
|
|
GET /api/auth/google?redirect_uri=https://play.mantimon.com/login/callback
|
|
|
|
# After OAuth callback, refresh tokens
|
|
POST /api/auth/refresh
|
|
{"refresh_token": "..."}
|
|
|
|
# Logout
|
|
POST /api/auth/logout
|
|
{"refresh_token": "..."}
|
|
"""
|
|
|
|
import secrets
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, status
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
from app.api.deps import CurrentUser, DbSession
|
|
from app.db.redis import get_redis
|
|
from app.schemas.auth import RefreshTokenRequest, TokenResponse
|
|
from app.services.jwt_service import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
get_refresh_token_expiration,
|
|
get_token_expiration_seconds,
|
|
verify_refresh_token,
|
|
)
|
|
from app.services.oauth.discord import DiscordOAuthError, discord_oauth
|
|
from app.services.oauth.google import GoogleOAuthError, google_oauth
|
|
from app.services.token_store import token_store
|
|
from app.services.user_service import user_service
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
# OAuth state TTL (5 minutes)
|
|
OAUTH_STATE_TTL = 300
|
|
|
|
|
|
async def _store_oauth_state(state: str, provider: str, redirect_uri: str) -> None:
|
|
"""Store OAuth state in Redis for CSRF validation."""
|
|
async with get_redis() as redis:
|
|
key = f"oauth_state:{state}"
|
|
value = f"{provider}:{redirect_uri}"
|
|
await redis.setex(key, OAUTH_STATE_TTL, value)
|
|
|
|
|
|
async def _validate_oauth_state(state: str, provider: str) -> str | None:
|
|
"""Validate and consume OAuth state, returning redirect_uri if valid."""
|
|
async with get_redis() as redis:
|
|
key = f"oauth_state:{state}"
|
|
value = await redis.get(key)
|
|
if not value:
|
|
return None
|
|
|
|
# Delete state (one-time use)
|
|
await redis.delete(key)
|
|
|
|
# Parse and validate
|
|
stored_provider, redirect_uri = value.split(":", 1)
|
|
if stored_provider != provider:
|
|
return None
|
|
|
|
return redirect_uri
|
|
|
|
|
|
async def _create_tokens_for_user(user_id) -> TokenResponse:
|
|
"""Create access and refresh tokens for a user."""
|
|
from uuid import UUID
|
|
|
|
if isinstance(user_id, str):
|
|
user_id = UUID(user_id)
|
|
|
|
access_token = create_access_token(user_id)
|
|
refresh_token, jti = create_refresh_token(user_id)
|
|
|
|
# Store refresh token in Redis for revocation tracking
|
|
expires_at = get_refresh_token_expiration()
|
|
await token_store.store_refresh_token(user_id, jti, expires_at)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
expires_in=get_token_expiration_seconds(),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Google OAuth
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/google")
|
|
async def google_auth_redirect(
|
|
redirect_uri: str = Query(..., description="URI to redirect to after OAuth completes"),
|
|
) -> RedirectResponse:
|
|
"""Start Google OAuth flow.
|
|
|
|
Redirects the user to Google's OAuth consent screen.
|
|
|
|
Args:
|
|
redirect_uri: Where to redirect after successful authentication.
|
|
This is YOUR app's callback, not the OAuth callback.
|
|
|
|
Returns:
|
|
Redirect to Google OAuth authorization URL.
|
|
|
|
Raises:
|
|
HTTPException: 501 if Google OAuth is not configured.
|
|
"""
|
|
if not google_oauth.is_configured():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="Google OAuth is not configured",
|
|
)
|
|
|
|
# Generate state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
await _store_oauth_state(state, "google", redirect_uri)
|
|
|
|
# Build OAuth callback URL (our server endpoint)
|
|
# The redirect_uri param here is where Google sends the code
|
|
oauth_callback = "/api/auth/google/callback"
|
|
|
|
# Get authorization URL
|
|
auth_url = google_oauth.get_authorization_url(
|
|
redirect_uri=oauth_callback,
|
|
state=state,
|
|
)
|
|
|
|
return RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
|
|
|
|
|
@router.get("/google/callback")
|
|
async def google_auth_callback(
|
|
db: DbSession,
|
|
code: str = Query(..., description="Authorization code from Google"),
|
|
state: str = Query(..., description="State parameter for CSRF validation"),
|
|
) -> TokenResponse:
|
|
"""Handle Google OAuth callback.
|
|
|
|
Exchanges the authorization code for tokens and creates/fetches the user.
|
|
|
|
Args:
|
|
code: Authorization code from Google.
|
|
state: State parameter for CSRF validation.
|
|
|
|
Returns:
|
|
JWT access and refresh tokens.
|
|
|
|
Raises:
|
|
HTTPException: 400 if state is invalid or OAuth fails.
|
|
"""
|
|
# Validate state
|
|
redirect_uri = await _validate_oauth_state(state, "google")
|
|
if redirect_uri is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired state parameter",
|
|
)
|
|
|
|
try:
|
|
# Exchange code for user info
|
|
oauth_callback = "/api/auth/google/callback"
|
|
user_info = await google_oauth.get_user_info(code, oauth_callback)
|
|
|
|
# Get or create user
|
|
user, created = await user_service.get_or_create_from_oauth(db, user_info)
|
|
|
|
# Update last login
|
|
await user_service.update_last_login(db, user)
|
|
|
|
# Create tokens
|
|
return await _create_tokens_for_user(user.id)
|
|
|
|
except GoogleOAuthError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Google OAuth failed: {e}",
|
|
) from None
|
|
|
|
|
|
# =============================================================================
|
|
# Discord OAuth
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/discord")
|
|
async def discord_auth_redirect(
|
|
redirect_uri: str = Query(..., description="URI to redirect to after OAuth completes"),
|
|
) -> RedirectResponse:
|
|
"""Start Discord OAuth flow.
|
|
|
|
Redirects the user to Discord's OAuth consent screen.
|
|
|
|
Args:
|
|
redirect_uri: Where to redirect after successful authentication.
|
|
|
|
Returns:
|
|
Redirect to Discord OAuth authorization URL.
|
|
|
|
Raises:
|
|
HTTPException: 501 if Discord OAuth is not configured.
|
|
"""
|
|
if not discord_oauth.is_configured():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="Discord OAuth is not configured",
|
|
)
|
|
|
|
# Generate state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
await _store_oauth_state(state, "discord", redirect_uri)
|
|
|
|
# Build OAuth callback URL
|
|
oauth_callback = "/api/auth/discord/callback"
|
|
|
|
# Get authorization URL
|
|
auth_url = discord_oauth.get_authorization_url(
|
|
redirect_uri=oauth_callback,
|
|
state=state,
|
|
)
|
|
|
|
return RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
|
|
|
|
|
@router.get("/discord/callback")
|
|
async def discord_auth_callback(
|
|
db: DbSession,
|
|
code: str = Query(..., description="Authorization code from Discord"),
|
|
state: str = Query(..., description="State parameter for CSRF validation"),
|
|
) -> TokenResponse:
|
|
"""Handle Discord OAuth callback.
|
|
|
|
Exchanges the authorization code for tokens and creates/fetches the user.
|
|
|
|
Args:
|
|
code: Authorization code from Discord.
|
|
state: State parameter for CSRF validation.
|
|
|
|
Returns:
|
|
JWT access and refresh tokens.
|
|
|
|
Raises:
|
|
HTTPException: 400 if state is invalid or OAuth fails.
|
|
"""
|
|
# Validate state
|
|
redirect_uri = await _validate_oauth_state(state, "discord")
|
|
if redirect_uri is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired state parameter",
|
|
)
|
|
|
|
try:
|
|
# Exchange code for user info
|
|
oauth_callback = "/api/auth/discord/callback"
|
|
user_info = await discord_oauth.get_user_info(code, oauth_callback)
|
|
|
|
# Get or create user
|
|
user, created = await user_service.get_or_create_from_oauth(db, user_info)
|
|
|
|
# Update last login
|
|
await user_service.update_last_login(db, user)
|
|
|
|
# Create tokens
|
|
return await _create_tokens_for_user(user.id)
|
|
|
|
except DiscordOAuthError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Discord OAuth failed: {e}",
|
|
) from None
|
|
|
|
|
|
# =============================================================================
|
|
# Token Management
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh_tokens(
|
|
db: DbSession,
|
|
request: RefreshTokenRequest,
|
|
) -> TokenResponse:
|
|
"""Refresh access token using refresh token.
|
|
|
|
Validates the refresh token and issues a new access token.
|
|
The refresh token itself is NOT rotated (same token can be used
|
|
until it expires or is revoked).
|
|
|
|
Args:
|
|
request: Contains the refresh token.
|
|
|
|
Returns:
|
|
New JWT access token (same refresh token).
|
|
|
|
Raises:
|
|
HTTPException: 401 if refresh token is invalid or revoked.
|
|
"""
|
|
# Verify refresh token
|
|
result = verify_refresh_token(request.refresh_token)
|
|
if result is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token",
|
|
)
|
|
|
|
user_id, jti = result
|
|
|
|
# Check if token is revoked
|
|
is_valid = await token_store.is_token_valid(user_id, jti)
|
|
if not is_valid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Refresh token has been revoked",
|
|
)
|
|
|
|
# Verify user still exists
|
|
user = await user_service.get_by_id(db, user_id)
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found",
|
|
)
|
|
|
|
# Create new access token (keep same refresh token)
|
|
access_token = create_access_token(user_id)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=request.refresh_token, # Return same refresh token
|
|
expires_in=get_token_expiration_seconds(),
|
|
)
|
|
|
|
|
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def logout(
|
|
request: RefreshTokenRequest,
|
|
) -> None:
|
|
"""Logout by revoking the refresh token.
|
|
|
|
The access token will continue to work until it expires,
|
|
but the refresh token cannot be used to get new tokens.
|
|
|
|
Args:
|
|
request: Contains the refresh token to revoke.
|
|
"""
|
|
# Verify refresh token to get user_id and jti
|
|
result = verify_refresh_token(request.refresh_token)
|
|
if result is None:
|
|
# Token is invalid, but that's fine - user is effectively logged out
|
|
return
|
|
|
|
user_id, jti = result
|
|
|
|
# Revoke the token
|
|
await token_store.revoke_token(user_id, jti)
|
|
|
|
|
|
@router.post("/logout-all", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def logout_all(
|
|
user: CurrentUser,
|
|
) -> None:
|
|
"""Logout from all devices by revoking all refresh tokens.
|
|
|
|
Requires authentication (uses current access token).
|
|
All refresh tokens for the user will be revoked.
|
|
|
|
Args:
|
|
user: Current authenticated user.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
user_id = UUID(user.id) if isinstance(user.id, str) else user.id
|
|
await token_store.revoke_all_user_tokens(user_id)
|