""" Session management endpoints. Allows users to view and manage their active sessions across devices. """ import logging from typing import Any, List from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status, Request from slowapi import Limiter from slowapi.util import get_remote_address from sqlalchemy.ext.asyncio import AsyncSession from app.api.dependencies.auth import get_current_user from app.core.database_async import get_async_db from app.core.auth import decode_token from app.models.user import User from app.schemas.sessions import SessionResponse, SessionListResponse from app.schemas.common import MessageResponse from app.crud.session_async import session_async as session_crud from app.core.exceptions import NotFoundError, AuthorizationError, ErrorCode router = APIRouter() logger = logging.getLogger(__name__) # Initialize limiter limiter = Limiter(key_func=get_remote_address) @router.get( "/me", response_model=SessionListResponse, summary="List My Active Sessions", description=""" Get a list of all active sessions for the current user. This shows where you're currently logged in. **Rate Limit**: 30 requests/minute """, operation_id="list_my_sessions" ) @limiter.limit("30/minute") async def list_my_sessions( request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_async_db) ) -> Any: """ List all active sessions for the current user. Args: current_user: Current authenticated user db: Database session Returns: List of active sessions """ try: # Get all active sessions for user sessions = await session_crud.get_user_sessions( db, user_id=str(current_user.id), active_only=True ) # Try to identify current session from Authorization header current_session_jti = None auth_header = request.headers.get("authorization") if auth_header and auth_header.startswith("Bearer "): try: access_token = auth_header.split(" ")[1] token_payload = decode_token(access_token) # Note: Access tokens don't have JTI by default, but we can try # For now, we'll mark current based on most recent activity except Exception: pass # Convert to response format session_responses = [] for s in sessions: session_response = SessionResponse( id=s.id, device_name=s.device_name, device_id=s.device_id, ip_address=s.ip_address, location_city=s.location_city, location_country=s.location_country, last_used_at=s.last_used_at, created_at=s.created_at, expires_at=s.expires_at, is_current=(s == sessions[0] if sessions else False) # Most recent = current ) session_responses.append(session_response) logger.info(f"User {current_user.id} listed {len(session_responses)} active sessions") return SessionListResponse( sessions=session_responses, total=len(session_responses) ) except Exception as e: logger.error(f"Error listing sessions for user {current_user.id}: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve sessions" ) @router.delete( "/{session_id}", response_model=MessageResponse, status_code=status.HTTP_200_OK, summary="Revoke Specific Session", description=""" Revoke a specific session by ID. This logs you out from that particular device. You can only revoke your own sessions. **Rate Limit**: 10 requests/minute """, operation_id="revoke_session" ) @limiter.limit("10/minute") async def revoke_session( request: Request, session_id: UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_async_db) ) -> Any: """ Revoke a specific session by ID. Args: session_id: UUID of the session to revoke current_user: Current authenticated user db: Database session Returns: Success message """ try: # Get the session session = await session_crud.get(db, id=str(session_id)) if not session: raise NotFoundError( message=f"Session {session_id} not found", error_code=ErrorCode.NOT_FOUND ) # Verify session belongs to current user if str(session.user_id) != str(current_user.id): logger.warning( f"User {current_user.id} attempted to revoke session {session_id} " f"belonging to user {session.user_id}" ) raise AuthorizationError( message="You can only revoke your own sessions", error_code=ErrorCode.INSUFFICIENT_PERMISSIONS ) # Deactivate the session await session_crud.deactivate(db, session_id=str(session_id)) logger.info( f"User {current_user.id} revoked session {session_id} " f"({session.device_name})" ) return MessageResponse( success=True, message=f"Session revoked: {session.device_name or 'Unknown device'}" ) except (NotFoundError, AuthorizationError): raise except Exception as e: logger.error(f"Error revoking session {session_id}: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to revoke session" ) @router.delete( "/me/expired", response_model=MessageResponse, status_code=status.HTTP_200_OK, summary="Cleanup Expired Sessions", description=""" Remove expired sessions for the current user. This is a cleanup operation to remove old session records. **Rate Limit**: 5 requests/minute """, operation_id="cleanup_expired_sessions" ) @limiter.limit("5/minute") async def cleanup_expired_sessions( request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_async_db) ) -> Any: """ Cleanup expired sessions for the current user. Args: current_user: Current authenticated user db: Database session Returns: Success message with count of sessions cleaned """ try: from datetime import datetime, timezone # Get all sessions for user all_sessions = await session_crud.get_user_sessions( db, user_id=str(current_user.id), active_only=False ) # Delete expired and inactive sessions deleted_count = 0 for s in all_sessions: if not s.is_active and s.expires_at < datetime.now(timezone.utc): await db.delete(s) deleted_count += 1 await db.commit() logger.info(f"User {current_user.id} cleaned up {deleted_count} expired sessions") return MessageResponse( success=True, message=f"Cleaned up {deleted_count} expired sessions" ) except Exception as e: logger.error(f"Error cleaning up sessions for user {current_user.id}: {str(e)}", exc_info=True) await db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to cleanup sessions" )