FlotillasGPS - Sistema completo de monitoreo de flotillas GPS
Sistema completo para monitoreo y gestion de flotas de vehiculos con: - Backend FastAPI con PostgreSQL/TimescaleDB - Frontend React con TypeScript y TailwindCSS - App movil React Native con Expo - Soporte para dispositivos GPS, Meshtastic y celulares - Video streaming en vivo con MediaMTX - Geocercas, alertas, viajes y reportes - Autenticacion JWT y WebSockets en tiempo real Documentacion completa y guias de usuario incluidas.
This commit is contained in:
56
backend/app/core/__init__.py
Normal file
56
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Módulo core - Configuración, seguridad y utilidades base.
|
||||
"""
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, get_db, engine, async_session_factory
|
||||
from app.core.security import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_current_user,
|
||||
get_current_active_admin,
|
||||
CurrentUser,
|
||||
CurrentAdmin,
|
||||
)
|
||||
from app.core.exceptions import (
|
||||
AdanException,
|
||||
NotFoundError,
|
||||
AlreadyExistsError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
ExternalServiceError,
|
||||
register_exception_handlers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"settings",
|
||||
# Database
|
||||
"Base",
|
||||
"get_db",
|
||||
"engine",
|
||||
"async_session_factory",
|
||||
# Security
|
||||
"hash_password",
|
||||
"verify_password",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"decode_token",
|
||||
"get_current_user",
|
||||
"get_current_active_admin",
|
||||
"CurrentUser",
|
||||
"CurrentAdmin",
|
||||
# Exceptions
|
||||
"AdanException",
|
||||
"NotFoundError",
|
||||
"AlreadyExistsError",
|
||||
"ValidationError",
|
||||
"AuthenticationError",
|
||||
"AuthorizationError",
|
||||
"ExternalServiceError",
|
||||
"register_exception_handlers",
|
||||
]
|
||||
177
backend/app/core/config.py
Normal file
177
backend/app/core/config.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Configuración central de la aplicación.
|
||||
|
||||
Utiliza Pydantic BaseSettings para cargar variables de entorno
|
||||
con validación de tipos y valores por defecto.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import PostgresDsn, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuración de la aplicación cargada desde variables de entorno."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Aplicación
|
||||
APP_NAME: str = "Adan Fleet Monitor"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
ENVIRONMENT: str = "development"
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Servidor
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
WORKERS: int = 4
|
||||
|
||||
# Base de datos PostgreSQL/TimescaleDB
|
||||
POSTGRES_HOST: str = "localhost"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_USER: str = "adan"
|
||||
POSTGRES_PASSWORD: str = "adan_secret"
|
||||
POSTGRES_DB: str = "adan_fleet"
|
||||
DATABASE_URL: Optional[str] = None
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
@model_validator(mode="after")
|
||||
def build_database_url(self) -> "Settings":
|
||||
"""Construye la URL de conexión a la base de datos."""
|
||||
if not self.DATABASE_URL:
|
||||
self.DATABASE_URL = (
|
||||
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
return self
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_DB: int = 0
|
||||
REDIS_PASSWORD: Optional[str] = None
|
||||
REDIS_URL: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def build_redis_url(self) -> "Settings":
|
||||
"""Construye la URL de conexión a Redis."""
|
||||
if not self.REDIS_URL:
|
||||
password_part = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
|
||||
self.REDIS_URL = f"redis://{password_part}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
return self
|
||||
|
||||
# Seguridad JWT
|
||||
SECRET_KEY: str = "your-super-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: List[str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: List[str] = ["*"]
|
||||
|
||||
@field_validator("CORS_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, v: Any) -> List[str]:
|
||||
"""Parsea los orígenes CORS desde string separado por comas."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",")]
|
||||
return v
|
||||
|
||||
# Traccar Integration
|
||||
TRACCAR_HOST: str = "localhost"
|
||||
TRACCAR_PORT: int = 5055
|
||||
TRACCAR_API_URL: str = "http://localhost:8082/api"
|
||||
TRACCAR_USERNAME: Optional[str] = None
|
||||
TRACCAR_PASSWORD: Optional[str] = None
|
||||
|
||||
# MediaMTX Video Server
|
||||
MEDIAMTX_HOST: str = "localhost"
|
||||
MEDIAMTX_API_PORT: int = 9997
|
||||
MEDIAMTX_RTSP_PORT: int = 8554
|
||||
MEDIAMTX_WEBRTC_PORT: int = 8889
|
||||
|
||||
# Meshtastic
|
||||
MESHTASTIC_ENABLED: bool = False
|
||||
MESHTASTIC_SERIAL_PORT: Optional[str] = None
|
||||
MESHTASTIC_TCP_HOST: Optional[str] = None
|
||||
MESHTASTIC_TCP_PORT: int = 4403
|
||||
|
||||
# MQTT (para dispositivos IoT)
|
||||
MQTT_ENABLED: bool = False
|
||||
MQTT_HOST: str = "localhost"
|
||||
MQTT_PORT: int = 1883
|
||||
MQTT_USERNAME: Optional[str] = None
|
||||
MQTT_PASSWORD: Optional[str] = None
|
||||
MQTT_TOPIC_LOCATIONS: str = "adan/locations/#"
|
||||
MQTT_TOPIC_ALERTS: str = "adan/alerts/#"
|
||||
|
||||
# Email (notificaciones)
|
||||
SMTP_HOST: str = "localhost"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_FROM_EMAIL: str = "noreply@adan-fleet.com"
|
||||
SMTP_FROM_NAME: str = "Adan Fleet Monitor"
|
||||
SMTP_TLS: bool = True
|
||||
|
||||
# Push Notifications (Firebase)
|
||||
FIREBASE_CREDENTIALS_PATH: Optional[str] = None
|
||||
FIREBASE_ENABLED: bool = False
|
||||
|
||||
# Almacenamiento de archivos
|
||||
UPLOAD_DIR: str = "/var/lib/adan/uploads"
|
||||
MAX_UPLOAD_SIZE_MB: int = 100
|
||||
ALLOWED_IMAGE_TYPES: List[str] = ["image/jpeg", "image/png", "image/webp"]
|
||||
ALLOWED_VIDEO_TYPES: List[str] = ["video/mp4", "video/webm"]
|
||||
|
||||
# Reportes
|
||||
REPORTS_DIR: str = "/var/lib/adan/reports"
|
||||
REPORT_RETENTION_DAYS: int = 90
|
||||
|
||||
# Geocoding
|
||||
GEOCODING_PROVIDER: str = "nominatim" # nominatim, google, mapbox
|
||||
GOOGLE_MAPS_API_KEY: Optional[str] = None
|
||||
MAPBOX_ACCESS_TOKEN: Optional[str] = None
|
||||
|
||||
# Alertas y umbrales
|
||||
ALERT_SPEED_LIMIT_DEFAULT: int = 120 # km/h
|
||||
ALERT_IDLE_MINUTES: int = 15
|
||||
ALERT_BATTERY_LOW_PERCENT: int = 20
|
||||
ALERT_NO_SIGNAL_MINUTES: int = 30
|
||||
|
||||
# Limpieza de datos
|
||||
LOCATION_RETENTION_DAYS: int = 365
|
||||
ALERT_RETENTION_DAYS: int = 180
|
||||
VIDEO_RETENTION_DAYS: int = 30
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
LOG_FILE: Optional[str] = None
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Obtiene la instancia de configuración (singleton cacheado).
|
||||
|
||||
Returns:
|
||||
Settings: Instancia de configuración de la aplicación.
|
||||
"""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Instancia global de configuración
|
||||
settings = get_settings()
|
||||
140
backend/app/core/database.py
Normal file
140
backend/app/core/database.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Configuración de conexión a la base de datos PostgreSQL/TimescaleDB.
|
||||
|
||||
Proporciona:
|
||||
- Engine async para SQLAlchemy
|
||||
- Session factory async
|
||||
- Dependency para obtener sesiones en endpoints
|
||||
- Base declarativa para modelos
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Clase base para todos los modelos SQLAlchemy."""
|
||||
pass
|
||||
|
||||
|
||||
# Configuración del engine según el entorno
|
||||
if settings.ENVIRONMENT == "testing":
|
||||
# En testing usamos NullPool para evitar problemas con conexiones
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
else:
|
||||
# En producción usamos pool de conexiones
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_pre_ping=True, # Verifica conexiones antes de usar
|
||||
pool_recycle=3600, # Recicla conexiones cada hora
|
||||
)
|
||||
|
||||
# Factory de sesiones async
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency que proporciona una sesión de base de datos.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Sesión de base de datos para usar en el endpoint.
|
||||
|
||||
Example:
|
||||
@router.get("/items")
|
||||
async def get_items(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""
|
||||
Inicializa la base de datos creando todas las tablas.
|
||||
|
||||
Nota: En producción se recomienda usar Alembic para migraciones.
|
||||
Esta función es útil para desarrollo y testing.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
# Importar todos los modelos para que SQLAlchemy los conozca
|
||||
from app.models import ( # noqa: F401
|
||||
alerta,
|
||||
camara,
|
||||
carga_combustible,
|
||||
conductor,
|
||||
configuracion,
|
||||
dispositivo,
|
||||
evento_video,
|
||||
geocerca,
|
||||
grabacion,
|
||||
grupo_vehiculos,
|
||||
mantenimiento,
|
||||
mensaje,
|
||||
parada,
|
||||
poi,
|
||||
tipo_alerta,
|
||||
tipo_mantenimiento,
|
||||
ubicacion,
|
||||
usuario,
|
||||
vehiculo,
|
||||
viaje,
|
||||
)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""
|
||||
Cierra el pool de conexiones a la base de datos.
|
||||
|
||||
Debe llamarse al apagar la aplicación para liberar recursos.
|
||||
"""
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def check_db_connection() -> bool:
|
||||
"""
|
||||
Verifica que la conexión a la base de datos funcione.
|
||||
|
||||
Returns:
|
||||
bool: True si la conexión es exitosa.
|
||||
|
||||
Raises:
|
||||
Exception: Si no se puede conectar a la base de datos.
|
||||
"""
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute("SELECT 1")
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception(f"Error conectando a la base de datos: {e}")
|
||||
280
backend/app/core/exceptions.py
Normal file
280
backend/app/core/exceptions.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Excepciones personalizadas para la aplicación.
|
||||
|
||||
Define excepciones específicas del dominio y handlers
|
||||
para convertirlas en respuestas HTTP apropiadas.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class AdanException(Exception):
|
||||
"""Excepción base para todas las excepciones de la aplicación."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str = "ADAN_ERROR",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class NotFoundError(AdanException):
|
||||
"""Recurso no encontrado."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
identifier: Any = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"{resource} no encontrado"
|
||||
if identifier:
|
||||
message = f"{resource} con id '{identifier}' no encontrado"
|
||||
super().__init__(message, "NOT_FOUND", details)
|
||||
self.resource = resource
|
||||
self.identifier = identifier
|
||||
|
||||
|
||||
class AlreadyExistsError(AdanException):
|
||||
"""El recurso ya existe."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
field: str,
|
||||
value: Any,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"{resource} con {field}='{value}' ya existe"
|
||||
super().__init__(message, "ALREADY_EXISTS", details)
|
||||
self.resource = resource
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
|
||||
class ValidationError(AdanException):
|
||||
"""Error de validación de datos."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
field: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message, "VALIDATION_ERROR", details)
|
||||
self.field = field
|
||||
|
||||
|
||||
class AuthenticationError(AdanException):
|
||||
"""Error de autenticación."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Credenciales inválidas",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message, "AUTHENTICATION_ERROR", details)
|
||||
|
||||
|
||||
class AuthorizationError(AdanException):
|
||||
"""Error de autorización (permisos insuficientes)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "No tiene permisos para realizar esta acción",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message, "AUTHORIZATION_ERROR", details)
|
||||
|
||||
|
||||
class ExternalServiceError(AdanException):
|
||||
"""Error al comunicarse con un servicio externo."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
full_message = f"Error en servicio {service}: {message}"
|
||||
super().__init__(full_message, "EXTERNAL_SERVICE_ERROR", details)
|
||||
self.service = service
|
||||
|
||||
|
||||
class GeocercaViolationError(AdanException):
|
||||
"""Violación de geocerca detectada."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
geocerca_id: int,
|
||||
geocerca_nombre: str,
|
||||
tipo_violacion: str, # 'entrada' o 'salida'
|
||||
vehiculo_id: int,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"Vehículo {vehiculo_id} {tipo_violacion} de geocerca '{geocerca_nombre}'"
|
||||
super().__init__(message, "GEOCERCA_VIOLATION", details)
|
||||
self.geocerca_id = geocerca_id
|
||||
self.geocerca_nombre = geocerca_nombre
|
||||
self.tipo_violacion = tipo_violacion
|
||||
self.vehiculo_id = vehiculo_id
|
||||
|
||||
|
||||
class SpeedLimitExceededError(AdanException):
|
||||
"""Límite de velocidad excedido."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
velocidad: float,
|
||||
limite: float,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"Vehículo {vehiculo_id} excedió límite de velocidad: {velocidad} km/h (límite: {limite} km/h)"
|
||||
super().__init__(message, "SPEED_LIMIT_EXCEEDED", details)
|
||||
self.vehiculo_id = vehiculo_id
|
||||
self.velocidad = velocidad
|
||||
self.limite = limite
|
||||
|
||||
|
||||
class DeviceConnectionError(AdanException):
|
||||
"""Error de conexión con dispositivo."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dispositivo_id: int,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
full_message = f"Error de conexión con dispositivo {dispositivo_id}: {message}"
|
||||
super().__init__(full_message, "DEVICE_CONNECTION_ERROR", details)
|
||||
self.dispositivo_id = dispositivo_id
|
||||
|
||||
|
||||
class VideoStreamError(AdanException):
|
||||
"""Error con stream de video."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
camara_id: int,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
full_message = f"Error de video en cámara {camara_id}: {message}"
|
||||
super().__init__(full_message, "VIDEO_STREAM_ERROR", details)
|
||||
self.camara_id = camara_id
|
||||
|
||||
|
||||
class MaintenanceRequiredError(AdanException):
|
||||
"""Mantenimiento requerido para el vehículo."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
tipo_mantenimiento: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"Vehículo {vehiculo_id} requiere mantenimiento: {tipo_mantenimiento}"
|
||||
super().__init__(message, "MAINTENANCE_REQUIRED", details)
|
||||
self.vehiculo_id = vehiculo_id
|
||||
self.tipo_mantenimiento = tipo_mantenimiento
|
||||
|
||||
|
||||
class DatabaseError(AdanException):
|
||||
"""Error de base de datos."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
full_message = f"Error de base de datos en {operation}: {message}"
|
||||
super().__init__(full_message, "DATABASE_ERROR", details)
|
||||
self.operation = operation
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Handlers para FastAPI
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def adan_exception_handler(request: Request, exc: AdanException) -> JSONResponse:
|
||||
"""Handler para excepciones base de Adan."""
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
if isinstance(exc, NotFoundError):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
elif isinstance(exc, AlreadyExistsError):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
elif isinstance(exc, ValidationError):
|
||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
elif isinstance(exc, AuthenticationError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
elif isinstance(exc, AuthorizationError):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
elif isinstance(exc, ExternalServiceError):
|
||||
status_code = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"details": exc.details,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""Handler para HTTPException estándar de FastAPI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": "HTTP_ERROR",
|
||||
"message": exc.detail,
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handler para excepciones no capturadas."""
|
||||
# En producción, loguear el error completo pero no exponerlo al cliente
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "Error interno del servidor",
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app) -> None:
|
||||
"""
|
||||
Registra los handlers de excepciones en la aplicación FastAPI.
|
||||
|
||||
Args:
|
||||
app: Instancia de FastAPI.
|
||||
"""
|
||||
app.add_exception_handler(AdanException, adan_exception_handler)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
285
backend/app/core/security.py
Normal file
285
backend/app/core/security.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Módulo de seguridad para autenticación y autorización.
|
||||
|
||||
Proporciona:
|
||||
- Generación y verificación de tokens JWT
|
||||
- Hashing de contraseñas con bcrypt
|
||||
- Dependencies para obtener el usuario actual
|
||||
- Encriptación de datos sensibles
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Any, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
|
||||
# Contexto de hashing para contraseñas
|
||||
pwd_context = CryptContext(
|
||||
schemes=["bcrypt"],
|
||||
deprecated="auto",
|
||||
bcrypt__rounds=12, # Factor de costo para bcrypt
|
||||
)
|
||||
|
||||
# Esquema de autenticación Bearer
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Genera un hash seguro de una contraseña.
|
||||
|
||||
Args:
|
||||
password: Contraseña en texto plano.
|
||||
|
||||
Returns:
|
||||
str: Hash bcrypt de la contraseña.
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verifica una contraseña contra su hash.
|
||||
|
||||
Args:
|
||||
plain_password: Contraseña en texto plano a verificar.
|
||||
hashed_password: Hash almacenado de la contraseña.
|
||||
|
||||
Returns:
|
||||
bool: True si la contraseña coincide.
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Crea un token JWT de acceso.
|
||||
|
||||
Args:
|
||||
data: Datos a incluir en el payload del token.
|
||||
expires_delta: Tiempo de expiración personalizado.
|
||||
|
||||
Returns:
|
||||
str: Token JWT codificado.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "access",
|
||||
})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Crea un token JWT de refresco.
|
||||
|
||||
Args:
|
||||
data: Datos a incluir en el payload del token.
|
||||
expires_delta: Tiempo de expiración personalizado.
|
||||
|
||||
Returns:
|
||||
str: Token JWT de refresco codificado.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "refresh",
|
||||
})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
Decodifica y valida un token JWT.
|
||||
|
||||
Args:
|
||||
token: Token JWT a decodificar.
|
||||
|
||||
Returns:
|
||||
dict: Payload del token decodificado.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el token es inválido o ha expirado.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Token inválido: {str(e)}",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def verify_token_type(payload: dict[str, Any], expected_type: str) -> None:
|
||||
"""
|
||||
Verifica que el token sea del tipo esperado.
|
||||
|
||||
Args:
|
||||
payload: Payload del token decodificado.
|
||||
expected_type: Tipo esperado ('access' o 'refresh').
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el tipo de token no coincide.
|
||||
"""
|
||||
token_type = payload.get("type")
|
||||
if token_type != expected_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Tipo de token inválido. Se esperaba '{expected_type}'",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""
|
||||
Dependency que obtiene el usuario actual desde el token JWT.
|
||||
|
||||
Args:
|
||||
credentials: Credenciales Bearer del header Authorization.
|
||||
db: Sesión de base de datos.
|
||||
|
||||
Returns:
|
||||
Usuario: Modelo del usuario autenticado.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si no hay token, es inválido, o el usuario no existe.
|
||||
"""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="No se proporcionaron credenciales",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
verify_token_type(payload, "access")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token inválido: falta el identificador de usuario",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Importar aquí para evitar importación circular
|
||||
from app.models.usuario import Usuario
|
||||
|
||||
result = await db.execute(select(Usuario).where(Usuario.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario no encontrado",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.activo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Usuario desactivado",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_admin(
|
||||
current_user: Annotated[Any, Depends(get_current_user)],
|
||||
):
|
||||
"""
|
||||
Dependency que verifica que el usuario actual sea administrador.
|
||||
|
||||
Args:
|
||||
current_user: Usuario actual obtenido del token.
|
||||
|
||||
Returns:
|
||||
Usuario: Usuario administrador verificado.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el usuario no es administrador.
|
||||
"""
|
||||
if not current_user.es_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Se requieren permisos de administrador",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def encrypt_sensitive_data(data: str) -> str:
|
||||
"""
|
||||
Encripta datos sensibles (ej: contraseñas de cámaras).
|
||||
|
||||
Por simplicidad, usa el mismo mecanismo de hash.
|
||||
En producción, usar Fernet o similar para encriptación reversible.
|
||||
|
||||
Args:
|
||||
data: Datos a encriptar.
|
||||
|
||||
Returns:
|
||||
str: Datos encriptados.
|
||||
"""
|
||||
# Para datos que necesitan ser recuperados (como passwords de cámaras),
|
||||
# se debería usar encriptación simétrica (Fernet)
|
||||
# Por ahora, usamos base64 + XOR simple como placeholder
|
||||
# TODO: Implementar encriptación Fernet apropiada
|
||||
import base64
|
||||
key = settings.SECRET_KEY[:32].encode()
|
||||
data_bytes = data.encode()
|
||||
encrypted = bytes(a ^ b for a, b in zip(data_bytes, key * (len(data_bytes) // len(key) + 1)))
|
||||
return base64.b64encode(encrypted).decode()
|
||||
|
||||
|
||||
def decrypt_sensitive_data(encrypted_data: str) -> str:
|
||||
"""
|
||||
Desencripta datos sensibles.
|
||||
|
||||
Args:
|
||||
encrypted_data: Datos encriptados.
|
||||
|
||||
Returns:
|
||||
str: Datos desencriptados.
|
||||
"""
|
||||
import base64
|
||||
key = settings.SECRET_KEY[:32].encode()
|
||||
encrypted_bytes = base64.b64decode(encrypted_data.encode())
|
||||
decrypted = bytes(a ^ b for a, b in zip(encrypted_bytes, key * (len(encrypted_bytes) // len(key) + 1)))
|
||||
return decrypted.decode()
|
||||
|
||||
|
||||
# Type aliases para uso en endpoints
|
||||
CurrentUser = Annotated[Any, Depends(get_current_user)]
|
||||
CurrentAdmin = Annotated[Any, Depends(get_current_active_admin)]
|
||||
Reference in New Issue
Block a user