| """ |
| Enhanced WebSocket Implementation for AegisLM SaaS Backend. |
| |
| Production-ready WebSocket with authentication, |
| rate limiting, and comprehensive security features. |
| """ |
|
|
| import json |
| import asyncio |
| import logging |
| from typing import Dict, Set, Optional |
| from datetime import datetime, timezone |
|
|
| from fastapi import WebSocket, WebSocketDisconnect, HTTPException, status |
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials |
| import redis.asyncio as redis |
|
|
| from core.config import settings |
| from core.security import verify_token |
| from services.audit_service import audit_service, AuditEventType |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class EnhancedWebSocketManager: |
| """Enhanced WebSocket manager with security and rate limiting.""" |
| |
| def __init__(self): |
| self.active_connections: Dict[str, WebSocket] = {} |
| self.user_connections: Dict[int, Set[str]] = {} |
| self.connection_metadata: Dict[str, dict] = {} |
| self.redis_client = None |
| self.rate_limiter = {} |
| |
| async def get_redis_client(self): |
| """Get Redis client connection.""" |
| if not self.redis_client: |
| self.redis_client = redis.from_url(settings.REDIS_URL) |
| return self.redis_client |
| |
| def generate_connection_id(self) -> str: |
| """Generate unique connection ID.""" |
| import uuid |
| return str(uuid.uuid4()) |
| |
| async def authenticate_websocket(self, websocket: WebSocket, token: str) -> Optional[int]: |
| """Authenticate WebSocket connection using JWT token.""" |
| try: |
| |
| user_id = verify_token(token) |
| if not user_id: |
| await websocket.close(code=4001, reason="Invalid authentication token") |
| return None |
| |
| |
| |
| |
| |
| return int(user_id) |
| |
| except Exception as e: |
| logger.error(f"WebSocket authentication failed: {str(e)}") |
| await websocket.close(code=4002, reason="Authentication failed") |
| return None |
| |
| async def check_rate_limit(self, connection_id: str) -> bool: |
| """Check if connection has exceeded message rate limit.""" |
| if connection_id not in self.rate_limiter: |
| self.rate_limiter[connection_id] = { |
| "messages": 0, |
| "window_start": datetime.now(timezone.utc) |
| } |
| |
| current_time = datetime.now(timezone.utc) |
| window_start = self.rate_limiter[connection_id]["window_start"] |
| |
| |
| if (current_time - window_start).seconds > 60: |
| self.rate_limiter[connection_id] = { |
| "messages": 0, |
| "window_start": current_time |
| } |
| |
| |
| if self.rate_limiter[connection_id]["messages"] >= 60: |
| return False |
| |
| self.rate_limiter[connection_id]["messages"] += 1 |
| return True |
| |
| async def connect(self, websocket: WebSocket, token: str): |
| """Accept and authenticate WebSocket connection.""" |
| connection_id = self.generate_connection_id() |
| |
| |
| user_id = await self.authenticate_websocket(websocket, token) |
| if not user_id: |
| return |
| |
| |
| await websocket.accept() |
| |
| |
| self.active_connections[connection_id] = websocket |
| self.connection_metadata[connection_id] = { |
| "user_id": user_id, |
| "connected_at": datetime.now(timezone.utc).isoformat(), |
| "ip_address": websocket.client.host if websocket.client else "unknown", |
| "user_agent": websocket.headers.get("user-agent", "unknown") |
| } |
| |
| |
| if user_id not in self.user_connections: |
| self.user_connections[user_id] = set() |
| self.user_connections[user_id].add(connection_id) |
| |
| |
| audit_service.log_user_action( |
| action="websocket_connect", |
| user_id=user_id, |
| ip_address=websocket.client.host if websocket.client else None, |
| details={"connection_id": connection_id} |
| ) |
| |
| logger.info(f"WebSocket connected: {connection_id} for user {user_id}") |
| |
| |
| await self.send_personal_message(connection_id, { |
| "type": "connection_established", |
| "data": { |
| "connection_id": connection_id, |
| "message": "WebSocket connection established successfully", |
| "timestamp": datetime.now(timezone.utc).isoformat() |
| } |
| }) |
| |
| async def disconnect(self, connection_id: str, reason: str = "Normal disconnect"): |
| """Handle WebSocket disconnection.""" |
| if connection_id in self.active_connections: |
| websocket = self.active_connections[connection_id] |
| metadata = self.connection_metadata.get(connection_id, {}) |
| user_id = metadata.get("user_id") |
| |
| |
| del self.active_connections[connection_id] |
| |
| |
| if user_id and user_id in self.user_connections: |
| self.user_connections[user_id].discard(connection_id) |
| if not self.user_connections[user_id]: |
| del self.user_connections[user_id] |
| |
| |
| if connection_id in self.connection_metadata: |
| del self.connection_metadata[connection_id] |
| |
| |
| if connection_id in self.rate_limiter: |
| del self.rate_limiter[connection_id] |
| |
| |
| audit_service.log_user_action( |
| action="websocket_disconnect", |
| user_id=user_id, |
| details={"connection_id": connection_id, "reason": reason} |
| ) |
| |
| logger.info(f"WebSocket disconnected: {connection_id} for user {user_id} - Reason: {reason}") |
| |
| async def send_personal_message(self, connection_id: str, message: dict): |
| """Send message to specific connection.""" |
| if connection_id in self.active_connections: |
| websocket = self.active_connections[connection_id] |
| await websocket.send_text(json.dumps(message)) |
| |
| async def send_user_message(self, user_id: int, message: dict): |
| """Send message to all connections for a specific user.""" |
| if user_id in self.user_connections: |
| for connection_id in self.user_connections[user_id]: |
| await self.send_personal_message(connection_id, message) |
| |
| async def broadcast_message(self, message: dict, exclude_connection: Optional[str] = None): |
| """Broadcast message to all authenticated connections.""" |
| for connection_id, websocket in self.active_connections.items(): |
| if connection_id != exclude_connection: |
| try: |
| await websocket.send_text(json.dumps(message)) |
| except Exception as e: |
| logger.error(f"Failed to send message to {connection_id}: {str(e)}") |
| |
| async def handle_message(self, connection_id: str, message: str): |
| """Handle incoming WebSocket message.""" |
| |
| if not await self.check_rate_limit(connection_id): |
| await self.send_personal_message(connection_id, { |
| "type": "error", |
| "data": { |
| "message": "Rate limit exceeded. Please slow down.", |
| "code": "RATE_LIMIT_EXCEEDED" |
| } |
| }) |
| return |
| |
| try: |
| |
| data = json.loads(message) |
| message_type = data.get("type", "unknown") |
| |
| |
| if message_type == "ping": |
| await self.send_personal_message(connection_id, { |
| "type": "pong", |
| "data": {"timestamp": datetime.now(timezone.utc).isoformat()} |
| }) |
| elif message_type == "subscribe": |
| await self.handle_subscription(connection_id, data.get("data", {})) |
| elif message_type == "unsubscribe": |
| await self.handle_unsubscription(connection_id, data.get("data", {})) |
| else: |
| |
| logger.warning(f"Unknown WebSocket message type: {message_type}") |
| |
| except json.JSONDecodeError: |
| await self.send_personal_message(connection_id, { |
| "type": "error", |
| "data": { |
| "message": "Invalid JSON format", |
| "code": "INVALID_JSON" |
| } |
| }) |
| except Exception as e: |
| logger.error(f"Error handling WebSocket message: {str(e)}") |
| await self.send_personal_message(connection_id, { |
| "type": "error", |
| "data": { |
| "message": "Internal server error", |
| "code": "INTERNAL_ERROR" |
| } |
| }) |
| |
| async def handle_subscription(self, connection_id: str, subscription_data: dict): |
| """Handle WebSocket subscription to specific channels.""" |
| metadata = self.connection_metadata.get(connection_id, {}) |
| if not metadata: |
| return |
| |
| user_id = metadata.get("user_id") |
| channel = subscription_data.get("channel") |
| |
| if not channel: |
| await self.send_personal_message(connection_id, { |
| "type": "error", |
| "data": { |
| "message": "Channel name required", |
| "code": "INVALID_SUBSCRIPTION" |
| } |
| }) |
| return |
| |
| |
| if "subscriptions" not in metadata: |
| metadata["subscriptions"] = set() |
| metadata["subscriptions"].add(channel) |
| |
| |
| audit_service.log_user_action( |
| action="websocket_subscribe", |
| user_id=user_id, |
| details={"channel": channel, "connection_id": connection_id} |
| ) |
| |
| await self.send_personal_message(connection_id, { |
| "type": "subscription_confirmed", |
| "data": { |
| "channel": channel, |
| "message": f"Subscribed to {channel}" |
| } |
| }) |
| |
| async def handle_unsubscription(self, connection_id: str, unsubscription_data: dict): |
| """Handle WebSocket unsubscription from channels.""" |
| metadata = self.connection_metadata.get(connection_id, {}) |
| if not metadata: |
| return |
| |
| user_id = metadata.get("user_id") |
| channel = unsubscription_data.get("channel") |
| |
| if not channel or "subscriptions" not in metadata: |
| return |
| |
| |
| metadata["subscriptions"].discard(channel) |
| |
| |
| audit_service.log_user_action( |
| action="websocket_unsubscribe", |
| user_id=user_id, |
| details={"channel": channel, "connection_id": connection_id} |
| ) |
| |
| await self.send_personal_message(connection_id, { |
| "type": "unsubscription_confirmed", |
| "data": { |
| "channel": channel, |
| "message": f"Unsubscribed from {channel}" |
| } |
| }) |
| |
| def get_connection_stats(self) -> dict: |
| """Get WebSocket connection statistics.""" |
| return { |
| "total_connections": len(self.active_connections), |
| "unique_users": len(self.user_connections), |
| "connections_per_user": { |
| str(user_id): len(connections) |
| for user_id, connections in self.user_connections.items() |
| }, |
| "timestamp": datetime.now(timezone.utc).isoformat() |
| } |
|
|
|
|
| |
| enhanced_websocket_manager = EnhancedWebSocketManager() |
|
|