tenant arreglado

This commit is contained in:
2026-03-05 14:04:04 -07:00
parent 3b46f48655
commit d8232fda7c
8 changed files with 299 additions and 118 deletions

View File

@@ -1,7 +1,7 @@
""" """
Auth Schemas - ServiceManagerWeb Auth Schemas - ServiceManagerWeb
Pydantic schemas para autenticación y autorización. Pydantic schemas para autenticación y autorización.
""" """
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
"""Schema para solicitud de login.""" """Schema para solicitud de login."""
email: EmailStr email: EmailStr
password: str password: str
tenant_slug: str tenant_slug: Optional[str] = None
totp_code: Optional[str] = None totp_code: Optional[str] = None
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
class TwoFactorEnableRequest(BaseModel): class TwoFactorEnableRequest(BaseModel):
"""Código TOTP para confirmar y activar 2FA.""" """Código TOTP para confirmar y activar 2FA."""
totp_code: str totp_code: str
class TwoFactorEnableResponse(BaseModel): class TwoFactorEnableResponse(BaseModel):
"""Resultado al habilitar 2FA: incluye los códigos de respaldo.""" """Resultado al habilitar 2FA: incluye los códigos de respaldo."""
enabled: bool enabled: bool
backup_codes: List[str] backup_codes: List[str]
class TwoFactorDisableRequest(BaseModel): class TwoFactorDisableRequest(BaseModel):
"""Deshabilitar 2FA verificando con TOTP o código de respaldo.""" """Deshabilitar 2FA verificando con TOTP o código de respaldo."""
totp_code: Optional[str] = None totp_code: Optional[str] = None
backup_code: Optional[str] = None backup_code: Optional[str] = None
# ============================================================ # ============================================================
# Cambio de contraseña # Cambio de contraseña
# ============================================================ # ============================================================
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
"""Schema para cambio de contraseña del usuario autenticado.""" """Schema para cambio de contraseña del usuario autenticado."""
current_password: str current_password: str
new_password: str new_password: str
@@ -82,15 +82,15 @@ class ChangePasswordRequest(BaseModel):
# ============================================================ # ============================================================
# Recuperación de contraseña # Recuperación de contraseña
# ============================================================ # ============================================================
class ForgotPasswordRequest(BaseModel): class ForgotPasswordRequest(BaseModel):
"""Solicitar enlace de reseteo de contraseña por email.""" """Solicitar enlace de reseteo de contraseña por email."""
email: EmailStr email: EmailStr
class ResetPasswordRequest(BaseModel): class ResetPasswordRequest(BaseModel):
"""Aplicar nueva contraseña usando token de reseteo.""" """Aplicar nueva contraseña usando token de reseteo."""
token: str token: str
new_password: str new_password: str

View File

