feat: implement 5 QA-driven security and quality proposals
Implemented proposals from comprehensive QA review: 1. extend-csrf-protection - Add POST to CSRF protected methods in frontend - Global CSRF middleware for all state-changing operations - Update tests with CSRF token fixtures 2. tighten-cors-websocket-security - Replace wildcard CORS with explicit method/header lists - Disable query parameter auth in production (code 4002) - Add per-user WebSocket connection limit (max 5, code 4005) 3. shorten-jwt-expiry - Reduce JWT expiry from 7 days to 60 minutes - Add refresh token support with 7-day expiry - Implement token rotation on refresh - Frontend auto-refresh when token near expiry (<5 min) 4. fix-frontend-quality - Add React.lazy() code splitting for all pages - Fix useCallback dependency arrays (Dashboard, Comments) - Add localStorage data validation in AuthContext - Complete i18n for AttachmentUpload component 5. enhance-backend-validation - Add SecurityAuditMiddleware for access denied logging - Add ErrorSanitizerMiddleware for production error messages - Protect /health/detailed with admin authentication - Add input length validation (comment 5000, desc 10000) All 521 backend tests passing. Frontend builds successfully. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -356,3 +356,140 @@ def create_token_payload(
|
||||
"department_id": department_id,
|
||||
"is_system_admin": is_system_admin,
|
||||
}
|
||||
|
||||
|
||||
# Refresh Token Functions
|
||||
REFRESH_TOKEN_BYTES = 32
|
||||
|
||||
|
||||
def generate_refresh_token() -> str:
|
||||
"""
|
||||
Generate a cryptographically secure refresh token.
|
||||
|
||||
Returns:
|
||||
A URL-safe base64-encoded random token
|
||||
"""
|
||||
return secrets.token_urlsafe(REFRESH_TOKEN_BYTES)
|
||||
|
||||
|
||||
def get_refresh_token_key(user_id: str, token: str) -> str:
|
||||
"""
|
||||
Generate the Redis key for a refresh token.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID
|
||||
token: The refresh token
|
||||
|
||||
Returns:
|
||||
Redis key string
|
||||
"""
|
||||
# Hash the token to avoid storing it directly as a key
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
|
||||
return f"refresh_token:{user_id}:{token_hash}"
|
||||
|
||||
|
||||
def store_refresh_token(redis_client, user_id: str, token: str) -> None:
|
||||
"""
|
||||
Store a refresh token in Redis with user binding.
|
||||
|
||||
Args:
|
||||
redis_client: Redis client instance
|
||||
user_id: The user's ID
|
||||
token: The refresh token to store
|
||||
"""
|
||||
key = get_refresh_token_key(user_id, token)
|
||||
# Store with TTL based on REFRESH_TOKEN_EXPIRE_DAYS
|
||||
ttl_seconds = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
|
||||
redis_client.setex(key, ttl_seconds, user_id)
|
||||
|
||||
|
||||
def validate_refresh_token(redis_client, user_id: str, token: str) -> bool:
|
||||
"""
|
||||
Validate a refresh token exists in Redis and is bound to the user.
|
||||
|
||||
Args:
|
||||
redis_client: Redis client instance
|
||||
user_id: The expected user ID
|
||||
token: The refresh token to validate
|
||||
|
||||
Returns:
|
||||
True if token is valid, False otherwise
|
||||
"""
|
||||
key = get_refresh_token_key(user_id, token)
|
||||
stored_user_id = redis_client.get(key)
|
||||
|
||||
if stored_user_id is None:
|
||||
return False
|
||||
|
||||
# Handle Redis bytes type
|
||||
if isinstance(stored_user_id, bytes):
|
||||
stored_user_id = stored_user_id.decode("utf-8")
|
||||
|
||||
return stored_user_id == user_id
|
||||
|
||||
|
||||
def invalidate_refresh_token(redis_client, user_id: str, token: str) -> bool:
|
||||
"""
|
||||
Invalidate (delete) a refresh token from Redis.
|
||||
|
||||
Args:
|
||||
redis_client: Redis client instance
|
||||
user_id: The user's ID
|
||||
token: The refresh token to invalidate
|
||||
|
||||
Returns:
|
||||
True if token was deleted, False if it didn't exist
|
||||
"""
|
||||
key = get_refresh_token_key(user_id, token)
|
||||
result = redis_client.delete(key)
|
||||
return result > 0 if isinstance(result, int) else bool(result)
|
||||
|
||||
|
||||
def invalidate_all_user_refresh_tokens(redis_client, user_id: str) -> int:
|
||||
"""
|
||||
Invalidate all refresh tokens for a user.
|
||||
|
||||
Args:
|
||||
redis_client: Redis client instance
|
||||
user_id: The user's ID
|
||||
|
||||
Returns:
|
||||
Number of tokens invalidated
|
||||
"""
|
||||
pattern = f"refresh_token:{user_id}:*"
|
||||
count = 0
|
||||
for key in redis_client.scan_iter(match=pattern):
|
||||
redis_client.delete(key)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def decode_refresh_token_user_id(token: str, redis_client) -> Optional[str]:
|
||||
"""
|
||||
Find the user ID associated with a refresh token by searching Redis.
|
||||
|
||||
This is used when we only have the token and need to find which user it belongs to.
|
||||
Note: This is less efficient but necessary for refresh token validation when
|
||||
the user_id is not provided in the request.
|
||||
|
||||
Args:
|
||||
token: The refresh token
|
||||
redis_client: Redis client instance
|
||||
|
||||
Returns:
|
||||
User ID if found, None otherwise
|
||||
"""
|
||||
# We need to search for the token across all users
|
||||
# This is done by checking the token hash pattern
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
|
||||
pattern = f"refresh_token:*:{token_hash}"
|
||||
|
||||
for key in redis_client.scan_iter(match=pattern):
|
||||
# Extract user_id from key format: refresh_token:{user_id}:{token_hash}
|
||||
if isinstance(key, bytes):
|
||||
key = key.decode("utf-8")
|
||||
parts = key.split(":")
|
||||
if len(parts) == 3:
|
||||
return parts[1]
|
||||
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user