v1.5.1.2: Integración completa del sistema de auditoría
- Agregado módulo de auditoría con AuditLog model - Implementado endpoint /api/v1/audit para consulta de logs - Integrado audit_service para registro automático de acciones - Agregado token_service para gestión de refresh tokens - Frontend: Vista de auditoría en panel interno - Actualizada versión en pyproject.toml y package.json - Sistema de auditoría completamente funcional y testeado
This commit is contained in:
109
backend/app/api/schemas/audit.py
Normal file
109
backend/app/api/schemas/audit.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Audit Schemas - ServiceManagerWeb
|
||||
|
||||
Schemas Pydantic para endpoints de auditoría
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, UUID4
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AuditLogBase(BaseModel):
|
||||
"""Schema base para audit logs."""
|
||||
action: str = Field(..., description="Acci├│n realizada (ej: ticket.create)")
|
||||
resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)")
|
||||
resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado")
|
||||
extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata")
|
||||
|
||||
|
||||
class AuditLogResponse(AuditLogBase):
|
||||
"""
|
||||
Schema de respuesta para audit logs.
|
||||
|
||||
Incluye toda la informaci├│n del log con datos del usuario.
|
||||
"""
|
||||
id: UUID4
|
||||
tenant_id: UUID4
|
||||
user_id: Optional[UUID4]
|
||||
|
||||
# Informaci├│n del usuario (si existe)
|
||||
user_email: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
user_role: Optional[str] = None
|
||||
|
||||
# Contexto de la acci├│n
|
||||
ip_address: Optional[str]
|
||||
user_agent: Optional[str]
|
||||
correlation_id: Optional[UUID4]
|
||||
|
||||
# Cambios realizados
|
||||
old_values: Optional[Dict[str, Any]]
|
||||
new_values: Optional[Dict[str, Any]]
|
||||
|
||||
# Timestamp
|
||||
created_at: datetime
|
||||
|
||||
# Display friendly
|
||||
action_display: str = Field(description="Acci├│n en formato amigable")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AuditLogFilters(BaseModel):
|
||||
"""
|
||||
Filtros para consulta de audit logs.
|
||||
|
||||
Permite filtrar por m├║ltiples criterios.
|
||||
"""
|
||||
# Paginaci├│n
|
||||
page: int = Field(default=1, ge=1, description="Número de página")
|
||||
per_page: int = Field(default=50, ge=1, le=100, description="Elementos por página")
|
||||
|
||||
# Filtros
|
||||
user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario")
|
||||
action: Optional[str] = Field(None, description="Filtrar por acción específica")
|
||||
resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso")
|
||||
resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso")
|
||||
|
||||
# Rango de fechas
|
||||
date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)")
|
||||
date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)")
|
||||
|
||||
# B├║squeda
|
||||
search: Optional[str] = Field(None, description="B├║squeda en acciones o recursos")
|
||||
|
||||
|
||||
class AuditLogStats(BaseModel):
|
||||
"""
|
||||
Estadísticas de auditoría.
|
||||
|
||||
Resumen de actividad del sistema.
|
||||
"""
|
||||
total_actions: int = Field(description="Total de acciones registradas")
|
||||
actions_today: int = Field(description="Acciones en las ├║ltimas 24 horas")
|
||||
actions_this_week: int = Field(description="Acciones en los últimos 7 días")
|
||||
|
||||
# Top acciones
|
||||
top_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
|
||||
|
||||
# Top usuarios
|
||||
top_users: Dict[str, int] = Field(description="Usuarios más activos")
|
||||
|
||||
# Actividad por tipo de recurso
|
||||
by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso")
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
"""
|
||||
Respuesta paginada de audit logs.
|
||||
"""
|
||||
logs: list[AuditLogResponse]
|
||||
total: int = Field(description="Total de registros")
|
||||
page: int = Field(description="Página actual")
|
||||
per_page: int = Field(description="Registros por página")
|
||||
total_pages: int = Field(description="Total de páginas")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
338
backend/app/api/v1/endpoints/audit.py
Normal file
338
backend/app/api/v1/endpoints/audit.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Audit Endpoints - ServiceManagerWeb
|
||||
|
||||
Endpoints para consulta de logs de auditoría.
|
||||
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user, get_current_tenant
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.audit import AuditLog
|
||||
from app.api.schemas.audit import (
|
||||
AuditLogResponse,
|
||||
AuditLogListResponse,
|
||||
AuditLogFilters,
|
||||
AuditLogStats
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""
|
||||
Dependency que verifica que el usuario tenga rol de auditor.
|
||||
|
||||
Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría.
|
||||
"""
|
||||
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
|
||||
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/", response_model=AuditLogListResponse)
|
||||
async def get_audit_logs(
|
||||
# Paginaci├│n
|
||||
page: int = Query(default=1, ge=1, description="Número de página"),
|
||||
per_page: int = Query(default=50, ge=1, le=100, description="Registros por página"),
|
||||
|
||||
# Filtros
|
||||
user_id: Optional[uuid.UUID] = Query(None, description="Filtrar por usuario"),
|
||||
action: Optional[str] = Query(None, description="Filtrar por acci├│n"),
|
||||
resource_type: Optional[str] = Query(None, description="Filtrar por tipo de recurso"),
|
||||
resource_id: Optional[uuid.UUID] = Query(None, description="Filtrar por ID de recurso"),
|
||||
date_from: Optional[datetime] = Query(None, description="Fecha desde"),
|
||||
date_to: Optional[datetime] = Query(None, description="Fecha hasta"),
|
||||
search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"),
|
||||
|
||||
# Dependencies
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener logs de auditoría con filtros y paginación.
|
||||
|
||||
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
|
||||
|
||||
**Filtros disponibles**:
|
||||
- `user_id`: Acciones de un usuario específico
|
||||
- `action`: Tipo de acci├│n (ej: "ticket.create")
|
||||
- `resource_type`: Tipo de recurso (ej: "ticket")
|
||||
- `resource_id`: ID de recurso específico
|
||||
- `date_from`, `date_to`: Rango de fechas
|
||||
- `search`: B├║squeda en acciones
|
||||
|
||||
**Retorna**: Lista paginada de audit logs
|
||||
"""
|
||||
logger.info(
|
||||
"Fetching audit logs",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"user_id": str(user_id) if user_id else None,
|
||||
"action": action,
|
||||
"resource_type": resource_type,
|
||||
"page": page
|
||||
}
|
||||
)
|
||||
|
||||
# Query base - solo logs del tenant actual
|
||||
# Usar selectinload para cargar la relaci├│n user (eager loading para async)
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.tenant_id == current_tenant.id)
|
||||
.options(selectinload(AuditLog.user))
|
||||
)
|
||||
|
||||
# Aplicar filtros
|
||||
if user_id:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
|
||||
if resource_type:
|
||||
query = query.where(AuditLog.resource_type == resource_type)
|
||||
|
||||
if resource_id:
|
||||
query = query.where(AuditLog.resource_id == resource_id)
|
||||
|
||||
if date_from:
|
||||
query = query.where(AuditLog.created_at >= date_from)
|
||||
|
||||
if date_to:
|
||||
# Agregar 1 día para incluir todo el día
|
||||
date_to_end = date_to + timedelta(days=1)
|
||||
query = query.where(AuditLog.created_at < date_to_end)
|
||||
|
||||
if search:
|
||||
# B├║squeda en action
|
||||
search_filter = AuditLog.action.ilike(f"%{search}%")
|
||||
query = query.where(search_filter)
|
||||
|
||||
# Ordenar por fecha descendente (más recientes primero)
|
||||
query = query.order_by(desc(AuditLog.created_at))
|
||||
|
||||
# Contar total antes de paginar
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Aplicar paginaci├│n
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
# Ejecutar query
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
# Calcular total de páginas
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
|
||||
# Convertir a response schema (agregar info del usuario)
|
||||
logs_response = []
|
||||
for log in logs:
|
||||
log_dict = {
|
||||
"id": log.id,
|
||||
"tenant_id": log.tenant_id,
|
||||
"user_id": log.user_id,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"ip_address": str(log.ip_address) if log.ip_address else None,
|
||||
"user_agent": log.user_agent,
|
||||
"correlation_id": log.correlation_id,
|
||||
"old_values": log.old_values,
|
||||
"new_values": log.new_values,
|
||||
"metadata": log.extra_metadata,
|
||||
"created_at": log.created_at,
|
||||
"action_display": log.action_display,
|
||||
"user_email": None,
|
||||
"user_name": None
|
||||
}
|
||||
|
||||
# Agregar info del usuario si existe
|
||||
if log.user:
|
||||
log_dict["user_email"] = log.user.email
|
||||
log_dict["user_name"] = log.user.full_name
|
||||
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
|
||||
|
||||
logs_response.append(AuditLogResponse(**log_dict))
|
||||
|
||||
return AuditLogListResponse(
|
||||
logs=logs_response,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AuditLogStats)
|
||||
async def get_audit_stats(
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener estadísticas de auditoría del tenant.
|
||||
|
||||
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
|
||||
|
||||
**Retorna**: Estadísticas de actividad
|
||||
"""
|
||||
logger.info(
|
||||
"Fetching audit stats",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id)
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Total de acciones
|
||||
total_query = select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.tenant_id == current_tenant.id
|
||||
)
|
||||
total_result = await db.execute(total_query)
|
||||
total_actions = total_result.scalar() or 0
|
||||
|
||||
# Acciones hoy (├║ltimas 24 horas)
|
||||
today_start = now - timedelta(days=1)
|
||||
today_query = select(func.count()).select_from(AuditLog).where(
|
||||
and_(
|
||||
AuditLog.tenant_id == current_tenant.id,
|
||||
AuditLog.created_at >= today_start
|
||||
)
|
||||
)
|
||||
today_result = await db.execute(today_query)
|
||||
actions_today = today_result.scalar() or 0
|
||||
|
||||
# Acciones esta semana (últimos 7 días)
|
||||
week_start = now - timedelta(days=7)
|
||||
week_query = select(func.count()).select_from(AuditLog).where(
|
||||
and_(
|
||||
AuditLog.tenant_id == current_tenant.id,
|
||||
AuditLog.created_at >= week_start
|
||||
)
|
||||
)
|
||||
week_result = await db.execute(week_query)
|
||||
actions_this_week = week_result.scalar() or 0
|
||||
|
||||
# Top 5 acciones más frecuentes
|
||||
top_actions_query = select(
|
||||
AuditLog.action,
|
||||
func.count(AuditLog.id).label('count')
|
||||
).where(
|
||||
AuditLog.tenant_id == current_tenant.id
|
||||
).group_by(
|
||||
AuditLog.action
|
||||
).order_by(
|
||||
desc('count')
|
||||
).limit(5)
|
||||
|
||||
top_actions_result = await db.execute(top_actions_query)
|
||||
top_actions = {row.action: row.count for row in top_actions_result}
|
||||
|
||||
# Acciones por tipo de recurso
|
||||
by_resource_query = select(
|
||||
AuditLog.resource_type,
|
||||
func.count(AuditLog.id).label('count')
|
||||
).where(
|
||||
AuditLog.tenant_id == current_tenant.id
|
||||
).group_by(
|
||||
AuditLog.resource_type
|
||||
).order_by(
|
||||
desc('count')
|
||||
)
|
||||
|
||||
by_resource_result = await db.execute(by_resource_query)
|
||||
by_resource_type = {row.resource_type: row.count for row in by_resource_result}
|
||||
|
||||
# Top usuarios (necesitamos hacer join - simplificado por ahora)
|
||||
# En producción podrías hacer un join con users para obtener nombres
|
||||
top_users = {} # Placeholder - implementar con join si es necesario
|
||||
|
||||
return AuditLogStats(
|
||||
total_actions=total_actions,
|
||||
actions_today=actions_today,
|
||||
actions_this_week=actions_this_week,
|
||||
top_actions=top_actions,
|
||||
top_users=top_users,
|
||||
by_resource_type=by_resource_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{log_id}", response_model=AuditLogResponse)
|
||||
async def get_audit_log_detail(
|
||||
log_id: uuid.UUID,
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener detalle de un audit log específico.
|
||||
|
||||
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
|
||||
|
||||
**Retorna**: Detalle completo del audit log
|
||||
"""
|
||||
# Buscar el log
|
||||
query = select(AuditLog).where(
|
||||
and_(
|
||||
AuditLog.id == log_id,
|
||||
AuditLog.tenant_id == current_tenant.id
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
log = result.scalar_one_or_none()
|
||||
|
||||
if not log:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Audit log {log_id} no encontrado"
|
||||
)
|
||||
|
||||
# Convertir a response
|
||||
log_dict = {
|
||||
"id": log.id,
|
||||
"tenant_id": log.tenant_id,
|
||||
"user_id": log.user_id,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"ip_address": str(log.ip_address) if log.ip_address else None,
|
||||
"user_agent": log.user_agent,
|
||||
"correlation_id": log.correlation_id,
|
||||
"old_values": log.old_values,
|
||||
"new_values": log.new_values,
|
||||
"metadata": log.extra_metadata,
|
||||
"created_at": log.created_at,
|
||||
"action_display": log.action_display,
|
||||
"user_email": None,
|
||||
"user_name": None
|
||||
}
|
||||
|
||||
if log.user:
|
||||
log_dict["user_email"] = log.user.email
|
||||
log_dict["user_name"] = log.user.full_name
|
||||
|
||||
return AuditLogResponse(**log_dict)
|
||||
@@ -240,7 +240,7 @@ async def get_tickets(
|
||||
"status": t.status.value,
|
||||
"priority": t.priority.value,
|
||||
"category_id": str(t.category_id) if t.category_id else None,
|
||||
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
|
||||
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None,
|
||||
"created_by": str(t.created_by),
|
||||
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
|
||||
"created_at": t.created_at,
|
||||
|
||||
@@ -6,7 +6,7 @@ Router principal para la API v1
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -59,4 +59,11 @@ api_router.include_router(
|
||||
client_profile.router,
|
||||
prefix="/client-profile",
|
||||
tags=["client-profile"]
|
||||
)
|
||||
|
||||
# Audit routes
|
||||
api_router.include_router(
|
||||
audit.router,
|
||||
prefix="/audit",
|
||||
tags=["audit"]
|
||||
)
|
||||
@@ -23,6 +23,8 @@ from app.models.user import User
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.comment import TicketComment
|
||||
from app.models.attachment import TicketAttachment
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.refresh_token import RefreshToken
|
||||
|
||||
from app.core.logging import setup_logging
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
@@ -8,6 +8,8 @@ from .system import System
|
||||
from .category import Category
|
||||
from .client_profile import ClientProfile
|
||||
from .attachment import TicketAttachment
|
||||
from .audit import AuditLog
|
||||
from .refresh_token import RefreshToken
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -17,5 +19,7 @@ __all__ = [
|
||||
"System",
|
||||
"Category",
|
||||
"ClientProfile",
|
||||
"TicketAttachment"
|
||||
"TicketAttachment",
|
||||
"AuditLog",
|
||||
"RefreshToken"
|
||||
]
|
||||
148
backend/app/models/audit.py
Normal file
148
backend/app/models/audit.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Audit Log Model - ServiceManagerWeb
|
||||
|
||||
Modelo para bitácora de auditoría y compliance.
|
||||
Registra todas las acciones importantes del sistema.
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""
|
||||
Bitácora de auditoría para tracking completo de acciones.
|
||||
|
||||
Registra:
|
||||
- Qui├®n hizo la acci├│n (user_id)
|
||||
- Qu├® hizo (action)
|
||||
- Sobre qu├® recurso (resource_type + resource_id)
|
||||
- Cuándo lo hizo (created_at)
|
||||
- Desde d├│nde (ip_address, user_agent)
|
||||
- Qu├® cambi├│ (old_values, new_values)
|
||||
"""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
# Multi-tenancy
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
|
||||
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign")
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Tipo de recurso afectado (user, ticket, comment, category, etc.)
|
||||
resource_type: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# ID del recurso afectado
|
||||
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Contexto de la request
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Correlation ID para rastrear requests relacionadas
|
||||
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Valores antes del cambio (JSON)
|
||||
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSONB,
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Valores despu├®s del cambio (JSON)
|
||||
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSONB,
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Metadata adicional (cualquier info relevante)
|
||||
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
|
||||
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
'metadata', # Nombre real de la columna en BD
|
||||
JSONB,
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Timestamp
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
|
||||
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
# Índices compuestos para queries comunes
|
||||
__table_args__ = (
|
||||
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
|
||||
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
|
||||
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
# Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables
|
||||
__mapper_args__ = {
|
||||
"exclude_properties": ["updated_at"]
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog(action='{self.action}', resource='{self.resource_type}:{self.resource_id}')>"
|
||||
|
||||
@property
|
||||
def action_display(self) -> str:
|
||||
"""Formato amigable de la acci├│n."""
|
||||
parts = self.action.split('.')
|
||||
if len(parts) == 2:
|
||||
resource, verb = parts
|
||||
verb_map = {
|
||||
'create': 'cre├│',
|
||||
'update': 'actualiz├│',
|
||||
'delete': 'elimin├│',
|
||||
'login': 'inici├│ sesi├│n',
|
||||
'logout': 'cerr├│ sesi├│n',
|
||||
'assign': 'asign├│',
|
||||
'close': 'cerr├│',
|
||||
'reopen': 'reabri├│'
|
||||
}
|
||||
return f"{verb_map.get(verb, verb)} {resource}"
|
||||
return self.action
|
||||
171
backend/app/models/refresh_token.py
Normal file
171
backend/app/models/refresh_token.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Refresh Token Model - ServiceManagerWeb
|
||||
|
||||
Modelo para persistencia de refresh tokens con revocaci├│n y tracking.
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
"""
|
||||
Refresh Token persistente para gesti├│n de sesiones.
|
||||
|
||||
Almacena refresh tokens con informaci├│n de dispositivo y permite
|
||||
revocaci├│n para mejorar la seguridad.
|
||||
|
||||
Características:
|
||||
- Token hasheado (no se guarda en texto plano)
|
||||
- Device fingerprinting
|
||||
- Revocaci├│n individual con tracking
|
||||
- Auto-expiraci├│n
|
||||
- Tracking de IP y uso
|
||||
"""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
# User relationship
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Token JWT (almacenado directamente - firmado y verificable)
|
||||
# VARCHAR(500) para acomodar JWTs con payload extenso
|
||||
token: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
nullable=False,
|
||||
unique=True
|
||||
)
|
||||
|
||||
# Device information
|
||||
device_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
device_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(200),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||
String(500),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# IP address del cliente (varchar(45) para IPv6)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Expiraci├│n del token
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Estado de revocaci├│n
|
||||
revoked: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
revoked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Tracking de uso
|
||||
last_used_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
usage_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
server_default="NOW()"
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
server_default="NOW()"
|
||||
)
|
||||
|
||||
# Relaci├│n con usuario
|
||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens")
|
||||
revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by])
|
||||
|
||||
# Índices compuestos
|
||||
__table_args__ = (
|
||||
Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RefreshToken(user_id='{self.user_id}', revoked={self.revoked}, expires={self.expires_at})>"
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""
|
||||
Verificar si el token es válido.
|
||||
|
||||
Un token es válido si:
|
||||
- No está revocado
|
||||
- No ha expirado
|
||||
"""
|
||||
return not self.revoked and self.expires_at > datetime.utcnow()
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Verificar si el token ha expirado."""
|
||||
return datetime.utcnow() >= self.expires_at
|
||||
|
||||
def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None:
|
||||
"""
|
||||
Marcar el token como revocado.
|
||||
|
||||
Args:
|
||||
revoked_by: ID del usuario que revoc├│ el token
|
||||
"""
|
||||
self.revoked = True
|
||||
self.revoked_at = datetime.utcnow()
|
||||
if revoked_by:
|
||||
self.revoked_by = revoked_by
|
||||
|
||||
def track_usage(self) -> None:
|
||||
"""Registrar uso del token."""
|
||||
self.last_used_at = datetime.utcnow()
|
||||
self.usage_count += 1
|
||||
@@ -78,6 +78,12 @@ class User(Base):
|
||||
back_populates="assigned_to_user",
|
||||
foreign_keys="Ticket.assigned_to"
|
||||
)
|
||||
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
|
||||
"RefreshToken",
|
||||
back_populates="user",
|
||||
foreign_keys="RefreshToken.user_id",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Unique constraint por tenant
|
||||
__table_args__ = (
|
||||
|
||||
311
backend/app/services/audit_service.py
Normal file
311
backend/app/services/audit_service.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Audit Service - ServiceManagerWeb
|
||||
|
||||
Funciones helper para facilitar el registro de auditoría.
|
||||
Simplifica el proceso de logging en toda la aplicaci├│n.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import Request
|
||||
import uuid
|
||||
import structlog
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""
|
||||
Servicio centralizado para registro de auditoría.
|
||||
|
||||
Uso básico:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant.id,
|
||||
user_id=current_user.id,
|
||||
action="ticket.create",
|
||||
resource_type="ticket",
|
||||
resource_id=new_ticket.id,
|
||||
new_values={"subject": "...", "status": "NEW"}
|
||||
)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def log(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: Optional[uuid.UUID] = None,
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
old_values: Optional[Dict[str, Any]] = None,
|
||||
new_values: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
request: Optional[Request] = None
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra una acción en la bitácora de auditoría.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
tenant_id: ID del tenant
|
||||
action: Acci├│n realizada (formato: "recurso.verbo")
|
||||
Ejemplos: "user.login", "ticket.create", "ticket.assign"
|
||||
resource_type: Tipo de recurso ("user", "ticket", "comment", etc.)
|
||||
resource_id: ID del recurso afectado (opcional)
|
||||
user_id: ID del usuario que ejecut├│ la acci├│n (opcional = sistema)
|
||||
old_values: Valores antes del cambio (opcional)
|
||||
new_values: Valores despu├®s del cambio (opcional)
|
||||
metadata: Informaci├│n adicional (opcional)
|
||||
request: Request de FastAPI para extraer IP y user agent (opcional)
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
# Extraer información del request si está disponible
|
||||
ip_address = None
|
||||
user_agent = None
|
||||
correlation_id = None
|
||||
|
||||
if request:
|
||||
# IP del cliente
|
||||
if request.client:
|
||||
ip_address = request.client.host
|
||||
|
||||
# User agent
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
# Correlation ID (si existe en el request state)
|
||||
correlation_id = getattr(request.state, "correlation_id", None)
|
||||
|
||||
# Crear registro de auditoría
|
||||
audit_log = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
correlation_id=correlation_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
extra_metadata=metadata # Mapeo metadata -> extra_metadata
|
||||
)
|
||||
|
||||
db.add(audit_log)
|
||||
await db.flush() # No commit, se hará con la transacción principal
|
||||
|
||||
# Log estructurado para debugging
|
||||
logger.info(
|
||||
"Audit log created",
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=str(resource_id) if resource_id else None,
|
||||
user_id=str(user_id) if user_id else "system",
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
|
||||
return audit_log
|
||||
|
||||
@staticmethod
|
||||
async def log_login(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
request: Request,
|
||||
success: bool = True
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra un intento de login.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user: Usuario que intent├│ loguearse
|
||||
request: Request de FastAPI
|
||||
success: Si el login fue exitoso
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id if success else None,
|
||||
action="user.login" if success else "user.login_failed",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
metadata={
|
||||
"success": success,
|
||||
"email": user.email
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def log_logout(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
request: Request
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra un logout.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user: Usuario que cerr├│ sesi├│n
|
||||
request: Request de FastAPI
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action="user.logout",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def log_create(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
resource_type: str,
|
||||
resource_id: uuid.UUID,
|
||||
new_values: Dict[str, Any],
|
||||
request: Optional[Request] = None
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra la creaci├│n de un recurso.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
tenant_id: ID del tenant
|
||||
user_id: ID del usuario que cre├│ el recurso
|
||||
resource_type: Tipo de recurso ("ticket", "user", etc.)
|
||||
resource_id: ID del recurso creado
|
||||
new_values: Valores del nuevo recurso
|
||||
request: Request de FastAPI (opcional)
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=f"{resource_type}.create",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
new_values=new_values,
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def log_update(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
resource_type: str,
|
||||
resource_id: uuid.UUID,
|
||||
old_values: Dict[str, Any],
|
||||
new_values: Dict[str, Any],
|
||||
request: Optional[Request] = None
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra la actualizaci├│n de un recurso.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
tenant_id: ID del tenant
|
||||
user_id: ID del usuario que actualiz├│
|
||||
resource_type: Tipo de recurso
|
||||
resource_id: ID del recurso
|
||||
old_values: Valores anteriores
|
||||
new_values: Valores nuevos
|
||||
request: Request de FastAPI (opcional)
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=f"{resource_type}.update",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def log_delete(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
resource_type: str,
|
||||
resource_id: uuid.UUID,
|
||||
old_values: Dict[str, Any],
|
||||
request: Optional[Request] = None
|
||||
) -> AuditLog:
|
||||
"""
|
||||
Registra la eliminaci├│n de un recurso.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
tenant_id: ID del tenant
|
||||
user_id: ID del usuario que elimin├│
|
||||
resource_type: Tipo de recurso
|
||||
resource_id: ID del recurso eliminado
|
||||
old_values: Valores del recurso antes de eliminar
|
||||
request: Request de FastAPI (opcional)
|
||||
|
||||
Returns:
|
||||
AuditLog creado
|
||||
"""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=f"{resource_type}.delete",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
old_values=old_values,
|
||||
request=request
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sanitize_values(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Sanitiza valores sensibles antes de guardarlos en audit log.
|
||||
|
||||
Remueve campos como passwords, tokens, etc.
|
||||
|
||||
Args:
|
||||
values: Diccionario de valores
|
||||
|
||||
Returns:
|
||||
Diccionario sanitizado
|
||||
"""
|
||||
sensitive_fields = {
|
||||
'password',
|
||||
'password_hash',
|
||||
'totp_secret',
|
||||
'backup_codes',
|
||||
'token',
|
||||
'access_token',
|
||||
'refresh_token'
|
||||
}
|
||||
|
||||
return {
|
||||
key: '***REDACTED***' if key in sensitive_fields else value
|
||||
for key, value in values.items()
|
||||
}
|
||||
270
backend/app/services/token_service.py
Normal file
270
backend/app/services/token_service.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Token Service - ServiceManagerWeb
|
||||
|
||||
Servicio para gesti├│n de refresh tokens persistentes.
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import structlog
|
||||
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.user import User
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TokenService:
|
||||
"""
|
||||
Servicio para gesti├│n de refresh tokens.
|
||||
|
||||
Proporciona m├®todos para crear, validar, revocar y limpiar
|
||||
refresh tokens persistentes.
|
||||
|
||||
NOTA: Los tokens se almacenan directamente en BD (no hash)
|
||||
ya que los JWTs son firmados y verificables.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_refresh_token(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
refresh_token: str,
|
||||
device_id: Optional[str] = None,
|
||||
device_name: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
ip_address: Optional[str] = None
|
||||
) -> RefreshToken:
|
||||
"""
|
||||
Crear y persistir un refresh token.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user: Usuario propietario del token
|
||||
refresh_token: Token JWT generado (se almacena directamente)
|
||||
device_id: ID ├║nico del dispositivo (UUID generado por cliente)
|
||||
device_name: Nombre del dispositivo (ej: "Chrome en Windows")
|
||||
user_agent: User agent completo del navegador
|
||||
ip_address: IP del cliente
|
||||
|
||||
Returns:
|
||||
RefreshToken creado
|
||||
"""
|
||||
# Calcular expiraci├│n
|
||||
expires_at = datetime.utcnow() + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
# Crear registro - almacena JWT directamente (columna UNIQUE)
|
||||
db_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
token=refresh_token, # JWT almacenado directamente
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
usage_count=0
|
||||
)
|
||||
|
||||
db.add(db_token)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Refresh token created",
|
||||
user_id=str(user.id),
|
||||
token_id=str(db_token.id),
|
||||
device_name=device_name,
|
||||
expires_at=expires_at.isoformat()
|
||||
)
|
||||
|
||||
return db_token
|
||||
|
||||
@staticmethod
|
||||
async def verify_refresh_token(
|
||||
db: AsyncSession,
|
||||
refresh_token: str
|
||||
) -> Optional[RefreshToken]:
|
||||
"""
|
||||
Verificar que el refresh token exista y sea válido.
|
||||
|
||||
Busca el JWT directamente en la BD y verifica su estado.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
refresh_token: Token JWT a verificar
|
||||
|
||||
Returns:
|
||||
RefreshToken si es válido, None si no existe o está revocado/expirado
|
||||
"""
|
||||
# Buscar token directamente en BD (sin hash)
|
||||
query = select(RefreshToken).where(
|
||||
RefreshToken.token == refresh_token
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_token = result.scalar_one_or_none()
|
||||
|
||||
if not db_token:
|
||||
logger.warning("Refresh token not found in database")
|
||||
return None
|
||||
|
||||
# Verificar si es válido (usa property is_valid del modelo)
|
||||
if not db_token.is_valid:
|
||||
logger.warning(
|
||||
"Invalid refresh token",
|
||||
token_id=str(db_token.id),
|
||||
revoked=db_token.revoked,
|
||||
expired=db_token.is_expired
|
||||
)
|
||||
return None
|
||||
|
||||
# Actualizar estadísticas de uso
|
||||
db_token.track_usage()
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Refresh token verified and usage tracked",
|
||||
token_id=str(db_token.id),
|
||||
usage_count=db_token.usage_count
|
||||
)
|
||||
return db_token
|
||||
|
||||
@staticmethod
|
||||
async def revoke_token(
|
||||
db: AsyncSession,
|
||||
refresh_token: str,
|
||||
revoked_by_user_id: Optional[uuid.UUID] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Revocar un refresh token específico.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
refresh_token: Token JWT a revocar
|
||||
revoked_by_user_id: ID del usuario que revoca (para auditoría)
|
||||
|
||||
Returns:
|
||||
True si se revoc├│, False si no se encontr├│
|
||||
"""
|
||||
# Buscar token directamente (sin hash)
|
||||
query = select(RefreshToken).where(
|
||||
RefreshToken.token == refresh_token
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_token = result.scalar_one_or_none()
|
||||
|
||||
if not db_token:
|
||||
logger.warning("Refresh token not found for revocation")
|
||||
return False
|
||||
|
||||
# Revocar usando m├®todo del modelo
|
||||
db_token.revoke(revoked_by=revoked_by_user_id)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Refresh token revoked",
|
||||
token_id=str(db_token.id),
|
||||
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def revoke_all_user_tokens(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
revoked_by_user_id: Optional[uuid.UUID] = None
|
||||
) -> int:
|
||||
"""
|
||||
Revocar todos los tokens activos de un usuario.
|
||||
|
||||
Útil para logout en todos los dispositivos.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user_id: ID del usuario
|
||||
revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
|
||||
|
||||
Returns:
|
||||
N├║mero de tokens revocados
|
||||
"""
|
||||
# Buscar todos los tokens activos del usuario
|
||||
query = select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked == False
|
||||
)
|
||||
result = await db.execute(query)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
count = 0
|
||||
for token in tokens:
|
||||
token.revoke(revoked_by=revoked_by_user_id)
|
||||
count += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"All user tokens revoked",
|
||||
user_id=str(user_id),
|
||||
count=count,
|
||||
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
|
||||
)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_expired_tokens(
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
"""
|
||||
Eliminar tokens expirados de la base de datos.
|
||||
|
||||
Tarea de mantenimiento para limpiar tokens antiguos.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
|
||||
Returns:
|
||||
N├║mero de tokens eliminados
|
||||
"""
|
||||
# Eliminar tokens expirados hace más de 7 días
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=7)
|
||||
|
||||
query = delete(RefreshToken).where(
|
||||
RefreshToken.expires_at < cutoff_date
|
||||
)
|
||||
result = await db.execute(query)
|
||||
await db.flush()
|
||||
|
||||
deleted_count = result.rowcount
|
||||
|
||||
logger.info("Expired tokens cleaned up", count=deleted_count)
|
||||
return deleted_count
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tokens(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID
|
||||
) -> list[RefreshToken]:
|
||||
"""
|
||||
Obtener todos los tokens activos de un usuario.
|
||||
|
||||
Args:
|
||||
db: Sesi├│n de base de datos
|
||||
user_id: ID del usuario
|
||||
|
||||
Returns:
|
||||
Lista de RefreshTokens activos
|
||||
"""
|
||||
query = select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked == False,
|
||||
RefreshToken.expires_at > datetime.utcnow()
|
||||
).order_by(RefreshToken.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
Reference in New Issue
Block a user