@@ -1,7 +1,7 @@
""" """
Authentication Endpoints - ServiceManagerWeb Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización Endpoints para autenticación y autorización
""" """
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
@@ -28,7 +28,7 @@ CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
def _cookie_name_for_role(role: str) -> str: def _cookie_name_for_role(role: str) -> str:
"""Devuelve el nombre de cookie según el rol del usuario.""" """Devuelve el nombre de cookie según el rol del usuario."""
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token" return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
from app.api.schemas.auth import ( from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse, LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
@@ -88,25 +88,23 @@ async def login(
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)}, headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
) )
# 1. Validar tenant # 1. Validar tenant - por slug si viene, sino buscar por email
tenant_result = await db.execute( if login_data.tenant_slug:
select(Tenant).where(Tenant.slug == login_data.tenant_slug) 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",
) )
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None
# Rate limiting (best-effort): by (tenant,email) to slow brute force. # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING: if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower() email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm) ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
ident_count = await cache.incr(ident_key, 1) ident_count = await cache.incr(ident_key, 1)
@@ -142,22 +140,25 @@ async def login(
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)}, headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
) )
# 2. Buscar usuario en base de datos (aislado por tenant) # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
query = select(User).where( if tenant:
User.email == login_data.email, query = select(User).where(
User.tenant_id == tenant.id, User.email == login_data.email,
) User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)
result = await db.execute(query) result = await db.execute(query)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
# 3. 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): if not user or not security.verify_password(login_data.password, user.password_hash):
logger.warning( logger.warning(
"Login failed - invalid credentials", "Login failed - invalid credentials",
email=login_data.email email=login_data.email
) )
# Registrar intento fallido en auditoría (si el usuario existe) # Registrar intento fallido en auditoría (si el usuario existe)
if user: if user:
try: try:
await AuditService.log( await AuditService.log(
@@ -178,7 +179,7 @@ async def login(
detail="Invalid credentials", detail="Invalid credentials",
) )
# 4. Verificar si está activo # 4. Verificar si está activo
if not user.is_active: if not user.is_active:
logger.warning( logger.warning(
"Login failed - user inactive", "Login failed - user inactive",
@@ -189,19 +190,19 @@ async def login(
detail="User inactive", detail="User inactive",
) )
# 5. Verificar 2FA si está habilitado # 5. Verificar 2FA si está habilitado
if user.totp_enabled: if user.totp_enabled:
if not login_data.totp_code: if not login_data.totp_code:
# Indicar al frontend que debe pedir el código TOTP # Indicar al frontend que debe pedir el código TOTP
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código." 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): if not security.verify_totp(user.totp_secret, login_data.totp_code):
logger.warning("Login failed - invalid 2FA code", email=login_data.email) logger.warning("Login failed - invalid 2FA code", email=login_data.email)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Código 2FA inválido o expirado" detail="Código 2FA inválido o expirado"
) )
# Create tokens # Create tokens
@@ -233,7 +234,7 @@ async def login(
detail="Service temporarily unavailable", detail="Service temporarily unavailable",
) )
# Registrar login exitoso en auditoría # Registrar login exitoso en auditoría
try: try:
await AuditService.log( await AuditService.log(
db=db, db=db,
@@ -392,7 +393,7 @@ async def logout(
except Exception as e: except Exception as e:
logger.warning("Failed to revoke refresh tokens on logout", error=str(e)) logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
# Registrar logout en auditoría # Registrar logout en auditoría
try: try:
import uuid import uuid
user_id = uuid.UUID(payload["sub"]) user_id = uuid.UUID(payload["sub"])
@@ -413,7 +414,7 @@ async def logout(
logger.info("Logout successful", user_id=payload["sub"]) logger.info("Logout successful", user_id=payload["sub"])
# Borrar la cookie correcta según el rol del usuario # Borrar la cookie correcta según el rol del usuario
cookie_name = _cookie_name_for_role(payload.get("role", "")) cookie_name = _cookie_name_for_role(payload.get("role", ""))
response.delete_cookie(key=cookie_name) response.delete_cookie(key=cookie_name)
return {"message": "Successfully logged out"} return {"message": "Successfully logged out"}
@@ -502,7 +503,7 @@ async def get_2fa_status(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
Consultar si el 2FA está habilitado para el usuario actual. Consultar si el 2FA está habilitado para el usuario actual.
Returns: Returns:
Estado de 2FA del usuario autenticado. Estado de 2FA del usuario autenticado.
@@ -516,10 +517,10 @@ async def setup_2fa(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI. 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. 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. Se necesita llamar a /2fa/enable con un código válido para activarlo.
Returns: Returns:
Secret y QR URI para escanear con la app autenticadora. Secret y QR URI para escanear con la app autenticadora.
@@ -527,7 +528,7 @@ async def setup_2fa(
new_secret = security.generate_totp_secret() new_secret = security.generate_totp_secret()
qr_uri = security.generate_totp_uri(new_secret, current_user.email) qr_uri = security.generate_totp_uri(new_secret, current_user.email)
# Guardar el secret (sin habilitar aún) # Guardar el secret (sin habilitar aún)
current_user.totp_secret = new_secret current_user.totp_secret = new_secret
await db.commit() await db.commit()
@@ -543,29 +544,29 @@ async def enable_2fa(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
Activar 2FA verificando que el usuario escaneó correctamente el QR. Activar 2FA verificando que el usuario escaneó correctamente el QR.
Requiere que /2fa/setup haya sido llamado previamente. Requiere que /2fa/setup haya sido llamado previamente.
Args: Args:
data: Código TOTP generado por la app autenticadora. data: Código TOTP generado por la app autenticadora.
Returns: Returns:
Confirmación y lista de códigos de respaldo. Confirmación y lista de códigos de respaldo.
""" """
if not current_user.totp_secret: if not current_user.totp_secret:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Primero inicia el proceso de configuración con /2fa/setup" detail="Primero inicia el proceso de configuración con /2fa/setup"
) )
if not security.verify_totp(current_user.totp_secret, data.totp_code): if not security.verify_totp(current_user.totp_secret, data.totp_code):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo." detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
) )
# Activar 2FA y generar códigos de respaldo # Activar 2FA y generar códigos de respaldo
backup_codes = security.generate_backup_codes() backup_codes = security.generate_backup_codes()
current_user.totp_enabled = True current_user.totp_enabled = True
current_user.backup_codes = backup_codes current_user.backup_codes = backup_codes
@@ -593,21 +594,21 @@ async def disable_2fa(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
Deshabilitar 2FA verificando con código TOTP o código de respaldo. Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Args: Args:
data: totp_code o backup_code para verificar identidad. data: totp_code o backup_code para verificar identidad.
Returns: Returns:
Mensaje de confirmación. Mensaje de confirmación.
""" """
if not current_user.totp_enabled: if not current_user.totp_enabled:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="El 2FA no está habilitado en esta cuenta" detail="El 2FA no está habilitado en esta cuenta"
) )
# Verificar con TOTP o código de respaldo # Verificar con TOTP o código de respaldo
verified = False verified = False
if data.totp_code: if data.totp_code:
@@ -615,7 +616,7 @@ async def disable_2fa(
elif data.backup_code and current_user.backup_codes: elif data.backup_code and current_user.backup_codes:
if data.backup_code in current_user.backup_codes: if data.backup_code in current_user.backup_codes:
verified = True verified = True
# Invalidar el código de respaldo usado # Invalidar el código de respaldo usado
current_user.backup_codes = [ current_user.backup_codes = [
c for c in current_user.backup_codes if c != data.backup_code c for c in current_user.backup_codes if c != data.backup_code
] ]
@@ -623,7 +624,7 @@ async def disable_2fa(
if not verified: if not verified:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido." detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
) )
# Deshabilitar 2FA # Deshabilitar 2FA
@@ -644,7 +645,7 @@ async def disable_2fa(
logger.info("2FA disabled", user_id=str(current_user.id)) logger.info("2FA disabled", user_id=str(current_user.id))
return {"message": "Autenticación de dos factores deshabilitada correctamente"} return {"message": "Autenticación de dos factores deshabilitada correctamente"}
@router.post("/change-password", status_code=status.HTTP_200_OK) @router.post("/change-password", status_code=status.HTTP_200_OK)
@@ -654,32 +655,32 @@ async def change_password(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
Cambiar la contraseña del usuario autenticado. Cambiar la contraseña del usuario autenticado.
Verifica la contraseña actual antes de actualizar. Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa. Requiere autenticación activa.
""" """
from datetime import datetime from datetime import datetime
# Validar longitud mínima # Validar longitud mínima
if len(data.new_password) < 8: if len(data.new_password) < 8:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña debe tener al menos 8 caracteres" detail="La nueva contraseña debe tener al menos 8 caracteres"
) )
# Verificar que la contraseña actual sea correcta # Verificar que la contraseña actual sea correcta
if not security.verify_password(data.current_password, current_user.password_hash): if not security.verify_password(data.current_password, current_user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña actual es incorrecta" detail="La contraseña actual es incorrecta"
) )
# No permitir que la nueva sea igual a la actual # No permitir que la nueva sea igual a la actual
if security.verify_password(data.new_password, current_user.password_hash): if security.verify_password(data.new_password, current_user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña no puede ser igual a la actual" detail="La nueva contraseña no puede ser igual a la actual"
) )
current_user.password_hash = security.hash_password(data.new_password) current_user.password_hash = security.hash_password(data.new_password)
@@ -697,11 +698,11 @@ async def change_password(
await db.commit() await db.commit()
logger.info("Password changed", user_id=str(current_user.id)) logger.info("Password changed", user_id=str(current_user.id))
return {"message": "Contraseña actualizada correctamente"} return {"message": "Contraseña actualizada correctamente"}
# ============================================================ # ============================================================
# Recuperación de contraseña (forgot / reset) # Recuperación de contraseña (forgot / reset)
# ============================================================ # ============================================================
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos _RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
@@ -716,10 +717,10 @@ async def forgot_password(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
Solicitar reseteo de contraseña. Solicitar reseteo de contraseña.
Siempre retorna 200 aunque el email no exista, para no revelar Siempre retorna 200 aunque el email no exista, para no revelar
si una dirección está registrada en el sistema. si una dirección está registrada en el sistema.
""" """
import secrets import secrets
from redis.asyncio import from_url as redis_from_url from redis.asyncio import from_url as redis_from_url
@@ -735,9 +736,9 @@ async def forgot_password(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
# Respuesta idéntica no revelar existencia # Respuesta idéntica — no revelar existencia
logger.info("Forgot password: email not found", email=data.email) logger.info("Forgot password: email not found", email=data.email)
return {"message": "Si el correo está registrado recibirás un enlace en breve."} return {"message": "Si el correo está registrado recibirás un enlace en breve."}
# Generar token seguro # Generar token seguro
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
@@ -757,7 +758,7 @@ async def forgot_password(
await send_email( await send_email(
to_email=user.email, to_email=user.email,
subject="Restablece tu contraseña — ServiceManager", subject="Restablece tu contraseña — ServiceManager",
html_content=html, html_content=html,
text_content=text, text_content=text,
) )
@@ -774,7 +775,7 @@ async def forgot_password(
await db.commit() await db.commit()
logger.info("Password reset email sent", user_id=str(user.id)) logger.info("Password reset email sent", user_id=str(user.id))
return {"message": "Si el correo está registrado recibirás un enlace en breve."} return {"message": "Si el correo está registrado recibirás un enlace en breve."}
@router.post("/reset-password", status_code=status.HTTP_200_OK) @router.post("/reset-password", status_code=status.HTTP_200_OK)
@@ -785,7 +786,7 @@ async def reset_password(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
Aplicar nueva contraseña usando el token recibido por email. Aplicar nueva contraseña usando el token recibido por email.
El token es de un solo uso: se elimina de Redis al usarse. El token es de un solo uso: se elimina de Redis al usarse.
""" """
@@ -796,7 +797,7 @@ async def reset_password(
if len(data.new_password) < 8: if len(data.new_password) < 8:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña debe tener al menos 8 caracteres" detail="La contraseña debe tener al menos 8 caracteres"
) )
redis_key = f"{_RESET_KEY_PREFIX}{data.token}" redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
@@ -807,7 +808,7 @@ async def reset_password(
if not user_id_str: if not user_id_str:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo." detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
) )
# Eliminar token inmediatamente (un solo uso) # Eliminar token inmediatamente (un solo uso)
@@ -838,4 +839,4 @@ async def reset_password(
await db.commit() await db.commit()
logger.info("Password reset completed", user_id=str(user.id)) logger.info("Password reset completed", user_id=str(user.id))
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."} return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}

67
backend/fix_login.py Normal file
View File

@@ -0,0 +1,67 @@
import re
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # 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",
)'''
new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email
if login_data.tenant_slug:
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:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None'''
if old in content:
content = content.replace(old, new)
print("OK: bloque tenant reemplazado")
else:
print("ERROR: bloque no encontrado")
# Tambien actualizar la query de usuario para usar tenant o no
old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)'''
new2 = ''' # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
if tenant:
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)'''
if old2 in content:
content = content.replace(old2, new2)
print("OK: bloque query reemplazado")
else:
print("ERROR: bloque query no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)
print("Listo")

23
backend/fix_ratelimit.py Normal file
View File

@@ -0,0 +1,23 @@
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # 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)'''
new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
if old in content:
content = content.replace(old, new)
print("OK: rate limiting fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)

67
fix_login.py Normal file
View File

@@ -0,0 +1,67 @@
import re
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # 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",
)'''
new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email
if login_data.tenant_slug:
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:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None'''
if old in content:
content = content.replace(old, new)
print("OK: bloque tenant reemplazado")
else:
print("ERROR: bloque no encontrado")
# Tambien actualizar la query de usuario para usar tenant o no
old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)'''
new2 = ''' # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
if tenant:
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)'''
if old2 in content:
content = content.replace(old2, new2)
print("OK: bloque query reemplazado")
else:
print("ERROR: bloque query no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)
print("Listo")

23
fix_ratelimit.py Normal file
View File

@@ -0,0 +1,23 @@
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # 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)'''
new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
if old in content:
content = content.replace(old, new)
print("OK: rate limiting fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)

View File

@@ -7,7 +7,7 @@
let email = ''; let email = '';
let password = ''; let password = '';
let tenantSlug = 'aduanasoft-demo'; let tenantSlug = 'ventas';
let totpCode = ''; let totpCode = '';
let isLoading = false; let isLoading = false;
let showTwoFactor = false; let showTwoFactor = false;
@@ -34,11 +34,11 @@
await auth.login({ await auth.login({
email, email,
password, password,
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo', tenant_slug: tenantSlug.trim() || 'ventas',
totp_code: totpCode || undefined totp_code: totpCode || undefined
}); });
toast.success('¡Bienvenido! Has iniciado sesión correctamente'); toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/'); goto('/');
} catch (error: any) { } catch (error: any) {
console.error('Login error:', error); console.error('Login error:', error);
@@ -46,9 +46,9 @@
// Check if 2FA is required // Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) { if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true; showTwoFactor = true;
errorMessage = 'Introduce el código de tu aplicación de autenticación'; errorMessage = 'Introduce el código de tu aplicación de autenticación';
} else { } else {
errorMessage = error.message || 'Error al iniciar sesión'; errorMessage = error.message || 'Error al iniciar sesión';
toast.error(errorMessage); toast.error(errorMessage);
} }
} finally { } finally {
@@ -96,8 +96,8 @@
de Servicios de TI de Servicios de TI
</h2> </h2>
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md"> <p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y
reciba asistencia especializada para garantizar la continuidad de su operación. reciba asistencia especializada para garantizar la continuidad de su operación.
</p> </p>
</div> </div>
@@ -125,7 +125,7 @@
<div <div
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600" class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
> >
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" /> <Icon name="alert-circle" className="w-4 h-4 flex-shrink-0" />
{errorMessage} {errorMessage}
</div> </div>
{/if} {/if}
@@ -135,13 +135,13 @@
<!-- Email Input --> <!-- Email Input -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700" <label for="email" class="block text-sm font-semibold text-gray-700"
>Correo Electrónico</label >Correo Electrónico</label
> >
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon <Icon
name="mail" name="mail"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/> />
</div> </div>
<input <input
@@ -160,13 +160,13 @@
<!-- Password Input --> <!-- Password Input -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700" <label for="password" class="block text-sm font-semibold text-gray-700"
>Contraseña</label >Contraseña</label
> >
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon <Icon
name="lock" name="lock"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/> />
</div> </div>
{#if showPassword} {#if showPassword}
@@ -176,7 +176,7 @@
bind:value={password} bind:value={password}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm" class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••" placeholder="••••••••"
required required
disabled={isLoading} disabled={isLoading}
/> />
@@ -187,7 +187,7 @@
bind:value={password} bind:value={password}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm" class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••" placeholder="••••••••"
required required
disabled={isLoading} disabled={isLoading}
/> />
@@ -197,7 +197,7 @@
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none" class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
on:click={() => (showPassword = !showPassword)} on:click={() => (showPassword = !showPassword)}
> >
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" /> <Icon name={showPassword ? 'eye-off' : 'eye'} className="w-5 h-5" />
</button> </button>
</div> </div>
</div> </div>
@@ -221,7 +221,7 @@
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer" class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
on:click={() => goto('/forgot-password')} on:click={() => goto('/forgot-password')}
> >
Olvide mi clave Olvidé mi clave
</button> </button>
</div> </div>
</div> </div>
@@ -229,13 +229,13 @@
<!-- 2FA Input --> <!-- 2FA Input -->
<div class="space-y-4 animate-slide-up"> <div class="space-y-4 animate-slide-up">
<label for="code" class="block text-sm font-medium text-gray-700 text-center" <label for="code" class="block text-sm font-medium text-gray-700 text-center"
>Código de Verificación (2FA)</label >Código de Verificación (2FA)</label
> >
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p> <p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="shield-check" class="w-5 h-5 text-blue-500" /> <Icon name="shield-check" className="w-5 h-5 text-blue-500" />
</div> </div>
<input <input
id="code" id="code"
@@ -259,7 +259,7 @@
disabled={isLoading} disabled={isLoading}
> >
{#if isLoading} {#if isLoading}
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" /> <Icon name="loader-2" className="w-5 h-5 animate-spin mr-2" />
Procesando... Procesando...
{:else} {:else}
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'} {showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
@@ -268,7 +268,7 @@
</div> </div>
<div class="mt-8 text-center text-xs text-gray-400"> <div class="mt-8 text-center text-xs text-gray-400">
© 2026 Aduanasoft. Acceso exclusivo autorizado. © 2026 Aduanasoft. Acceso exclusivo autorizado.
</div> </div>
</form> </form>
</div> </div>

View File

@@ -32,11 +32,11 @@
await auth.login({ await auth.login({
email, email,
password, password,
tenant_slug: 'aduanasoft-demo', tenant_slug: 'aduanasoft',
totp_code: totpCode || undefined totp_code: totpCode || undefined
}); });
toast.success('¡Bienvenido! Has iniciado sesión correctamente'); toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/'); goto('/');
} catch (error: any) { } catch (error: any) {
console.error('Login error:', error); console.error('Login error:', error);
@@ -44,9 +44,9 @@
// Check if 2FA is required // Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) { if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true; showTwoFactor = true;
errorMessage = 'Introduce el código de tu aplicación de autenticación'; errorMessage = 'Introduce el código de tu aplicación de autenticación';
} else { } else {
errorMessage = error.message || 'Error al iniciar sesión'; errorMessage = error.message || 'Error al iniciar sesión';
toast.error(errorMessage); toast.error(errorMessage);
} }
} finally { } finally {
@@ -77,27 +77,27 @@
<div class="relative z-10"> <div class="relative z-10">
<div class="flex items-center space-x-3 mb-6"> <div class="flex items-center space-x-3 mb-6">
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30"> <div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
<Icon name="server" class="w-6 h-6 text-blue-400" /> <Icon name="server" className="w-6 h-6 text-blue-400" />
</div> </div>
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span> <span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
</div> </div>
<h1 class="text-3xl font-bold tracking-tight mb-4"> <h1 class="text-3xl font-bold tracking-tight mb-4">
Panel de Administración Panel de Administración
</h1> </h1>
<p class="text-gray-400 text-sm leading-relaxed max-w-sm"> <p class="text-gray-400 text-sm leading-relaxed max-w-sm">
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado. Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
</p> </p>
</div> </div>
<div class="relative z-10 mt-12"> <div class="relative z-10 mt-12">
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono"> <div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="check-circle" class="w-4 h-4 text-green-500" /> <Icon name="check-circle" className="w-4 h-4 text-green-500" />
<span>System Status: Operational</span> <span>System Status: Operational</span>
</div> </div>
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono"> <div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="shield" class="w-4 h-4 text-blue-500" /> <Icon name="shield" className="w-4 h-4 text-blue-500" />
<span>256-bit Encryption Enabled</span> <span>256-bit Encryption Enabled</span>
</div> </div>
</div> </div>
@@ -109,14 +109,14 @@
<div class="max-w-sm mx-auto w-full"> <div class="max-w-sm mx-auto w-full">
<div class="mb-8"> <div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p> <p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
</div> </div>
<form on:submit|preventDefault={handleLogin} class="space-y-5"> <form on:submit|preventDefault={handleLogin} class="space-y-5">
{#if errorMessage} {#if errorMessage}
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3"> <div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" /> <Icon name="alert-triangle" className="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p> <p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
</div> </div>
{/if} {/if}
@@ -127,7 +127,7 @@
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label> <label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="user" class="w-5 h-5" /> <Icon name="user" className="w-5 h-5" />
</div> </div>
<input <input
id="email" id="email"
@@ -146,7 +146,7 @@
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label> <label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="lock" class="w-5 h-5" /> <Icon name="lock" className="w-5 h-5" />
</div> </div>
<input <input
id="password" id="password"
@@ -154,7 +154,7 @@
bind:value={password} bind:value={password}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm" class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="••••••••••••" placeholder="••••••••••••"
required required
disabled={isLoading} disabled={isLoading}
/> />
@@ -165,7 +165,7 @@
{:else} {:else}
<!-- 2FA Input --> <!-- 2FA Input -->
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30"> <div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label> <label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
<div class="relative"> <div class="relative">
<input <input
id="code" id="code"
@@ -193,7 +193,7 @@
disabled={isLoading} disabled={isLoading}
> >
{#if isLoading} {#if isLoading}
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" /> <Icon name="loader" className="w-4 h-4 animate-spin mr-2" />
Autenticando... Autenticando...
{:else} {:else}
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'} {showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
@@ -204,7 +204,7 @@
</div> </div>
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800"> <div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p> <p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
</div> </div>
</div> </div>
</div> </div>