504 lines
18 KiB
Python
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"
|
|
)
|