""" Authentication Endpoints - ServiceManagerWeb Endpoints para autenticación y autorización """ from fastapi import APIRouter, HTTPException, status, Depends 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.api.deps import oauth2_scheme, get_current_user 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) async def login( login_data: LoginRequest, 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 ) # 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 ) # 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="Credenciales inválidas" ) # 3. 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_401_UNAUTHORIZED, detail="Usuario inactivo" ) # 4. 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) # 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) ) 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. 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" ) # TODO: Check if refresh token exists in database and is not revoked # 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( 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" ) # TODO: Revoke refresh token in database # 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"]) 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, "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) async def forgot_password( 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) async def reset_password( 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."}