Auditoria funcionando
This commit is contained in:
@@ -4,13 +4,14 @@ Authentication Endpoints - ServiceManagerWeb
|
||||
Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi.security import 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
|
||||
@@ -18,6 +19,8 @@ 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__)
|
||||
@@ -67,13 +70,17 @@ class TokenResponse(BaseModel):
|
||||
@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:
|
||||
@@ -99,6 +106,24 @@ async def login(
|
||||
"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"
|
||||
@@ -110,11 +135,31 @@ async def login(
|
||||
"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),
|
||||
@@ -126,6 +171,62 @@ async def login(
|
||||
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,
|
||||
@@ -159,6 +260,9 @@ async def refresh_token(
|
||||
"""
|
||||
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
|
||||
@@ -171,18 +275,26 @@ async def refresh_token(
|
||||
"""
|
||||
logger.info("Token refresh attempt")
|
||||
|
||||
# Verify refresh token
|
||||
# 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 token")
|
||||
logger.warning("Token refresh failed - invalid JWT")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# TODO: Check if refresh token exists in database and is not revoked
|
||||
# 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"
|
||||
)
|
||||
|
||||
# Create new access token
|
||||
# 3. Create new access token
|
||||
token_data = {
|
||||
"sub": payload["sub"],
|
||||
"email": payload["email"],
|
||||
@@ -192,7 +304,15 @@ async def refresh_token(
|
||||
|
||||
access_token = security.create_access_token(token_data)
|
||||
|
||||
logger.info("Token refresh successful", user_id=payload["sub"])
|
||||
# 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,
|
||||
@@ -206,14 +326,17 @@ async def logout(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Logout user and revoke refresh token.
|
||||
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
|
||||
Success message with count of revoked tokens
|
||||
"""
|
||||
logger.info("Logout attempt")
|
||||
|
||||
@@ -225,11 +348,31 @@ async def logout(
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# TODO: Revoke refresh token in database
|
||||
# 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"
|
||||
)
|
||||
|
||||
logger.info("Logout successful", user_id=payload["sub"])
|
||||
user_id = uuid.UUID(user_id_str)
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
# 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")
|
||||
|
||||
Reference in New Issue
Block a user