Files
pagasis/backend/authentication/audit.py
T
2025-10-15 23:45:32 -06:00

222 lines
7.5 KiB
Python

"""
Sistema de auditoría para registrar eventos de seguridad
"""
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
import json
class AuditLog(models.Model):
"""
Modelo para registrar eventos de auditoría del sistema
"""
# Categorías de eventos
CATEGORY_CHOICES = [
('AUTH', 'Autenticación'),
('ACCESS', 'Control de Acceso'),
('DATA', 'Modificación de Datos'),
('SECURITY', 'Evento de Seguridad'),
('SYSTEM', 'Sistema'),
]
# Niveles de severidad
SEVERITY_CHOICES = [
('INFO', 'Información'),
('WARNING', 'Advertencia'),
('ERROR', 'Error'),
('CRITICAL', 'Crítico'),
]
# Acciones comunes
ACTION_CHOICES = [
('LOGIN_SUCCESS', 'Login exitoso'),
('LOGIN_FAILED', 'Login fallido'),
('LOGOUT', 'Logout'),
('TOKEN_REFRESH', 'Token refrescado'),
('ACCESS_DENIED', 'Acceso denegado'),
('RATE_LIMITED', 'Rate limit excedido'),
('ATTENDANCE_CREATE', 'Asistencia registrada'),
('ATTENDANCE_UPDATE', 'Asistencia actualizada'),
('ATTENDANCE_DELETE', 'Asistencia eliminada'),
('EVENT_CREATE', 'Evento creado'),
('EVENT_UPDATE', 'Evento actualizado'),
('EVENT_DELETE', 'Evento eliminado'),
('EXTERNAL_USER_REGISTER', 'Usuario externo registrado'),
('EXTERNAL_USER_APPROVE', 'Usuario externo aprobado'),
('EXTERNAL_USER_REJECT', 'Usuario externo rechazado'),
('PERMISSION_CHANGE', 'Cambio de permisos'),
('DATA_EXPORT', 'Exportación de datos'),
('OTHER', 'Otro'),
]
# Campos principales
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, db_index=True)
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES, default='INFO', db_index=True)
action = models.CharField(max_length=50, choices=ACTION_CHOICES)
# Usuario relacionado (puede ser None para eventos anónimos)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='audit_logs'
)
username = models.CharField(max_length=150, blank=True) # Backup si el usuario se elimina
# Información de la solicitud
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
path = models.CharField(max_length=255, blank=True)
method = models.CharField(max_length=10, blank=True)
# Detalles del evento
message = models.TextField()
details = models.JSONField(default=dict, blank=True) # Datos adicionales en JSON
# Resultados
success = models.BooleanField(default=True)
status_code = models.IntegerField(null=True, blank=True)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['-timestamp', 'category']),
models.Index(fields=['-timestamp', 'severity']),
models.Index(fields=['-timestamp', 'user']),
models.Index(fields=['ip_address', '-timestamp']),
]
verbose_name = 'Registro de Auditoría'
verbose_name_plural = 'Registros de Auditoría'
def __str__(self):
return f"[{self.timestamp}] {self.get_category_display()} - {self.message}"
@classmethod
def log(cls, category, action, message, request=None, user=None,
severity='INFO', success=True, status_code=None, **details):
"""
Método de conveniencia para crear logs de auditoría
Args:
category: Categoría del evento
action: Acción realizada
message: Mensaje descriptivo
request: Objeto request de Django (opcional)
user: Usuario (opcional, se extrae del request si no se provee)
severity: Nivel de severidad
success: Si la operación fue exitosa
status_code: Código HTTP de respuesta
**details: Datos adicionales a guardar en JSON
"""
# Extraer información del request
ip_address = None
user_agent = ''
path = ''
method = ''
if request:
# Obtener IP real (considerando proxies)
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip_address = x_forwarded_for.split(',')[0].strip()
else:
ip_address = request.META.get('REMOTE_ADDR')
user_agent = request.META.get('HTTP_USER_AGENT', '')[:500] # Limitar tamaño
path = request.path
method = request.method
# Si no se proveyó usuario, intentar obtenerlo del request
if not user and hasattr(request, 'user') and request.user.is_authenticated:
user = request.user
# Obtener username
username = user.username if user else 'Anonymous'
# Sanitizar detalles (no guardar datos sensibles)
sanitized_details = cls._sanitize_details(details)
# Crear el log
return cls.objects.create(
category=category,
severity=severity,
action=action,
user=user,
username=username,
ip_address=ip_address,
user_agent=user_agent,
path=path,
method=method,
message=message,
details=sanitized_details,
success=success,
status_code=status_code,
)
@staticmethod
def _sanitize_details(details):
"""
Eliminar datos sensibles de los detalles antes de guardar
"""
sensitive_keys = ['password', 'token', 'secret', 'key', 'authorization']
sanitized = {}
for key, value in details.items():
# Verificar si la clave contiene información sensible
if any(sensitive in key.lower() for sensitive in sensitive_keys):
sanitized[key] = '***REDACTED***'
elif isinstance(value, dict):
sanitized[key] = AuditLog._sanitize_details(value)
else:
# Limitar tamaño de strings
if isinstance(value, str) and len(value) > 1000:
sanitized[key] = value[:1000] + '...[truncated]'
else:
sanitized[key] = value
return sanitized
@classmethod
def get_user_activity(cls, user, days=30):
"""
Obtener actividad de un usuario en los últimos N días
"""
from datetime import timedelta
cutoff = timezone.now() - timedelta(days=days)
return cls.objects.filter(user=user, timestamp__gte=cutoff)
@classmethod
def get_failed_logins(cls, ip_address=None, hours=24):
"""
Obtener intentos fallidos de login
"""
from datetime import timedelta
cutoff = timezone.now() - timedelta(hours=hours)
query = cls.objects.filter(
action='LOGIN_FAILED',
timestamp__gte=cutoff
)
if ip_address:
query = query.filter(ip_address=ip_address)
return query
@classmethod
def get_security_events(cls, severity='WARNING', hours=24):
"""
Obtener eventos de seguridad recientes
"""
from datetime import timedelta
cutoff = timezone.now() - timedelta(hours=hours)
return cls.objects.filter(
category='SECURITY',
severity__in=[severity, 'ERROR', 'CRITICAL'],
timestamp__gte=cutoff
)