feat: Version 1.10.0 - Refactorizacion, optimizacion UI y mejoras de seguridad

- Extraccion de helpers en backend: audit_helpers.py, helpers.py
- Modularizacion de schemas en archivos individuales por dominio
- Reduccion de audit.py en 953 lineas (74% del archivo)
- Reduccion de tickets.py en 655 lineas (60% del archivo)
- Expansion de auth.py con recuperacion de contrasenia y tokens
- Nuevos modulos: core/email.py, core/cache.py
- Reorganizacion de scripts a backend/scripts/
- Frontend: refactorizacion de audit page con array-driven components
- Frontend: correccion de 11 errores ortograficos en tickets page
- Frontend: proxy Docker corregido en vite.config.js
- Frontend: nuevas rutas forgot-password, reset-password, organization, profile
- Nuevas utilidades TS: colorUtils.ts, dateFormats.ts
- 5 nuevos archivos de tests unitarios en backend/tests/unit/
- Eliminacion de 3 scripts temporales de prueba
- Documentacion tecnica: CAMBIOS_v1.10.0.md, OPTIMIZACIONES_RENDIMIENTO.md
This commit is contained in:
2026-02-19 13:48:21 -07:00
parent 16d795e8bd
commit 517297e89a
57 changed files with 8022 additions and 3660 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,10 @@ Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr
from typing import Optional
import structlog
@@ -19,47 +18,18 @@ from app.core.config import get_settings
from app.models.user import User
from app.models.tenant import Tenant
from app.services.audit_service import AuditService
from app.api.deps import oauth2_scheme, get_current_user
from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
TwoFactorStatusResponse, TwoFactorSetupResponse,
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
)
router = APIRouter()
logger = structlog.get_logger(__name__)
settings = get_settings()
# OAuth2 scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class LoginRequest(BaseModel):
"""Schema for login request."""
email: EmailStr
password: str
tenant_slug: str
totp_code: Optional[str] = None
class LoginResponse(BaseModel):
"""Schema for login response."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
user: dict
class RefreshTokenRequest(BaseModel):
"""Schema for refresh token request."""
refresh_token: str
class TokenResponse(BaseModel):
"""Schema for token response."""
access_token: str
token_type: str = "bearer"
expires_in: int
# ===================================
# ENDPOINTS
@@ -132,7 +102,22 @@ async def login(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo"
)
# 4. Verificar 2FA si está habilitado
if user.totp_enabled:
if not login_data.totp_code:
# Indicar al frontend que debe pedir el código TOTP
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
)
if not security.verify_totp(user.totp_secret, login_data.totp_code):
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Código 2FA inválido o expirado"
)
# Create tokens
token_data = {
"sub": str(user.id),
@@ -355,4 +340,348 @@ async def get_current_user(
# DEPENDENCIES
# ===================================
# Dependencies are imported from app.api.deps to avoid duplication
# Use get_current_user and get_current_active_superuser from deps.py
# Use get_current_user and get_current_active_superuser from deps.py
# ===================================
# 2FA / TOTP ENDPOINTS
# ===================================
@router.get("/2fa/status", response_model=TwoFactorStatusResponse)
async def get_2fa_status(
current_user: User = Depends(get_current_user),
):
"""
Consultar si el 2FA está habilitado para el usuario actual.
Returns:
Estado de 2FA del usuario autenticado.
"""
return TwoFactorStatusResponse(enabled=bool(current_user.totp_enabled))
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
async def setup_2fa(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
El secret se guarda en BD pero 2FA NO se activa todavía.
Se necesita llamar a /2fa/enable con un código válido para activarlo.
Returns:
Secret y QR URI para escanear con la app autenticadora.
"""
new_secret = security.generate_totp_secret()
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
# Guardar el secret (sin habilitar aún)
current_user.totp_secret = new_secret
await db.commit()
logger.info("2FA setup initiated", user_id=str(current_user.id))
return TwoFactorSetupResponse(secret=new_secret, qr_uri=qr_uri)
@router.post("/2fa/enable", response_model=TwoFactorEnableResponse)
async def enable_2fa(
data: TwoFactorEnableRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Activar 2FA verificando que el usuario escaneó correctamente el QR.
Requiere que /2fa/setup haya sido llamado previamente.
Args:
data: Código TOTP generado por la app autenticadora.
Returns:
Confirmación y lista de códigos de respaldo.
"""
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Primero inicia el proceso de configuración con /2fa/setup"
)
if not security.verify_totp(current_user.totp_secret, data.totp_code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
)
# Activar 2FA y generar códigos de respaldo
backup_codes = security.generate_backup_codes()
current_user.totp_enabled = True
current_user.backup_codes = backup_codes
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.2fa_enabled",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("2FA enabled", user_id=str(current_user.id))
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
@router.post("/2fa/disable")
async def disable_2fa(
data: TwoFactorDisableRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Args:
data: totp_code o backup_code para verificar identidad.
Returns:
Mensaje de confirmación.
"""
if not current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El 2FA no está habilitado en esta cuenta"
)
# Verificar con TOTP o código de respaldo
verified = False
if data.totp_code:
verified = security.verify_totp(current_user.totp_secret, data.totp_code)
elif data.backup_code and current_user.backup_codes:
if data.backup_code in current_user.backup_codes:
verified = True
# Invalidar el código de respaldo usado
current_user.backup_codes = [
c for c in current_user.backup_codes if c != data.backup_code
]
if not verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
)
# Deshabilitar 2FA
current_user.totp_enabled = False
current_user.totp_secret = None
current_user.backup_codes = None
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.2fa_disabled",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("2FA disabled", user_id=str(current_user.id))
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
@router.post("/change-password", status_code=status.HTTP_200_OK)
async def change_password(
data: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Cambiar la contraseña del usuario autenticado.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
"""
from datetime import datetime
# Validar longitud mínima
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña debe tener al menos 8 caracteres"
)
# Verificar que la contraseña actual sea correcta
if not security.verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña actual es incorrecta"
)
# No permitir que la nueva sea igual a la actual
if security.verify_password(data.new_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña no puede ser igual a la actual"
)
current_user.password_hash = security.hash_password(data.new_password)
current_user.updated_at = datetime.utcnow()
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.password_changed",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("Password changed", user_id=str(current_user.id))
return {"message": "Contraseña actualizada correctamente"}
# ============================================================
# Recuperación de contraseña (forgot / reset)
# ============================================================
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
_RESET_KEY_PREFIX = "pwd_reset:"
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
async def forgot_password(
data: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""
Solicitar reseteo de contraseña.
Siempre retorna 200 aunque el email no exista, para no revelar
si una dirección está registrada en el sistema.
"""
import secrets
from redis.asyncio import from_url as redis_from_url
from app.core.email import send_email, build_password_reset_email
# Buscar usuario activo con ese email
result = await db.execute(
select(User).where(
User.email == data.email,
User.is_active == True, # noqa: E712
).limit(1)
)
user = result.scalar_one_or_none()
if not user:
# Respuesta idéntica — no revelar existencia
logger.info("Forgot password: email not found", email=data.email)
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
# Generar token seguro
token = secrets.token_urlsafe(32)
redis_key = f"{_RESET_KEY_PREFIX}{token}"
# Guardar en Redis con TTL de 30 min
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
try:
await redis.setex(redis_key, _RESET_TOKEN_TTL, str(user.id))
finally:
await redis.aclose()
# Construir URL y enviar email
reset_url = f"{settings.CLIENT_FRONTEND_URL}/reset-password?token={token}"
user_name = f"{user.first_name} {user.last_name}".strip() or user.email
html, text = build_password_reset_email(reset_url, user_name)
await send_email(
to_email=user.email,
subject="Restablece tu contraseña — ServiceManager",
html_content=html,
text_content=text,
)
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.password_reset_requested",
resource_type="user",
resource_id=user.id,
new_values={"email": user.email},
)
await db.commit()
logger.info("Password reset email sent", user_id=str(user.id))
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
@router.post("/reset-password", status_code=status.HTTP_200_OK)
async def reset_password(
data: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""
Aplicar nueva contraseña usando el token recibido por email.
El token es de un solo uso: se elimina de Redis al usarse.
"""
from datetime import datetime
from redis.asyncio import from_url as redis_from_url
import uuid
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña debe tener al menos 8 caracteres"
)
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
try:
user_id_str = await redis.get(redis_key)
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
)
# Eliminar token inmediatamente (un solo uso)
await redis.delete(redis_key)
finally:
await redis.aclose()
# Buscar y actualizar usuario
user = await db.get(User, uuid.UUID(user_id_str))
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Usuario no encontrado o inactivo"
)
user.password_hash = security.hash_password(data.new_password)
user.updated_at = datetime.utcnow()
await db.commit()
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.password_reset_completed",
resource_type="user",
resource_id=user.id,
)
await db.commit()
logger.info("Password reset completed", user_id=str(user.id))
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}

View File

@@ -1,58 +1,20 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
import uuid
from app.core.database import get_db
from app.core.cache import cache, cache_key
from app.models.category import Category
from app.models.user import User
from app.api import deps
from app.services.audit_service import AuditService
from app.services.audit_service import AuditService
from app.api.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse
router = APIRouter()
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class CategoryCreate(BaseModel):
"""Schema para crear categoría - NO incluye tenant_id (se asigna automáticamente)"""
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None
class CategoryUpdate(BaseModel):
"""Schema para actualizar categoría"""
name: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: Optional[int] = None
sla_resolution_hours: Optional[int] = None
auto_assign_to: Optional[uuid.UUID] = None
is_active: Optional[bool] = None
class CategoryResponse(BaseModel):
"""Schema de respuesta - incluye todos los campos"""
id: uuid.UUID
tenant_id: uuid.UUID # ✅ AÑADIDO
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int
sla_resolution_hours: int
auto_assign_to: Optional[uuid.UUID] = None
is_active: bool
created_at: datetime # ✅ AÑADIDO
updated_at: datetime # ✅ AÑADIDO
model_config = ConfigDict(from_attributes=True)
# ===================================
# ENDPOINTS
@@ -69,14 +31,41 @@ async def read_categories(
Listar categorías del tenant del usuario actual.
✅ Implementa multi-tenancy: solo muestra categorías del tenant del usuario.
✅ Optimizado con caché Redis (TTL: 10 minutos)
"""
# ✅ CORREGIDO: Filtrar por tenant_id
# Intentar obtener del caché
cache_key_str = cache_key("categories", "tenant", str(current_user.tenant_id), f"skip-{skip}", f"limit-{limit}")
cached_categories = await cache.get(cache_key_str)
if cached_categories is not None:
return [CategoryResponse(**cat) for cat in cached_categories]
# Si no está en caché, consultar BD
query = select(Category).where(
Category.tenant_id == current_user.tenant_id
).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
categories = result.scalars().all()
# Guardar en caché (10 minutos)
categories_dict = [
{
"id": str(cat.id),
"name": cat.name,
"description": cat.description,
"sla_response_hours": cat.sla_response_hours,
"sla_resolution_hours": cat.sla_resolution_hours,
"is_active": cat.is_active,
"tenant_id": str(cat.tenant_id),
"created_at": cat.created_at.isoformat(),
"updated_at": cat.updated_at.isoformat()
}
for cat in categories
]
await cache.set(cache_key_str, categories_dict, ttl=600)
return categories
@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
@@ -100,6 +89,9 @@ async def create_category(
await db.commit()
await db.refresh(db_category)
# Invalidar caché de categorías para este tenant
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
# Registrar creación en auditoría
try:
await AuditService.log(
@@ -191,6 +183,9 @@ async def update_category(
await db.commit()
await db.refresh(db_category)
# Invalidar caché de categorías para este tenant
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
# Registrar actualización en auditoría
try:
new_values = {
@@ -250,6 +245,9 @@ async def delete_category(
db_category.is_active = False
await db.commit()
# Invalidar caché de categorías para este tenant
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
# Registrar eliminación en auditoría
try:
await AuditService.log(

View File

@@ -1,7 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
import uuid
@@ -9,37 +8,11 @@ import uuid
from app.core.database import get_db
from app.models.system import System
from app.models.user import User
from app.api import deps
from app.api import deps
from app.api.schemas.system import SystemCreate, SystemUpdate, SystemResponse
router = APIRouter()
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class SystemCreate(BaseModel):
"""Schema para crear sistema - NO incluye tenant_id (se asigna automáticamente)"""
name: str
description: Optional[str] = None
class SystemUpdate(BaseModel):
"""Schema para actualizar sistema"""
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class SystemResponse(BaseModel):
"""Schema de respuesta - incluye todos los campos"""
id: uuid.UUID
tenant_id: uuid.UUID # ✅ AÑADIDO
name: str
description: Optional[str] = None
is_active: bool
created_at: datetime # ✅ AÑADIDO
updated_at: datetime # ✅ AÑADIDO
model_config = ConfigDict(from_attributes=True)
# ===================================
# ENDPOINTS

View File

@@ -1,40 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, EmailStr
from typing import List, Optional
import uuid
from app.core.database import get_db
from app.models.tenant import Tenant, TenantStatus
from app.api import deps
from app.api import deps
from app.api.schemas.tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
router = APIRouter()
class TenantBase(BaseModel):
name: str
slug: str
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = None
class TenantCreate(TenantBase):
pass
class TenantUpdate(BaseModel):
name: Optional[str] = None
slug: Optional[str] = None
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = None
status: Optional[TenantStatus] = None
class TenantResponse(TenantBase):
id: uuid.UUID
status: TenantStatus
model_config = ConfigDict(from_attributes=True)
@router.get("/", response_model=List[TenantResponse])
async def read_tenants(
skip: int = 0,

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, EmailStr
from typing import List, Optional
from datetime import datetime
import uuid
@@ -10,58 +9,11 @@ from app.core.database import get_db
from app.core.security import security
from app.models.user import User, UserRole
from app.services.audit_service import AuditService
from app.api import deps
from app.api import deps
from app.api.schemas.user import UserCreate, UserUpdate, UserResponse
router = APIRouter()
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class UserCreate(BaseModel):
"""Schema para crear usuario - NO incluye tenant_id (se asigna automáticamente)"""
email: EmailStr
first_name: str
last_name: str
role: UserRole
password: str
language: str = "es"
timezone: str = "UTC"
notifications_email: bool = True
class UserUpdate(BaseModel):
"""Schema para actualizar usuario"""
email: Optional[EmailStr] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None
password: Optional[str] = None
language: Optional[str] = None
timezone: Optional[str] = None
notifications_email: Optional[bool] = None
class UserResponse(BaseModel):
"""Schema de respuesta - incluye todos los campos públicos"""
id: uuid.UUID
tenant_id: uuid.UUID
email: EmailStr
first_name: str
last_name: str
avatar_url: Optional[str] = None
role: UserRole
is_active: bool
email_verified: bool
last_login: Optional[datetime] = None
language: str
timezone: str
notifications_email: bool
totp_enabled: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# ===================================
# ENDPOINTS