mantimon-tcg/backend/app/socketio/auth.py
Cal Corum 0c810e5b30 Add Phase 4 WebSocket infrastructure (WS-001 through GS-001)
WebSocket Message Schemas (WS-002):
- Add Pydantic models for all client/server WebSocket messages
- Implement discriminated unions for message type parsing
- Include JoinGame, Action, Resign, Heartbeat client messages
- Include GameState, ActionResult, Error, TurnStart server messages

Connection Manager (WS-003):
- Add Redis-backed WebSocket connection tracking
- Implement user-to-sid mapping with TTL management
- Support game room association and opponent lookup
- Add heartbeat tracking for connection health

Socket.IO Authentication (WS-004):
- Add JWT-based authentication middleware
- Support token extraction from multiple formats
- Implement session setup with ConnectionManager integration
- Add require_auth helper for event handlers

Socket.IO Server Setup (WS-001):
- Configure AsyncServer with ASGI mode
- Register /game namespace with event handlers
- Integrate with FastAPI via ASGIApp wrapper
- Configure CORS from application settings

Game Service (GS-001):
- Add stateless GameService for game lifecycle orchestration
- Create engine per-operation using rules from GameState
- Implement action-based RNG seeding for deterministic replay
- Add rng_seed field to GameState for replay support

Architecture verified:
- Core module independence (no forbidden imports)
- Config from request pattern (rules in GameState)
- Dependency injection (constructor deps, method config)
- All 1090 tests passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:21:20 -06:00

284 lines
8.1 KiB
Python

