Mejora de seguridad

This commit is contained in:
2026-03-03 09:29:53 -07:00
parent b187aa1b46
commit 49dfb3ef24
19 changed files with 428 additions and 657 deletions

Binary file not shown.

View File

@@ -1,3 +1,4 @@
from typing import Optional
from fastapi import Depends, HTTPException, status
from starlette.requests import Request
from fastapi.security import OAuth2PasswordBearer
@@ -15,7 +16,48 @@ from app.models.tenant import Tenant
settings = get_settings()
# Esquema OAuth2 centralizado — auth.py importa desde aquí
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
# Soporta: 1) Authorization: Bearer header (Swagger/API clients)
# 2) Cookie access_token HttpOnly (apps web)
_bearer_scheme = OAuth2PasswordBearer(
tokenUrl=f"/{settings.API_VERSION}/auth/login",
auto_error=False,
)
async def oauth2_scheme(
request: Request,
bearer_token: Optional[str] = Depends(_bearer_scheme),
) -> str:
"""Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto.
Usa el header X-App para seleccionar la cookie:
- X-App: internal → solo 'internal_access_token'
- X-App: client → solo 'client_access_token'
- sin header → prueba ambas (compatibilidad con Swagger/CLI)
"""
if bearer_token:
return bearer_token
app_hint = request.headers.get("X-App", "").lower()
if app_hint == "internal":
token = request.cookies.get("internal_access_token")
elif app_hint == "client":
token = request.cookies.get("client_access_token")
else:
# Fallback para Swagger, tests y clientes sin header
token = (
request.cookies.get("internal_access_token")
or request.cookies.get("client_access_token")
)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return token
async def get_current_user(
request: Request,

View File

@@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -21,6 +21,14 @@ from app.services.audit_service import AuditService
from app.services.token_service import TokenService
from app.api.deps import oauth2_scheme, get_current_user
from app.core.cache import cache, cache_key
# Nombres de cookie por tipo de usuario
CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
def _cookie_name_for_role(role: str) -> str:
"""Devuelve el nombre de cookie según el rol del usuario."""
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
TwoFactorStatusResponse, TwoFactorSetupResponse,
@@ -41,6 +49,7 @@ settings = get_settings()
async def login(
login_data: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""
@@ -247,7 +256,20 @@ async def login(
# Best-effort: clear per-identity limiter on success.
if ident_key:
await cache.delete(ident_key)
# Cookie diferenciada por rol para aislar sesiones entre frontends
cookie_name = _cookie_name_for_role(
user.role.value if hasattr(user.role, "value") else user.role
)
response.set_cookie(
key=cookie_name,
value=access_token,
httponly=True,
secure=settings.is_production(),
samesite="strict" if settings.is_production() else "lax",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
@@ -330,6 +352,7 @@ async def refresh_token(
@router.post("/logout")
async def logout(
response: Response,
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
@@ -387,7 +410,10 @@ async def logout(
logger.warning("Failed to log audit entry", error=str(e))
logger.info("Logout successful", user_id=payload["sub"])
# Borrar la cookie correcta según el rol del usuario
cookie_name = _cookie_name_for_role(payload.get("role", ""))
response.delete_cookie(key=cookie_name)
return {"message": "Successfully logged out"}

View File

@@ -7,7 +7,7 @@ Accesible por ADMIN y SUPPORT_MANAGER.
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, case, text
from sqlalchemy import select, func, and_, case, text, literal_column
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
@@ -505,20 +505,23 @@ async def get_report_trends(
tenant_filter = Ticket.tenant_id == current_user.tenant_id
# Tickets creados por día
# literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros
# ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY.
_day_lit = literal_column("'day'")
created_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.created_at).label("day"),
func.date_trunc(_day_lit, Ticket.created_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(func.date_trunc("day", Ticket.created_at))
.order_by(func.date_trunc("day", Ticket.created_at))
.group_by(func.date_trunc(_day_lit, Ticket.created_at))
.order_by(func.date_trunc(_day_lit, Ticket.created_at))
)).all()
# Tickets resueltos por día (según resolved_at)
resolved_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.resolved_at).label("day"),
func.date_trunc(_day_lit, Ticket.resolved_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(
@@ -526,8 +529,8 @@ async def get_report_trends(
Ticket.resolved_at >= period_start,
Ticket.resolved_at.isnot(None),
))
.group_by(func.date_trunc("day", Ticket.resolved_at))
.order_by(func.date_trunc("day", Ticket.resolved_at))
.group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
.order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
)).all()
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}

View File

@@ -48,11 +48,17 @@ async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db)
category = await db.get(Category, category_uuid)
if not category:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
# ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection)
if category.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
# ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection)
if system.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None

View File

@@ -87,8 +87,14 @@ if settings.is_production():
"X-Correlation-ID",
]
else:
cors_allow_methods = ["*"]
cors_allow_headers = ["*"]
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
cors_allow_headers = [
"Authorization",
"Content-Type",
"X-Tenant-ID",
"X-Tenant-Slug",
"X-Correlation-ID",
]
app.add_middleware(
CORSMiddleware,

View File

@@ -42,6 +42,8 @@ class TenantMiddleware(BaseHTTPMiddleware):
"/v1/auth/refresh",
"/api/v1/auth/logout",
"/v1/auth/logout",
"/api/v1/auth/me",
"/v1/auth/me",
"/api/v1/auth/forgot-password",
"/v1/auth/forgot-password",
"/api/v1/auth/reset-password",