""" 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