Files
service_manager/backend/app/api/v1/endpoints/auth.py
2026-03-11 09:26:36 -06:00

844 lines
27 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import Optional
import structlog
from app.core.database import get_db
from app.core.security import security
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.core.limiter import limiter
# 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,
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
)
router = APIRouter()
logger = structlog.get_logger(__name__)
settings = get_settings()
# ===================================
# ENDPOINTS
# ===================================
@router.post("/login", response_model=LoginResponse)
@limiter.limit("10/minute")
async def login(
login_data: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""
Authenticate user and return access/refresh tokens.
Args:
login_data: Login credentials
db: Database session
Returns:
LoginResponse with tokens and user info
Raises:
HTTPException: If authentication fails
"""
logger.info(
"Login attempt",
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. 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 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)
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 - 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
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)
if user:
try:
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # Login fallido = sin user_id
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={"email": login_data.email, "reason": "invalid_password"}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
# 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_403_FORBIDDEN,
detail="User inactive",
)
# 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
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
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"
)
# Create tokens
token_data = {
"sub": str(user.id),
"email": user.email,
"role": user.role.value if hasattr(user.role, "value") else user.role,
"tenant_id": str(user.tenant_id)
}
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:
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.login",
resource_type="user",
resource_id=user.id,
metadata={"email": user.email, "success": True}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
logger.info(
"Login successful",
email=login_data.email,
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)
# 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,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
user={
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role,
"tenant_id": str(user.tenant_id),
"tenant_slug": tenant.slug if tenant else str(user.tenant_id),
"is_active": user.is_active,
"is_two_factor_enabled": user.totp_enabled or False,
"created_at": user.created_at.isoformat() if user.created_at else None
}
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
refresh_data: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
):
"""
Refresh access token using refresh token.
Args:
refresh_data: Refresh token data
db: Database session
Returns:
New access token
Raises:
HTTPException: If refresh token is invalid
"""
logger.info("Token refresh attempt")
# Verify refresh token
payload = security.verify_token(refresh_data.refresh_token)
if not payload or payload.get("type") != "refresh":
logger.warning("Token refresh failed - invalid token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# 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 = {
"sub": payload["sub"],
"email": payload["email"],
"role": payload["role"],
"tenant_id": payload["tenant_id"]
}
access_token = security.create_access_token(token_data)
logger.info("Token refresh successful", user_id=payload["sub"])
return TokenResponse(
access_token=access_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
@router.post("/logout")
async def logout(
response: Response,
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
"""
Logout user and revoke refresh token.
Args:
token: Access token
db: Database session
Returns:
Success message
"""
logger.info("Logout attempt")
# Verify token
payload = security.verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
# 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:
import uuid
user_id = uuid.UUID(payload["sub"])
tenant_id = uuid.UUID(payload["tenant_id"])
await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action="user.logout",
resource_type="user",
resource_id=user_id,
metadata={"email": payload.get("email")}
)
await db.commit()
except Exception as e:
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"}
@router.get("/me")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
"""
Get current user information.
Args:
token: Access token
db: Database session
Returns:
Current user data
Raises:
HTTPException: If token is invalid
"""
# Verify token
payload = security.verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
# Fetch actual user from database
query = select(User).where(User.id == user_id).options(
selectinload(User.tenant)
)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User account is disabled"
)
return {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role.value if hasattr(user.role, 'value') else user.role,
"tenant_id": str(user.tenant_id),
"tenant_name": user.tenant.name if user.tenant else None,
"tenant_slug": user.tenant.slug if user.tenant else None,
"is_active": user.is_active,
"is_two_factor_enabled": user.totp_secret is not None,
"last_login": user.last_login.isoformat() if user.last_login else None,
"created_at": user.created_at.isoformat()
}
# ===================================
# DEPENDENCIES
# ===================================
# Dependencies are imported from app.api.deps to avoid duplication
# Use get_current_user and get_current_active_superuser from deps.py
# ===================================
# 2FA / TOTP ENDPOINTS
# ===================================
@router.get("/2fa/status", response_model=TwoFactorStatusResponse)
async def get_2fa_status(
current_user: User = Depends(get_current_user),
):
"""
Consultar si el 2FA está habilitado para el usuario actual.
Returns:
Estado de 2FA del usuario autenticado.
"""
return TwoFactorStatusResponse(enabled=bool(current_user.totp_enabled))
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
async def setup_2fa(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
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.
Returns:
Secret y QR URI para escanear con la app autenticadora.
"""
new_secret = security.generate_totp_secret()
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
# Guardar el secret (sin habilitar aún)
current_user.totp_secret = new_secret
await db.commit()
logger.info("2FA setup initiated", user_id=str(current_user.id))
return TwoFactorSetupResponse(secret=new_secret, qr_uri=qr_uri)
@router.post("/2fa/enable", response_model=TwoFactorEnableResponse)
async def enable_2fa(
data: TwoFactorEnableRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
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.
Returns:
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"
)
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."
)
# 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
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.2fa_enabled",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("2FA enabled", user_id=str(current_user.id))
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
@router.post("/2fa/disable")
async def disable_2fa(
data: TwoFactorDisableRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
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.
"""
if not current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El 2FA no está habilitado en esta cuenta"
)
# Verificar con TOTP o código de respaldo
verified = False
if data.totp_code:
verified = security.verify_totp(current_user.totp_secret, data.totp_code)
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
current_user.backup_codes = [
c for c in current_user.backup_codes if c != data.backup_code
]
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."
)
# Deshabilitar 2FA
current_user.totp_enabled = False
current_user.totp_secret = None
current_user.backup_codes = None
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.2fa_disabled",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("2FA disabled", user_id=str(current_user.id))
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
@router.post("/change-password", status_code=status.HTTP_200_OK)
async def change_password(
data: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Cambiar la contraseña del usuario autenticado.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
"""
from datetime import datetime
# 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"
)
# 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"
)
# 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"
)
current_user.password_hash = security.hash_password(data.new_password)
current_user.updated_at = datetime.utcnow()
await db.commit()
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.password_changed",
resource_type="user",
resource_id=current_user.id,
)
await db.commit()
logger.info("Password changed", user_id=str(current_user.id))
return {"message": "Contraseña actualizada correctamente"}
# ============================================================
# Recuperación de contraseña (forgot / reset)
# ============================================================
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
_RESET_KEY_PREFIX = "pwd_reset:"
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
@limiter.limit("5/minute")
async def forgot_password(
request: Request,
data: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""
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.
"""
import secrets
from redis.asyncio import from_url as redis_from_url
from app.core.email import send_email, build_password_reset_email
# Buscar usuario activo con ese email
result = await db.execute(
select(User).where(
User.email == data.email,
User.is_active == True, # noqa: E712
).limit(1)
)
user = result.scalar_one_or_none()
if not user:
# 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."}
# Generar token seguro
token = secrets.token_urlsafe(32)
redis_key = f"{_RESET_KEY_PREFIX}{token}"
# Guardar en Redis con TTL de 30 min
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
try:
await redis.setex(redis_key, _RESET_TOKEN_TTL, str(user.id))
finally:
await redis.aclose()
# Construir URL y enviar email
reset_url = f"{settings.CLIENT_FRONTEND_URL}/reset-password?token={token}"
user_name = f"{user.first_name} {user.last_name}".strip() or user.email
html, text = build_password_reset_email(reset_url, user_name)
await send_email(
to_email=user.email,
subject="Restablece tu contraseña — ServiceManager",
html_content=html,
text_content=text,
)
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.password_reset_requested",
resource_type="user",
resource_id=user.id,
new_values={"email": user.email},
)
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."}
@router.post("/reset-password", status_code=status.HTTP_200_OK)
@limiter.limit("5/minute")
async def reset_password(
request: Request,
data: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""
Aplicar nueva contraseña usando el token recibido por email.
El token es de un solo uso: se elimina de Redis al usarse.
"""
from datetime import datetime
from redis.asyncio import from_url as redis_from_url
import uuid
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"
)
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
try:
user_id_str = await redis.get(redis_key)
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."
)
# Eliminar token inmediatamente (un solo uso)
await redis.delete(redis_key)
finally:
await redis.aclose()
# Buscar y actualizar usuario
user = await db.get(User, uuid.UUID(user_id_str))
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Usuario no encontrado o inactivo"
)
user.password_hash = security.hash_password(data.new_password)
user.updated_at = datetime.utcnow()
await db.commit()
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.password_reset_completed",
resource_type="user",
resource_id=user.id,
)
await db.commit()
logger.info("Password reset completed", user_id=str(user.id))
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}