Mejora de seguridad
This commit is contained in:
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user