Files
delphi-database/app/api/session_management.py
HotSwapp bac8cc4bd5 changes
2025-08-18 20:20:04 -05:00

504 lines
18 KiB
Python

"""
Session Management API for P2 security features
"""
from datetime import datetime, timezone, timedelta
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from app.database.base import get_db
from app.auth.security import get_current_user, get_admin_user
from app.models.user import User
from app.models.sessions import UserSession, SessionConfiguration, SessionSecurityEvent
from app.utils.session_manager import SessionManager, get_session_manager
from app.utils.responses import create_success_response as success_response
from app.core.logging import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/api/session", tags=["Session Management"])
# Pydantic schemas
class SessionInfo(BaseModel):
"""Session information response"""
session_id: str
user_id: int
ip_address: Optional[str] = None
user_agent: Optional[str] = None
device_fingerprint: Optional[str] = None
country: Optional[str] = None
city: Optional[str] = None
is_suspicious: bool = False
risk_score: int = 0
status: str
created_at: datetime
last_activity: datetime
expires_at: datetime
login_method: Optional[str] = None
class Config:
from_attributes = True
class SessionConfigurationSchema(BaseModel):
"""Session configuration schema"""
max_concurrent_sessions: int = Field(default=3, ge=1, le=20)
session_timeout_minutes: int = Field(default=480, ge=30, le=1440) # 30 min to 24 hours
idle_timeout_minutes: int = Field(default=60, ge=5, le=240) # 5 min to 4 hours
require_session_renewal: bool = True
renewal_interval_hours: int = Field(default=24, ge=1, le=168) # 1 hour to 1 week
force_logout_on_ip_change: bool = False
suspicious_activity_threshold: int = Field(default=5, ge=1, le=20)
allowed_countries: Optional[List[str]] = None
blocked_countries: Optional[List[str]] = None
class SessionConfigurationUpdate(BaseModel):
"""Session configuration update request"""
max_concurrent_sessions: Optional[int] = Field(None, ge=1, le=20)
session_timeout_minutes: Optional[int] = Field(None, ge=30, le=1440)
idle_timeout_minutes: Optional[int] = Field(None, ge=5, le=240)
require_session_renewal: Optional[bool] = None
renewal_interval_hours: Optional[int] = Field(None, ge=1, le=168)
force_logout_on_ip_change: Optional[bool] = None
suspicious_activity_threshold: Optional[int] = Field(None, ge=1, le=20)
allowed_countries: Optional[List[str]] = None
blocked_countries: Optional[List[str]] = None
class SecurityEventInfo(BaseModel):
"""Security event information"""
id: int
event_type: str
severity: str
description: str
ip_address: Optional[str] = None
country: Optional[str] = None
action_taken: Optional[str] = None
resolved: bool = False
timestamp: datetime
class Config:
from_attributes = True
# Session Management Endpoints
@router.get("/current", response_model=SessionInfo)
async def get_current_session(
request: Request,
current_user: User = Depends(get_current_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Get current session information"""
try:
# Extract session ID from request
session_id = request.headers.get("X-Session-ID") or request.cookies.get("session_id")
if not session_id:
# For JWT-based sessions, use a portion of the JWT as session identifier
auth_header = request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
session_id = auth_header[7:][:32]
if not session_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No session identifier found"
)
session = session_manager.validate_session(session_id, request)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found or expired"
)
return SessionInfo.from_orm(session)
except Exception as e:
logger.error(f"Error getting current session: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve session information"
)
@router.get("/list", response_model=List[SessionInfo])
async def list_user_sessions(
current_user: User = Depends(get_current_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""List all active sessions for current user"""
try:
sessions = session_manager.get_active_sessions(current_user.id)
return [SessionInfo.from_orm(session) for session in sessions]
except Exception as e:
logger.error(f"Error listing sessions: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve sessions"
)
@router.delete("/revoke/{session_id}")
async def revoke_session(
session_id: str,
current_user: User = Depends(get_current_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Revoke a specific session"""
try:
# Verify the session belongs to the current user
session = session_manager.db.query(UserSession).filter(
UserSession.session_id == session_id,
UserSession.user_id == current_user.id
).first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
success = session_manager.revoke_session(session_id, "user_revocation")
if success:
return success_response("Session revoked successfully")
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to revoke session"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error revoking session: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to revoke session"
)
@router.delete("/revoke-all")
async def revoke_all_sessions(
current_user: User = Depends(get_current_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Revoke all sessions for current user"""
try:
count = session_manager.revoke_all_user_sessions(current_user.id, "user_revoke_all")
return success_response(f"Revoked {count} sessions successfully")
except Exception as e:
logger.error(f"Error revoking all sessions: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to revoke sessions"
)
@router.get("/configuration", response_model=SessionConfigurationSchema)
async def get_session_configuration(
current_user: User = Depends(get_current_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Get session configuration for current user"""
try:
config = session_manager._get_session_config(current_user)
return SessionConfigurationSchema(
max_concurrent_sessions=config.max_concurrent_sessions,
session_timeout_minutes=config.session_timeout_minutes,
idle_timeout_minutes=config.idle_timeout_minutes,
require_session_renewal=config.require_session_renewal,
renewal_interval_hours=config.renewal_interval_hours,
force_logout_on_ip_change=config.force_logout_on_ip_change,
suspicious_activity_threshold=config.suspicious_activity_threshold,
allowed_countries=config.allowed_countries.split(",") if config.allowed_countries else None,
blocked_countries=config.blocked_countries.split(",") if config.blocked_countries else None
)
except Exception as e:
logger.error(f"Error getting session configuration: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve session configuration"
)
@router.put("/configuration")
async def update_session_configuration(
config_update: SessionConfigurationUpdate,
current_user: User = Depends(get_current_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Update session configuration for current user"""
try:
config = session_manager._get_session_config(current_user)
# Ensure user-specific config exists
if config.user_id is None:
# Create user-specific config based on global config
user_config = SessionConfiguration(
user_id=current_user.id,
max_concurrent_sessions=config.max_concurrent_sessions,
session_timeout_minutes=config.session_timeout_minutes,
idle_timeout_minutes=config.idle_timeout_minutes,
require_session_renewal=config.require_session_renewal,
renewal_interval_hours=config.renewal_interval_hours,
force_logout_on_ip_change=config.force_logout_on_ip_change,
suspicious_activity_threshold=config.suspicious_activity_threshold,
allowed_countries=config.allowed_countries,
blocked_countries=config.blocked_countries
)
session_manager.db.add(user_config)
session_manager.db.flush()
config = user_config
# Update configuration
update_data = config_update.dict(exclude_unset=True)
for field, value in update_data.items():
if field in ["allowed_countries", "blocked_countries"] and value:
setattr(config, field, ",".join(value))
else:
setattr(config, field, value)
config.updated_at = datetime.now(timezone.utc)
session_manager.db.commit()
return success_response("Session configuration updated successfully")
except Exception as e:
logger.error(f"Error updating session configuration: {str(e)}")
session_manager.db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update session configuration"
)
@router.get("/security-events", response_model=List[SecurityEventInfo])
async def get_security_events(
limit: int = 50,
resolved: Optional[bool] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get security events for current user"""
try:
query = db.query(SessionSecurityEvent).filter(
SessionSecurityEvent.user_id == current_user.id
)
if resolved is not None:
query = query.filter(SessionSecurityEvent.resolved == resolved)
events = query.order_by(SessionSecurityEvent.timestamp.desc()).limit(limit).all()
return [SecurityEventInfo.from_orm(event) for event in events]
except Exception as e:
logger.error(f"Error getting security events: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve security events"
)
@router.get("/statistics")
async def get_session_statistics(
current_user: User = Depends(get_current_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Get session statistics for current user"""
try:
stats = session_manager.get_session_statistics(current_user.id)
return success_response(data=stats)
except Exception as e:
logger.error(f"Error getting session statistics: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve session statistics"
)
# Admin endpoints
@router.get("/admin/sessions", response_model=List[SessionInfo])
async def admin_list_all_sessions(
user_id: Optional[int] = None,
limit: int = 100,
admin_user: User = Depends(get_admin_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Admin: List sessions for all users or specific user"""
try:
if user_id:
sessions = session_manager.get_active_sessions(user_id)
else:
sessions = session_manager.db.query(UserSession).filter(
UserSession.status == "active"
).order_by(UserSession.last_activity.desc()).limit(limit).all()
return [SessionInfo.from_orm(session) for session in sessions]
except Exception as e:
logger.error(f"Error getting admin sessions: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve sessions"
)
@router.delete("/admin/revoke/{session_id}")
async def admin_revoke_session(
session_id: str,
reason: str = "admin_revocation",
admin_user: User = Depends(get_admin_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Admin: Revoke any session"""
try:
success = session_manager.revoke_session(session_id, f"admin_revocation: {reason}")
if success:
return success_response(f"Session {session_id} revoked successfully")
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error admin revoking session: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to revoke session"
)
@router.delete("/admin/revoke-user/{user_id}")
async def admin_revoke_user_sessions(
user_id: int,
reason: str = "admin_action",
admin_user: User = Depends(get_admin_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Admin: Revoke all sessions for a specific user"""
try:
count = session_manager.revoke_all_user_sessions(user_id, f"admin_action: {reason}")
return success_response(f"Revoked {count} sessions for user {user_id}")
except Exception as e:
logger.error(f"Error admin revoking user sessions: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to revoke user sessions"
)
@router.get("/admin/global-configuration", response_model=SessionConfigurationSchema)
async def admin_get_global_configuration(
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Admin: Get global session configuration"""
try:
config = db.query(SessionConfiguration).filter(
SessionConfiguration.user_id.is_(None)
).first()
if not config:
# Create default global config
config = SessionConfiguration()
db.add(config)
db.commit()
return SessionConfigurationSchema(
max_concurrent_sessions=config.max_concurrent_sessions,
session_timeout_minutes=config.session_timeout_minutes,
idle_timeout_minutes=config.idle_timeout_minutes,
require_session_renewal=config.require_session_renewal,
renewal_interval_hours=config.renewal_interval_hours,
force_logout_on_ip_change=config.force_logout_on_ip_change,
suspicious_activity_threshold=config.suspicious_activity_threshold,
allowed_countries=config.allowed_countries.split(",") if config.allowed_countries else None,
blocked_countries=config.blocked_countries.split(",") if config.blocked_countries else None
)
except Exception as e:
logger.error(f"Error getting global session configuration: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve global session configuration"
)
@router.put("/admin/global-configuration")
async def admin_update_global_configuration(
config_update: SessionConfigurationUpdate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Admin: Update global session configuration"""
try:
config = db.query(SessionConfiguration).filter(
SessionConfiguration.user_id.is_(None)
).first()
if not config:
config = SessionConfiguration()
db.add(config)
db.flush()
# Update configuration
update_data = config_update.dict(exclude_unset=True)
for field, value in update_data.items():
if field in ["allowed_countries", "blocked_countries"] and value:
setattr(config, field, ",".join(value))
else:
setattr(config, field, value)
config.updated_at = datetime.now(timezone.utc)
db.commit()
return success_response("Global session configuration updated successfully")
except Exception as e:
logger.error(f"Error updating global session configuration: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update global session configuration"
)
@router.get("/admin/statistics")
async def admin_get_global_statistics(
admin_user: User = Depends(get_admin_user),
session_manager: SessionManager = Depends(get_session_manager)
):
"""Admin: Get global session statistics"""
try:
stats = session_manager.get_session_statistics()
return success_response(data=stats)
except Exception as e:
logger.error(f"Error getting global session statistics: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve global session statistics"
)