feat: implement 8 OpenSpec proposals for security, reliability, and UX improvements
## Security Enhancements (P0) - Add input validation with max_length and numeric range constraints - Implement WebSocket token authentication via first message - Add path traversal prevention in file storage service ## Permission Enhancements (P0) - Add project member management for cross-department access - Implement is_department_manager flag for workload visibility ## Cycle Detection (P0) - Add DFS-based cycle detection for task dependencies - Add formula field circular reference detection - Display user-friendly cycle path visualization ## Concurrency & Reliability (P1) - Implement optimistic locking with version field (409 Conflict on mismatch) - Add trigger retry mechanism with exponential backoff (1s, 2s, 4s) - Implement cascade restore for soft-deleted tasks ## Rate Limiting (P1) - Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min) - Apply rate limits to tasks, reports, attachments, and comments ## Frontend Improvements (P1) - Add responsive sidebar with hamburger menu for mobile - Improve touch-friendly UI with proper tap target sizes - Complete i18n translations for all components ## Backend Reliability (P2) - Configure database connection pool (size=10, overflow=20) - Add Redis fallback mechanism with message queue - Add blocker check before task deletion ## API Enhancements (P3) - Add standardized response wrapper utility - Add /health/ready and /health/live endpoints - Implement project templates with status/field copying ## Tests Added - test_input_validation.py - Schema and path traversal tests - test_concurrency_reliability.py - Optimistic locking and retry tests - test_backend_reliability.py - Connection pool and Redis tests - test_api_enhancements.py - Health check and template tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -19,6 +20,9 @@ router = APIRouter(tags=["websocket"])
|
||||
PING_INTERVAL = 60.0 # Send ping after this many seconds of no messages
|
||||
PONG_TIMEOUT = 30.0 # Disconnect if no pong received within this time after ping
|
||||
|
||||
# Authentication timeout (10 seconds)
|
||||
AUTH_TIMEOUT = 10.0
|
||||
|
||||
|
||||
async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
"""Validate token and return user_id and user object."""
|
||||
@@ -47,6 +51,56 @@ async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
db.close()
|
||||
|
||||
|
||||
async def authenticate_websocket(
|
||||
websocket: WebSocket,
|
||||
query_token: Optional[str] = None
|
||||
) -> tuple[str | None, User | None]:
|
||||
"""
|
||||
Authenticate WebSocket connection.
|
||||
|
||||
Supports two authentication methods:
|
||||
1. First message authentication (preferred, more secure)
|
||||
- Client sends: {"type": "auth", "token": "<jwt_token>"}
|
||||
2. Query parameter authentication (deprecated, for backward compatibility)
|
||||
- Client connects with: ?token=<jwt_token>
|
||||
|
||||
Returns (user_id, user) if authenticated, (None, None) otherwise.
|
||||
"""
|
||||
# If token provided via query parameter (backward compatibility)
|
||||
if query_token:
|
||||
logger.warning(
|
||||
"WebSocket authentication via query parameter is deprecated. "
|
||||
"Please use first-message authentication for better security."
|
||||
)
|
||||
return await get_user_from_token(query_token)
|
||||
|
||||
# Wait for authentication message with timeout
|
||||
try:
|
||||
data = await asyncio.wait_for(
|
||||
websocket.receive_json(),
|
||||
timeout=AUTH_TIMEOUT
|
||||
)
|
||||
|
||||
msg_type = data.get("type")
|
||||
if msg_type != "auth":
|
||||
logger.warning("Expected 'auth' message type, got: %s", msg_type)
|
||||
return None, None
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
logger.warning("No token provided in auth message")
|
||||
return None, None
|
||||
|
||||
return await get_user_from_token(token)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("WebSocket authentication timeout after %.1f seconds", AUTH_TIMEOUT)
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error("Error during WebSocket authentication: %s", e)
|
||||
return None, None
|
||||
|
||||
|
||||
async def get_unread_notifications(user_id: str) -> list[dict]:
|
||||
"""Query all unread notifications for a user."""
|
||||
db = SessionLocal()
|
||||
@@ -90,14 +144,22 @@ async def get_unread_count(user_id: str) -> int:
|
||||
@router.websocket("/ws/notifications")
|
||||
async def websocket_notifications(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(..., description="JWT token for authentication"),
|
||||
token: Optional[str] = Query(None, description="JWT token (deprecated, use first-message auth)"),
|
||||
):
|
||||
"""
|
||||
WebSocket endpoint for real-time notifications.
|
||||
|
||||
Connect with: ws://host/ws/notifications?token=<jwt_token>
|
||||
Authentication methods (in order of preference):
|
||||
1. First message authentication (recommended):
|
||||
- Connect without token: ws://host/ws/notifications
|
||||
- Send: {"type": "auth", "token": "<jwt_token>"}
|
||||
- Must authenticate within 10 seconds or connection will be closed
|
||||
|
||||
2. Query parameter (deprecated, for backward compatibility):
|
||||
- Connect with: ws://host/ws/notifications?token=<jwt_token>
|
||||
|
||||
Messages sent by server:
|
||||
- {"type": "auth_required"} - Sent when waiting for auth message
|
||||
- {"type": "connected", "data": {"user_id": "...", "message": "..."}} - Connection success
|
||||
- {"type": "unread_sync", "data": {"notifications": [...], "unread_count": N}} - All unread on connect
|
||||
- {"type": "notification", "data": {...}} - New notification
|
||||
@@ -106,9 +168,18 @@ async def websocket_notifications(
|
||||
- {"type": "pong"} - Response to client ping
|
||||
|
||||
Messages accepted from client:
|
||||
- {"type": "auth", "token": "..."} - Authentication (must be first message if no query token)
|
||||
- {"type": "ping"} - Client keepalive ping
|
||||
"""
|
||||
user_id, user = await get_user_from_token(token)
|
||||
# Accept WebSocket connection first
|
||||
await websocket.accept()
|
||||
|
||||
# If no query token, notify client that auth is required
|
||||
if not token:
|
||||
await websocket.send_json({"type": "auth_required"})
|
||||
|
||||
# Authenticate
|
||||
user_id, user = await authenticate_websocket(websocket, token)
|
||||
|
||||
if user_id is None:
|
||||
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||
@@ -263,14 +334,22 @@ async def verify_project_access(user_id: str, project_id: str) -> tuple[bool, Pr
|
||||
async def websocket_project_sync(
|
||||
websocket: WebSocket,
|
||||
project_id: str,
|
||||
token: str = Query(..., description="JWT token for authentication"),
|
||||
token: Optional[str] = Query(None, description="JWT token (deprecated, use first-message auth)"),
|
||||
):
|
||||
"""
|
||||
WebSocket endpoint for project task real-time sync.
|
||||
|
||||
Connect with: ws://host/ws/projects/{project_id}?token=<jwt_token>
|
||||
Authentication methods (in order of preference):
|
||||
1. First message authentication (recommended):
|
||||
- Connect without token: ws://host/ws/projects/{project_id}
|
||||
- Send: {"type": "auth", "token": "<jwt_token>"}
|
||||
- Must authenticate within 10 seconds or connection will be closed
|
||||
|
||||
2. Query parameter (deprecated, for backward compatibility):
|
||||
- Connect with: ws://host/ws/projects/{project_id}?token=<jwt_token>
|
||||
|
||||
Messages sent by server:
|
||||
- {"type": "auth_required"} - Sent when waiting for auth message
|
||||
- {"type": "connected", "data": {"project_id": "...", "user_id": "..."}}
|
||||
- {"type": "task_created", "data": {...}, "triggered_by": "..."}
|
||||
- {"type": "task_updated", "data": {...}, "triggered_by": "..."}
|
||||
@@ -280,10 +359,18 @@ async def websocket_project_sync(
|
||||
- {"type": "ping"} / {"type": "pong"}
|
||||
|
||||
Messages accepted from client:
|
||||
- {"type": "auth", "token": "..."} - Authentication (must be first message if no query token)
|
||||
- {"type": "ping"} - Client keepalive ping
|
||||
"""
|
||||
# Accept WebSocket connection first
|
||||
await websocket.accept()
|
||||
|
||||
# If no query token, notify client that auth is required
|
||||
if not token:
|
||||
await websocket.send_json({"type": "auth_required"})
|
||||
|
||||
# Authenticate user
|
||||
user_id, user = await get_user_from_token(token)
|
||||
user_id, user = await authenticate_websocket(websocket, token)
|
||||
|
||||
if user_id is None:
|
||||
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||
@@ -300,8 +387,7 @@ async def websocket_project_sync(
|
||||
await websocket.close(code=4004, reason="Project not found")
|
||||
return
|
||||
|
||||
# Accept connection and join project room
|
||||
await websocket.accept()
|
||||
# Join project room
|
||||
await manager.join_project(websocket, user_id, project_id)
|
||||
|
||||
# Create Redis subscriber for project task events
|
||||
|
||||
Reference in New Issue
Block a user