feat: Funcion de sistema tenants
This commit is contained in:
@@ -208,7 +208,7 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user:
|
||||
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
||||
|
||||
# Calcular IPs sospechosas (más de 5 intentos fallidos)
|
||||
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'auth.login.failed']))
|
||||
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed']))
|
||||
|
||||
# Contar acciones críticas (delete, privilege changes, etc)
|
||||
critical_actions = mass_deletions + privilege_changes
|
||||
|
||||
@@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb
|
||||
Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -18,7 +18,9 @@ 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.services.token_service import TokenService
|
||||
from app.api.deps import oauth2_scheme, get_current_user
|
||||
from app.core.cache import cache, cache_key
|
||||
from app.api.schemas.auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
@@ -38,6 +40,7 @@ settings = get_settings()
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -58,13 +61,85 @@ async def login(
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug
|
||||
)
|
||||
|
||||
# Rate limiting (best-effort): by IP before any tenant/user lookup.
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
ip_key = cache_key("rl", "login", "ip", client_ip)
|
||||
ip_count = await cache.incr(ip_key, 1)
|
||||
if ip_count == 1:
|
||||
await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again later.",
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 1. Buscar usuario en base de datos
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
# 1. Validar tenant
|
||||
tenant_result = await db.execute(
|
||||
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
||||
)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
logger.warning(
|
||||
"Login failed - tenant not found",
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
|
||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
ident_key = None
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
email_norm = login_data.email.strip().lower()
|
||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||
ident_count = await cache.incr(ident_key, 1)
|
||||
if ident_count == 1:
|
||||
await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS:
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant.id,
|
||||
user_id=None,
|
||||
action="user.login_rate_limited",
|
||||
resource_type="user",
|
||||
resource_id=None,
|
||||
metadata={
|
||||
"email": email_norm,
|
||||
"tenant_slug": login_data.tenant_slug,
|
||||
"ip": request.client.host if request.client else None,
|
||||
"scope": "tenant_email",
|
||||
"window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
||||
"max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to log rate limit audit entry", error=str(e))
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again later.",
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 2. Buscar usuario en base de datos (aislado por tenant)
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 2. Verificar usuario y contraseña
|
||||
# 3. Verificar usuario y contraseña
|
||||
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||
logger.warning(
|
||||
"Login failed - invalid credentials",
|
||||
@@ -89,21 +164,21 @@ async def login(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenciales inválidas"
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# 3. Verificar si está activo
|
||||
# 4. Verificar si está activo
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Login failed - user inactive",
|
||||
email=login_data.email
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario inactivo"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User inactive",
|
||||
)
|
||||
|
||||
# 4. Verificar 2FA si está habilitado
|
||||
# 5. 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
|
||||
@@ -128,6 +203,24 @@ async def login(
|
||||
|
||||
access_token = security.create_access_token(token_data)
|
||||
refresh_token = security.create_refresh_token(token_data)
|
||||
|
||||
# Persist refresh token so it can be revoked/validated later
|
||||
try:
|
||||
await TokenService.create_refresh_token(
|
||||
db=db,
|
||||
user=user,
|
||||
refresh_token=refresh_token,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# If persistence fails, do not leak tokens
|
||||
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Service temporarily unavailable",
|
||||
)
|
||||
|
||||
# Registrar login exitoso en auditoría
|
||||
try:
|
||||
@@ -150,6 +243,10 @@ async def login(
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
user_id=str(user.id)
|
||||
)
|
||||
|
||||
# Best-effort: clear per-identity limiter on success.
|
||||
if ident_key:
|
||||
await cache.delete(ident_key)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
@@ -198,7 +295,20 @@ async def refresh_token(
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# TODO: Check if refresh token exists in database and is not revoked
|
||||
# Check token exists in database and is not revoked/expired
|
||||
db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token)
|
||||
if db_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Defensive: ensure DB token belongs to same subject
|
||||
if str(db_token.user_id) != str(payload.get("sub")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Create new access token
|
||||
token_data = {
|
||||
@@ -243,7 +353,19 @@ async def logout(
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# TODO: Revoke refresh token in database
|
||||
# Revoke all active refresh tokens for this user (logout invalidates refresh)
|
||||
try:
|
||||
import uuid
|
||||
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
await TokenService.revoke_all_user_tokens(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
revoked_by_user_id=user_id,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||
|
||||
# Registrar logout en auditoría
|
||||
try:
|
||||
|
||||
@@ -80,8 +80,15 @@ async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db)
|
||||
"title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"contact_email": ticket.contact_email,
|
||||
"contact_phone": ticket.contact_phone,
|
||||
"created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at
|
||||
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at,
|
||||
"sla_response_due": db_ticket.sla_response_due,
|
||||
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||
"first_response_at": db_ticket.first_response_at,
|
||||
"resolved_at": db_ticket.resolved_at,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
@@ -111,35 +118,36 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
|
||||
query = query.options(
|
||||
selectinload(Ticket.category),
|
||||
selectinload(Ticket.affected_system),
|
||||
selectinload(Ticket.assigned_to_user)
|
||||
)
|
||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
return [
|
||||
{"id": str(t.id), "ticket_number": t.ticket_number, "subject": t.subject, "title": t.subject,
|
||||
"description": t.description, "status": t.status.value, "priority": t.priority.value,
|
||||
"category_id": str(t.category_id) if t.category_id else None,
|
||||
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None,
|
||||
"created_by": str(t.created_by), "assigned_to": str(t.assigned_to) if t.assigned_to else None,
|
||||
"created_at": t.created_at, "updated_at": t.updated_at, "sla_response_due": t.sla_response_due,
|
||||
"sla_resolution_due": t.sla_resolution_due, "first_response_at": t.first_response_at, "resolved_at": t.resolved_at}
|
||||
for t in tickets
|
||||
]
|
||||
return [ticket_to_dict(t) for t in tickets]
|
||||
|
||||
@router.get("/admin/all", response_model=List[dict])
|
||||
async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None,
|
||||
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
|
||||
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Obtener todos los tickets de todos los tenants (solo para administradores)"""
|
||||
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
|
||||
|
||||
|
||||
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
|
||||
|
||||
|
||||
# SUPPORT_MANAGER solo ve su propio tenant.
|
||||
# ADMIN ve todos los tenants (es el administrador de la plataforma).
|
||||
if current_user.role == "SUPPORT_MANAGER":
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
|
||||
if tenant_id_filter:
|
||||
query = query.where(Ticket.tenant_id == validate_uuid_param(tenant_id_filter, "tenant ID"))
|
||||
if category_filter:
|
||||
@@ -166,10 +174,20 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Cargar categorías en un solo query para evitar N+1
|
||||
category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
|
||||
categories_map = {}
|
||||
if category_ids:
|
||||
from app.models.category import Category as CategoryModel
|
||||
cat_result = await db.execute(select(CategoryModel).where(CategoryModel.id.in_(category_ids)))
|
||||
categories_map = {c.id: c.name for c in cat_result.scalars().all()}
|
||||
|
||||
return [
|
||||
{"id": str(ticket.id), "ticket_number": ticket.ticket_number, "subject": ticket.subject,
|
||||
"description": ticket.description, "status": ticket.status.value, "priority": ticket.priority.value,
|
||||
"tenant_id": str(ticket.tenant_id), "tenant_name": tenant.name, "tenant_slug": tenant.slug,
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None,
|
||||
"created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}",
|
||||
"creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due,
|
||||
@@ -358,6 +376,9 @@ async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc()))
|
||||
attachments = result.scalars().all()
|
||||
@@ -382,6 +403,9 @@ async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: As
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
|
||||
|
||||
@@ -425,6 +449,9 @@ async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSessi
|
||||
if not ticket:
|
||||
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid))
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
@@ -19,6 +19,14 @@ router = APIRouter()
|
||||
# ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_current_user(
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
):
|
||||
"""Obtener el perfil del usuario actual."""
|
||||
return current_user
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def read_users(
|
||||
skip: int = 0,
|
||||
|
||||
@@ -100,7 +100,10 @@ def ticket_to_dict(ticket: Ticket) -> dict:
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"category_name": ticket.category.name if ticket.category else None,
|
||||
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"affected_system_name": ticket.affected_system.name if ticket.affected_system else None,
|
||||
"contact_email": None,
|
||||
"contact_phone": None,
|
||||
"created_by": str(ticket.created_by),
|
||||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"assigned_to_name": f"{ticket.assigned_to_user.first_name} {ticket.assigned_to_user.last_name}" if ticket.assigned_to_user else None,
|
||||
|
||||
Reference in New Issue
Block a user