"""Socket.IO authentication middleware for WebSocket connections.
This module provides JWT-based authentication for Socket.IO connections.
It validates access tokens and attaches user information to the socket session.
Authentication Flow:
1. Client connects with `auth: { token: "JWT_ACCESS_TOKEN" }`
2. Server extracts and validates the JWT
3. If valid, user_id is stored in socket session
4. If invalid, connection is rejected with appropriate error
Session Data:
After successful authentication, the socket session contains:
- user_id: str (UUID as string)
- authenticated_at: str (ISO timestamp)
Example:
# In connect handler:
auth_result = await authenticate_connection(sid, auth)
if not auth_result.success:
return False # Reject connection
# Later, get user_id:
session = await sio.get_session(sid, namespace="/game")
user_id = session.get("user_id")
"""
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from uuid import UUID
from app.services.connection_manager import connection_manager
from app.services.jwt_service import verify_access_token
logger = logging.getLogger(__name__)
@dataclass
class AuthResult:
"""Result of authentication attempt.
Attributes:
success: Whether authentication succeeded.
user_id: User's UUID if successful, None otherwise.
error_code: Error code for client if failed.
error_message: Human-readable error message if failed.
"""
success: bool
user_id: UUID | None = None
error_code: str | None = None
error_message: str | None = None
def extract_token(auth: dict[str, object] | None) -> str | None:
"""Extract JWT token from Socket.IO auth data.
Clients should send the token in the auth dict:
socket.connect({ auth: { token: "JWT_TOKEN" } })
Also supports:
- auth.authorization: "Bearer TOKEN"
- auth.access_token: "TOKEN"
Args:
auth: Authentication data from Socket.IO connect.
Returns:
JWT token string if found, None otherwise.
Example:
token = extract_token({"token": "eyJ..."})
token = extract_token({"authorization": "Bearer eyJ..."})
"""
if auth is None:
return None
# Primary: auth.token
token = auth.get("token")
if token and isinstance(token, str):
return token
# Alternative: auth.authorization (Bearer token)
authorization = auth.get("authorization")
if authorization and isinstance(authorization, str):
if authorization.lower().startswith("bearer "):
return authorization[7:]
return authorization
# Alternative: auth.access_token
access_token = auth.get("access_token")
if access_token and isinstance(access_token, str):
return access_token
return None
async def authenticate_connection(
sid: str,
auth: dict[str, object] | None,
) -> AuthResult:
"""Authenticate a Socket.IO connection using JWT.
Extracts the JWT from auth data, validates it, and returns the result.
Does NOT modify socket session - caller should handle that.
Args:
sid: Socket session ID (for logging).
auth: Authentication data from connect event.
Returns:
AuthResult with success status and user_id or error details.
Example:
result = await authenticate_connection(sid, auth)
if result.success:
await sio.save_session(sid, {"user_id": str(result.user_id)})
else:
logger.warning(f"Auth failed: {result.error_message}")
return False # Reject connection
"""
# Extract token from auth data
token = extract_token(auth)
if token is None:
logger.debug(f"Connection {sid}: No token provided")
return AuthResult(
success=False,
error_code="missing_token",
error_message="Authentication token required",
)
# Validate the token
user_id = verify_access_token(token)
if user_id is None:
logger.debug(f"Connection {sid}: Invalid or expired token")
return AuthResult(
success=False,
error_code="invalid_token",
error_message="Invalid or expired token",
)
logger.debug(f"Connection {sid}: Authenticated as user {user_id}")
return AuthResult(
success=True,
user_id=user_id,
)
async def setup_authenticated_session(
sio: object,
sid: str,
user_id: UUID,
namespace: str = "/game",
) -> None:
"""Set up socket session with authenticated user data.
Saves user_id and authentication timestamp to the socket session,
and registers the connection with ConnectionManager.
Args:
sio: Socket.IO AsyncServer instance.
sid: Socket session ID.
user_id: Authenticated user's UUID.
namespace: Socket.IO namespace.
Example:
if auth_result.success:
await setup_authenticated_session(sio, sid, auth_result.user_id)
"""
# Import here to avoid circular dependency
from app.socketio.server import sio as server_sio
# Use provided sio or fall back to server sio
socket_server = sio if sio is not None else server_sio
# Save to socket session
session_data = {
"user_id": str(user_id),
"authenticated_at": datetime.now(UTC).isoformat(),
}
await socket_server.save_session(sid, session_data, namespace=namespace)
# Register with ConnectionManager
await connection_manager.register_connection(sid, user_id)
logger.info(f"Session established: sid={sid}, user_id={user_id}")
async def cleanup_authenticated_session(
sid: str,
namespace: str = "/game",
) -> str | None:
"""Clean up session data on disconnect.
Unregisters the connection from ConnectionManager and returns
the user_id for any additional cleanup needed.
Args:
sid: Socket session ID.
namespace: Socket.IO namespace.
Returns:
user_id if session was authenticated, None otherwise.
Example:
user_id = await cleanup_authenticated_session(sid)
if user_id:
# Notify opponent, etc.
"""
# Unregister from ConnectionManager
conn_info = await connection_manager.unregister_connection(sid)
if conn_info:
logger.info(f"Session cleaned up: sid={sid}, user_id={conn_info.user_id}")
return conn_info.user_id
logger.debug(f"No session to clean up for {sid}")
return None
async def get_session_user_id(
sio: object,
sid: str,
namespace: str = "/game",
) -> str | None:
"""Get the authenticated user_id from a socket session.
Convenience function to extract user_id from session data.
Args:
sio: Socket.IO AsyncServer instance.
sid: Socket session ID.
namespace: Socket.IO namespace.
Returns:
user_id string if authenticated, None otherwise.
Example:
user_id = await get_session_user_id(sio, sid)
if not user_id:
await sio.emit("error", {"message": "Not authenticated"}, to=sid)
return
"""
try:
session = await sio.get_session(sid, namespace=namespace)
return session.get("user_id") if session else None
except Exception:
return None
async def require_auth(
sio: object,
sid: str,
namespace: str = "/game",
) -> str | None:
"""Require authentication for an event handler.
Returns the user_id if authenticated, None if not.
Logs a warning if authentication is missing.
Args:
sio: Socket.IO AsyncServer instance.
sid: Socket session ID.
namespace: Socket.IO namespace.
Returns:
user_id string if authenticated, None otherwise.
Example:
@sio.on("game:action", namespace="/game")
async def on_action(sid, data):
user_id = await require_auth(sio, sid)
if not user_id:
return {"error": "Not authenticated"}
# ... handle action
"""
user_id = await get_session_user_id(sio, sid, namespace)
if user_id is None:
logger.warning(f"Unauthenticated event from {sid}")
return user_id