talk2me/redis_session_manager.py
Adolfo Delorenzo fa951c3141 Add comprehensive database integration, authentication, and admin dashboard
This commit introduces major enhancements to Talk2Me:

## Database Integration
- PostgreSQL support with SQLAlchemy ORM
- Redis integration for caching and real-time analytics
- Automated database initialization scripts
- Migration support infrastructure

## User Authentication System
- JWT-based API authentication
- Session-based web authentication
- API key authentication for programmatic access
- User roles and permissions (admin/user)
- Login history and session tracking
- Rate limiting per user with customizable limits

## Admin Dashboard
- Real-time analytics and monitoring
- User management interface (create, edit, delete users)
- System health monitoring
- Request/error tracking
- Language pair usage statistics
- Performance metrics visualization

## Key Features
- Dual authentication support (token + user accounts)
- Graceful fallback for missing services
- Non-blocking analytics middleware
- Comprehensive error handling
- Session management with security features

## Bug Fixes
- Fixed rate limiting bypass for admin routes
- Added missing email validation method
- Improved error handling for missing database tables
- Fixed session-based authentication for API endpoints

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-03 18:21:56 -06:00

389 lines
15 KiB
Python

# Redis-based session management system
import time
import uuid
import logging
from datetime import datetime
from typing import Dict, Any, Optional, List
from dataclasses import dataclass, asdict
from flask import session, request, g
logger = logging.getLogger(__name__)
@dataclass
class SessionInfo:
"""Session information stored in Redis"""
session_id: str
user_id: Optional[str] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
created_at: float = None
last_activity: float = None
request_count: int = 0
resource_count: int = 0
total_bytes_used: int = 0
metadata: Dict[str, Any] = None
def __post_init__(self):
if self.created_at is None:
self.created_at = time.time()
if self.last_activity is None:
self.last_activity = time.time()
if self.metadata is None:
self.metadata = {}
class RedisSessionManager:
"""
Session management using Redis for distributed sessions
"""
def __init__(self, redis_manager, config: Dict[str, Any] = None):
self.redis = redis_manager
self.config = config or {}
# Configuration
self.max_session_duration = self.config.get('max_session_duration', 3600) # 1 hour
self.max_idle_time = self.config.get('max_idle_time', 900) # 15 minutes
self.max_resources_per_session = self.config.get('max_resources_per_session', 100)
self.max_bytes_per_session = self.config.get('max_bytes_per_session', 100 * 1024 * 1024) # 100MB
logger.info("Redis session manager initialized")
def create_session(self, session_id: str = None, user_id: str = None,
ip_address: str = None, user_agent: str = None) -> SessionInfo:
"""Create a new session"""
if not session_id:
session_id = str(uuid.uuid4())
# Check if session already exists
existing = self.get_session(session_id)
if existing:
logger.warning(f"Session {session_id} already exists")
return existing
session_info = SessionInfo(
session_id=session_id,
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent
)
# Save to Redis
self._save_session(session_info)
# Add to active sessions set
self.redis.sadd('active_sessions', session_id)
# Update stats
self.redis.incr('stats:sessions:created')
logger.info(f"Created session {session_id}")
return session_info
def get_session(self, session_id: str) -> Optional[SessionInfo]:
"""Get a session by ID"""
data = self.redis.get(f'session:{session_id}')
if not data:
return None
# Update last activity
session_info = SessionInfo(**data)
session_info.last_activity = time.time()
self._save_session(session_info)
return session_info
def update_session_activity(self, session_id: str):
"""Update session last activity time"""
session_info = self.get_session(session_id)
if session_info:
session_info.last_activity = time.time()
session_info.request_count += 1
self._save_session(session_info)
def add_resource(self, session_id: str, resource_type: str,
resource_id: str = None, path: str = None,
size_bytes: int = 0, metadata: Dict[str, Any] = None) -> bool:
"""Add a resource to a session"""
session_info = self.get_session(session_id)
if not session_info:
logger.warning(f"Session {session_id} not found")
return False
# Check limits
if session_info.resource_count >= self.max_resources_per_session:
logger.warning(f"Session {session_id} reached resource limit")
# Clean up oldest resources
self._cleanup_oldest_resources(session_id, 1)
if session_info.total_bytes_used + size_bytes > self.max_bytes_per_session:
logger.warning(f"Session {session_id} reached size limit")
bytes_to_free = (session_info.total_bytes_used + size_bytes) - self.max_bytes_per_session
self._cleanup_resources_by_size(session_id, bytes_to_free)
# Generate resource ID if not provided
if not resource_id:
resource_id = str(uuid.uuid4())
# Store resource info
resource_key = f'session:{session_id}:resource:{resource_id}'
resource_data = {
'resource_id': resource_id,
'resource_type': resource_type,
'path': path,
'size_bytes': size_bytes,
'created_at': time.time(),
'metadata': metadata or {}
}
self.redis.set(resource_key, resource_data, expire=self.max_session_duration)
# Add to session's resource set
self.redis.sadd(f'session:{session_id}:resources', resource_id)
# Update session info
session_info.resource_count += 1
session_info.total_bytes_used += size_bytes
self._save_session(session_info)
# Update global stats
self.redis.incr('stats:resources:active')
self.redis.incr('stats:bytes:active', size_bytes)
logger.debug(f"Added {resource_type} resource {resource_id} to session {session_id}")
return True
def remove_resource(self, session_id: str, resource_id: str) -> bool:
"""Remove a resource from a session"""
# Get resource info
resource_key = f'session:{session_id}:resource:{resource_id}'
resource_data = self.redis.get(resource_key)
if not resource_data:
return False
# Clean up the actual resource (file, etc.)
self._cleanup_resource(resource_data)
# Remove from Redis
self.redis.delete(resource_key)
self.redis.srem(f'session:{session_id}:resources', resource_id)
# Update session info
session_info = self.get_session(session_id)
if session_info:
session_info.resource_count -= 1
session_info.total_bytes_used -= resource_data.get('size_bytes', 0)
self._save_session(session_info)
# Update stats
self.redis.decr('stats:resources:active')
self.redis.decr('stats:bytes:active', resource_data.get('size_bytes', 0))
self.redis.incr('stats:resources:cleaned')
self.redis.incr('stats:bytes:cleaned', resource_data.get('size_bytes', 0))
logger.debug(f"Removed resource {resource_id} from session {session_id}")
return True
def cleanup_session(self, session_id: str) -> bool:
"""Clean up a session and all its resources"""
session_info = self.get_session(session_id)
if not session_info:
return False
# Get all resources
resource_ids = self.redis.smembers(f'session:{session_id}:resources')
# Clean up each resource
for resource_id in resource_ids:
self.remove_resource(session_id, resource_id)
# Remove session data
self.redis.delete(f'session:{session_id}')
self.redis.delete(f'session:{session_id}:resources')
self.redis.srem('active_sessions', session_id)
# Update stats
self.redis.incr('stats:sessions:cleaned')
logger.info(f"Cleaned up session {session_id}")
return True
def cleanup_expired_sessions(self):
"""Clean up sessions that have exceeded max duration"""
now = time.time()
active_sessions = self.redis.smembers('active_sessions')
for session_id in active_sessions:
session_info = self.get_session(session_id)
if session_info and (now - session_info.created_at > self.max_session_duration):
logger.info(f"Cleaning up expired session {session_id}")
self.cleanup_session(session_id)
def cleanup_idle_sessions(self):
"""Clean up sessions that have been idle too long"""
now = time.time()
active_sessions = self.redis.smembers('active_sessions')
for session_id in active_sessions:
session_info = self.get_session(session_id)
if session_info and (now - session_info.last_activity > self.max_idle_time):
logger.info(f"Cleaning up idle session {session_id}")
self.cleanup_session(session_id)
def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed information about a session"""
session_info = self.get_session(session_id)
if not session_info:
return None
# Get resources
resource_ids = self.redis.smembers(f'session:{session_id}:resources')
resources = []
for resource_id in resource_ids:
resource_data = self.redis.get(f'session:{session_id}:resource:{resource_id}')
if resource_data:
resources.append({
'resource_id': resource_data['resource_id'],
'resource_type': resource_data['resource_type'],
'size_bytes': resource_data['size_bytes'],
'created_at': datetime.fromtimestamp(resource_data['created_at']).isoformat()
})
return {
'session_id': session_info.session_id,
'user_id': session_info.user_id,
'ip_address': session_info.ip_address,
'created_at': datetime.fromtimestamp(session_info.created_at).isoformat(),
'last_activity': datetime.fromtimestamp(session_info.last_activity).isoformat(),
'duration_seconds': int(time.time() - session_info.created_at),
'idle_seconds': int(time.time() - session_info.last_activity),
'request_count': session_info.request_count,
'resource_count': session_info.resource_count,
'total_bytes_used': session_info.total_bytes_used,
'resources': resources
}
def get_all_sessions_info(self) -> List[Dict[str, Any]]:
"""Get information about all active sessions"""
active_sessions = self.redis.smembers('active_sessions')
return [
self.get_session_info(session_id)
for session_id in active_sessions
if self.get_session_info(session_id)
]
def get_stats(self) -> Dict[str, Any]:
"""Get session manager statistics"""
active_sessions = self.redis.scard('active_sessions')
return {
'active_sessions': active_sessions,
'total_sessions_created': self.redis.get('stats:sessions:created', 0),
'total_sessions_cleaned': self.redis.get('stats:sessions:cleaned', 0),
'active_resources': self.redis.get('stats:resources:active', 0),
'total_resources_cleaned': self.redis.get('stats:resources:cleaned', 0),
'active_bytes': self.redis.get('stats:bytes:active', 0),
'total_bytes_cleaned': self.redis.get('stats:bytes:cleaned', 0)
}
def _save_session(self, session_info: SessionInfo):
"""Save session info to Redis"""
key = f'session:{session_info.session_id}'
data = asdict(session_info)
self.redis.set(key, data, expire=self.max_session_duration)
def _cleanup_resource(self, resource_data: Dict[str, Any]):
"""Clean up a resource (e.g., delete file)"""
import os
if resource_data.get('resource_type') in ['audio_file', 'temp_file']:
path = resource_data.get('path')
if path and os.path.exists(path):
try:
os.remove(path)
logger.debug(f"Removed file {path}")
except Exception as e:
logger.error(f"Failed to remove file {path}: {e}")
def _cleanup_oldest_resources(self, session_id: str, count: int):
"""Clean up oldest resources from a session"""
resource_ids = list(self.redis.smembers(f'session:{session_id}:resources'))
# Get resource creation times
resources_with_time = []
for resource_id in resource_ids:
resource_data = self.redis.get(f'session:{session_id}:resource:{resource_id}')
if resource_data:
resources_with_time.append((resource_id, resource_data.get('created_at', 0)))
# Sort by creation time and remove oldest
resources_with_time.sort(key=lambda x: x[1])
for resource_id, _ in resources_with_time[:count]:
self.remove_resource(session_id, resource_id)
def _cleanup_resources_by_size(self, session_id: str, bytes_to_free: int):
"""Clean up resources to free up space"""
resource_ids = list(self.redis.smembers(f'session:{session_id}:resources'))
# Get resource sizes
resources_with_size = []
for resource_id in resource_ids:
resource_data = self.redis.get(f'session:{session_id}:resource:{resource_id}')
if resource_data:
resources_with_size.append((resource_id, resource_data.get('size_bytes', 0)))
# Sort by size (largest first) and remove until we've freed enough
resources_with_size.sort(key=lambda x: x[1], reverse=True)
freed_bytes = 0
for resource_id, size in resources_with_size:
if freed_bytes >= bytes_to_free:
break
freed_bytes += size
self.remove_resource(session_id, resource_id)
def init_app(app):
"""Initialize Redis session management for Flask app"""
# Get Redis manager
redis_manager = getattr(app, 'redis', None)
if not redis_manager:
raise RuntimeError("Redis manager not initialized. Call init_redis() first.")
config = {
'max_session_duration': app.config.get('MAX_SESSION_DURATION', 3600),
'max_idle_time': app.config.get('MAX_SESSION_IDLE_TIME', 900),
'max_resources_per_session': app.config.get('MAX_RESOURCES_PER_SESSION', 100),
'max_bytes_per_session': app.config.get('MAX_BYTES_PER_SESSION', 100 * 1024 * 1024)
}
manager = RedisSessionManager(redis_manager, config)
app.redis_session_manager = manager
# Add before_request handler
@app.before_request
def before_request_session():
# Get or create session
session_id = session.get('session_id')
if not session_id:
session_id = str(uuid.uuid4())
session['session_id'] = session_id
session.permanent = True
# Get session from manager
user_session = manager.get_session(session_id)
if not user_session:
user_session = manager.create_session(
session_id=session_id,
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
# Update activity
manager.update_session_activity(session_id)
# Store in g for request access
g.user_session = user_session
g.session_manager = manager
logger.info("Redis session management initialized")