tenant arreglado
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Auth Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
|
||||
"""Schema para solicitud de login."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
tenant_slug: Optional[str] = None
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
|
||||
|
||||
|
||||
class TwoFactorEnableRequest(BaseModel):
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
totp_code: str
|
||||
|
||||
|
||||
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
|
||||
backup_codes: List[str]
|
||||
|
||||
|
||||
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
|
||||
backup_code: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cambio de contraseña
|
||||
# Cambio de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
current_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):
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
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
|
||||
@@ -28,7 +28,7 @@ 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."""
|
||||
"""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,
|
||||
@@ -88,25 +88,23 @@ async def login(
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 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",
|
||||
# 1. Validar tenant - por slug si viene, sino buscar 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
|
||||
|
||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
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()
|
||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||
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)},
|
||||
)
|
||||
|
||||
# 2. Buscar usuario en base de datos (aislado por tenant)
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
# 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)
|
||||
result = await db.execute(query)
|
||||
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):
|
||||
logger.warning(
|
||||
"Login failed - invalid credentials",
|
||||
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:
|
||||
try:
|
||||
await AuditService.log(
|
||||
@@ -178,7 +179,7 @@ async def login(
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# 4. Verificar si está activo
|
||||
# 4. Verificar si está activo
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Login failed - user inactive",
|
||||
@@ -189,19 +190,19 @@ async def login(
|
||||
detail="User inactive",
|
||||
)
|
||||
|
||||
# 5. 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
|
||||
# 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."
|
||||
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"
|
||||
detail="Código 2FA inválido o expirado"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
@@ -233,7 +234,7 @@ async def login(
|
||||
detail="Service temporarily unavailable",
|
||||
)
|
||||
|
||||
# Registrar login exitoso en auditoría
|
||||
# Registrar login exitoso en auditorÃa
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
@@ -392,7 +393,7 @@ async def logout(
|
||||
except Exception as e:
|
||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||
|
||||
# Registrar logout en auditoría
|
||||
# Registrar logout en auditorÃa
|
||||
try:
|
||||
import uuid
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
@@ -413,7 +414,7 @@ async def logout(
|
||||
|
||||
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", ""))
|
||||
response.delete_cookie(key=cookie_name)
|
||||
return {"message": "Successfully logged out"}
|
||||
@@ -502,7 +503,7 @@ async def get_2fa_status(
|
||||
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:
|
||||
Estado de 2FA del usuario autenticado.
|
||||
@@ -516,10 +517,10 @@ async def setup_2fa(
|
||||
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.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
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.
|
||||
@@ -527,7 +528,7 @@ async def setup_2fa(
|
||||
new_secret = security.generate_totp_secret()
|
||||
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
|
||||
await db.commit()
|
||||
|
||||
@@ -543,29 +544,29 @@ async def enable_2fa(
|
||||
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.
|
||||
|
||||
Args:
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
|
||||
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:
|
||||
raise HTTPException(
|
||||
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):
|
||||
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."
|
||||
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()
|
||||
current_user.totp_enabled = True
|
||||
current_user.backup_codes = backup_codes
|
||||
@@ -593,21 +594,21 @@ async def disable_2fa(
|
||||
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:
|
||||
data: totp_code o backup_code para verificar identidad.
|
||||
|
||||
Returns:
|
||||
Mensaje de confirmación.
|
||||
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"
|
||||
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
|
||||
|
||||
if data.totp_code:
|
||||
@@ -615,7 +616,7 @@ async def disable_2fa(
|
||||
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
|
||||
# Invalidar el código de respaldo usado
|
||||
current_user.backup_codes = [
|
||||
c for c in current_user.backup_codes if c != data.backup_code
|
||||
]
|
||||
@@ -623,7 +624,7 @@ async def disable_2fa(
|
||||
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."
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
)
|
||||
|
||||
# Deshabilitar 2FA
|
||||
@@ -644,7 +645,7 @@ async def disable_2fa(
|
||||
|
||||
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)
|
||||
@@ -654,32 +655,32 @@ async def change_password(
|
||||
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.
|
||||
Requiere autenticación activa.
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Validar longitud mínima
|
||||
# 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"
|
||||
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):
|
||||
raise HTTPException(
|
||||
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
|
||||
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"
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
)
|
||||
|
||||
current_user.password_hash = security.hash_password(data.new_password)
|
||||
@@ -697,11 +698,11 @@ async def change_password(
|
||||
await db.commit()
|
||||
|
||||
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
|
||||
@@ -716,10 +717,10 @@ async def forgot_password(
|
||||
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
|
||||
si una dirección está registrada en el sistema.
|
||||
si una dirección está registrada en el sistema.
|
||||
"""
|
||||
import secrets
|
||||
from redis.asyncio import from_url as redis_from_url
|
||||
@@ -735,9 +736,9 @@ async def forgot_password(
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
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)
|
||||
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
|
||||
token = secrets.token_urlsafe(32)
|
||||
@@ -757,7 +758,7 @@ async def forgot_password(
|
||||
|
||||
await send_email(
|
||||
to_email=user.email,
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
html_content=html,
|
||||
text_content=text,
|
||||
)
|
||||
@@ -774,7 +775,7 @@ async def forgot_password(
|
||||
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."}
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
|
||||
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||
@@ -785,7 +786,7 @@ async def reset_password(
|
||||
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.
|
||||
"""
|
||||
@@ -796,7 +797,7 @@ async def reset_password(
|
||||
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"
|
||||
detail="La contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||
@@ -807,7 +808,7 @@ async def reset_password(
|
||||
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."
|
||||
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||
)
|
||||
|
||||
# Eliminar token inmediatamente (un solo uso)
|
||||
@@ -838,4 +839,4 @@ async def reset_password(
|
||||
await db.commit()
|
||||
|
||||
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."}
|
||||
Reference in New Issue
Block a user