Files
service_manager/backend/app/api/v1/endpoints/auth.py
2026-02-12 12:23:11 -07:00

449 lines
13 KiB
Python

"""
Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime # Para actualizar last_login
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 # Servicio de auditoría
from app.services.token_service import TokenService # Servicio de tokens
router = APIRouter()
logger = structlog.get_logger(__name__)
settings = get_settings()
# OAuth2 scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class LoginRequest(BaseModel):
"""Schema for login request."""
email: EmailStr
password: str
tenant_slug: str
totp_code: Optional[str] = None
class LoginResponse(BaseModel):
"""Schema for login response."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
user: dict
class RefreshTokenRequest(BaseModel):
"""Schema for refresh token request."""
refresh_token: str
class TokenResponse(BaseModel):
"""Schema for token response."""
access_token: str
token_type: str = "bearer"
expires_in: int
# ===================================
# ENDPOINTS
# ===================================
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
request: Request, # Agregar Request para capturar IP y user agent
db: AsyncSession = Depends(get_db)
):
"""
Authenticate user and return access/refresh tokens.
**Auditoría**: Registra login exitoso y fallido en audit_logs
Args:
login_data: Login credentials
request: FastAPI Request (para auditoría)
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
)
# 1. Buscar usuario en base de datos
query = select(User).where(User.email == login_data.email)
result = await db.execute(query)
user = result.scalar_one_or_none()
# 2. 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
)
# 🔍 AUDITORÍA: Registrar intento fallido
if user: # Solo si el usuario existe (password incorrecto)
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # NULL porque aún no autenticado
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "invalid_password"
},
request=request
)
await db.commit() # Commit del audit log
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas"
)
# 3. Verificar si está activo
if not user.is_active:
logger.warning(
"Login failed - user inactive",
email=login_data.email
)
# 🔍 AUDITORÍA: Registrar intento con usuario inactivo
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None,
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "user_inactive"
},
request=request
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo"
)
# Actualizar último login
user.last_login = datetime.utcnow()
# 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)
# Extraer información del dispositivo para rastreo
user_agent = request.headers.get("user-agent")
client_ip = request.client.host if request.client else None
# device_id: El frontend puede enviarlo en el futuro como header
device_id = request.headers.get("x-device-id") # Opcional
# device_name: Simplificado del user-agent (ej: "Chrome en Windows")
device_name = None
if user_agent:
# Parseo básico para obtener un nombre legible
if "Chrome" in user_agent:
device_name = "Chrome"
elif "Firefox" in user_agent:
device_name = "Firefox"
elif "Safari" in user_agent:
device_name = "Safari"
elif "Edge" in user_agent:
device_name = "Edge"
else:
device_name = "Unknown Browser"
# Agregar OS
if "Windows" in user_agent:
device_name += " en Windows"
elif "Macintosh" in user_agent or "Mac OS" in user_agent:
device_name += " en macOS"
elif "Linux" in user_agent:
device_name += " en Linux"
elif "Android" in user_agent:
device_name += " en Android"
elif "iPhone" in user_agent or "iPad" in user_agent:
device_name += " en iOS"
# Persistir refresh token en BD con tracking completo
await TokenService.create_refresh_token(
db=db,
user=user,
refresh_token=refresh_token,
device_id=device_id,
device_name=device_name,
user_agent=user_agent,
ip_address=client_ip
)
# 🔍 AUDITORÍA: Registrar login exitoso
await AuditService.log_login(
db=db,
user=user,
request=request,
success=True
)
# Commit de todos los cambios (last_login + refresh token + audit log)
await db.commit()
logger.info(
"Login successful",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
user_id=str(user.id)
)
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),
"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.
Verifica el JWT, valida en BD que no esté revocado/expirado,
actualiza estadísticas de uso y genera nuevo access 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")
# 1. Verify refresh token JWT signature
payload = security.verify_token(refresh_data.refresh_token)
if not payload or payload.get("type") != "refresh":
logger.warning("Token refresh failed - invalid JWT")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# 2. Verificar que el token exista en BD y no esté revocado/expirado
# También actualiza last_used_at y usage_count automáticamente
db_token = await TokenService.verify_refresh_token(db, refresh_data.refresh_token)
if not db_token:
logger.warning("Token refresh failed - token not found, revoked or expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked refresh token"
)
# 3. 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)
# 4. Commit actualización de estadísticas de uso
await db.commit()
logger.info(
"Token refresh successful",
user_id=payload["sub"],
token_id=str(db_token.id),
usage_count=db_token.usage_count
)
return TokenResponse(
access_token=access_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
@router.post("/logout")
async def logout(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
"""
Logout user and revoke all refresh tokens.
Revoca todos los tokens del usuario (logout en todos los dispositivos)
y registra quién ejecutó la revocación para auditoría.
Args:
token: Access token
db: Database session
Returns:
Success message with count of revoked tokens
"""
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"
)
# Extraer user_id del token
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
user_id = uuid.UUID(user_id_str)
# Revocar todos los tokens del usuario con auditoría de quién lo revocó
count = await TokenService.revoke_all_user_tokens(
db=db,
user_id=user_id,
revoked_by_user_id=user_id # El usuario se revoca a sí mismo
)
await db.commit()
logger.info("Logout successful", user_id=str(user_id), tokens_revoked=count)
return {
"message": "Successfully logged out from all devices",
"tokens_revoked": count
}
@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,
"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