security: Sprint 00 — critical security fixes (OPC WS auth, Transmission creds, SECRET_KEY, passkey rate limit)

- Add JWT token auth to OPC WebSocket (unauthenticated → 403, per-user tracking)
- Externalize Transmission RPC credentials to TRANSMISSION_USER/PASS env vars
- Remove hardcoded SECRET_KEY fallback from dev compose
- Rate-limit passkey register options endpoint at 5/minute
- Add PDD (docs/improvement-plan.md) and sprint specs (specs/)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Gan, Jimmy
2026-05-03 13:31:25 +08:00
parent 5c7d185953
commit ddaf3c6cee
16 changed files with 987 additions and 21 deletions
+23 -14
View File
@@ -6,15 +6,14 @@ import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status
import auth_service as auth
logger = logging.getLogger(__name__)
router = APIRouter()
# Active WebSocket connections
active_connections: set[WebSocket] = set()
def serialize_datetime(obj: Any) -> Any:
"""Recursively serialize datetime objects to ISO format strings"""
@@ -29,21 +28,21 @@ def serialize_datetime(obj: Any) -> Any:
class ConnectionManager:
"""Manages WebSocket connections"""
"""Manages WebSocket connections with per-user tracking"""
def __init__(self):
self.active_connections: set[WebSocket] = set()
self.active_connections: dict[WebSocket, str] = {}
async def connect(self, websocket: WebSocket):
async def connect(self, websocket: WebSocket, username: str):
"""Accept and register a new connection"""
await websocket.accept()
self.active_connections.add(websocket)
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
self.active_connections[websocket] = username
logger.info(f"WebSocket connected: {username}. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
"""Remove a connection"""
self.active_connections.discard(websocket)
logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
username = self.active_connections.pop(websocket, "unknown")
logger.info(f"WebSocket disconnected: {username}. Total connections: {len(self.active_connections)}")
async def broadcast(self, message: dict):
"""Broadcast message to all connected clients"""
@@ -64,9 +63,19 @@ manager = ConnectionManager()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for OPC real-time updates"""
await manager.connect(websocket)
async def websocket_endpoint(websocket: WebSocket, token: str = Query(None)):
"""WebSocket endpoint for OPC real-time updates. Requires JWT token via ?token= query param."""
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing authentication token")
return
try:
user = auth.user_from_access_token(token, include_auth_header=False)
except HTTPException:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Invalid authentication token")
return
await manager.connect(websocket, user.username)
try:
while True: