mirror of
https://github.com/sstent/FitTrack_GarminSync.git
synced 2026-01-26 00:51:44 +00:00
feat: Implement MFA authentication flow with garth for CLI
This commit implements the multi-factor authentication (MFA) flow for the CLI using the garth library, as specified in task 007. Changes include: - Created to handle API communication with robust error handling. - Refactored to correctly implement the logout logic and ensure proper handling of API client headers. - Updated with Black, Flake8, Mypy, and Isort configurations. - Implemented and refined integration tests for authentication, sync operations, and sync status checking, including mocking for the API client. - Renamed integration test files for clarity and consistency. - Updated to reflect task completion.
This commit is contained in:
106
cli/src/api_client.py
Normal file
106
cli/src/api_client.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
# Use httpx.AsyncClient for asynchronous requests
|
||||
self.client = httpx.AsyncClient(base_url=base_url)
|
||||
print(f"ApiClient initialized - base_url: {self.base_url}")
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
return self.base_url
|
||||
|
||||
async def authenticate_user(
|
||||
self, username: str, password: str, mfa_code: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
url = f"{self.get_base_url()}/api/garmin/login"
|
||||
print(f"Attempting to connect to: {url}")
|
||||
print(f"Payload being sent (password masked): {{'username': '{username}', 'password': '[REDACTED]', 'mfa_code': {mfa_code is not None}}}")
|
||||
|
||||
payload = {"username": username, "password": password}
|
||||
if mfa_code:
|
||||
payload["mfa_code"] = mfa_code
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
|
||||
if response.status_code == 200:
|
||||
print("Authentication successful (200)")
|
||||
return response.json()
|
||||
elif response.status_code == 400:
|
||||
print("Received 400 Bad Request")
|
||||
response_json = response.json()
|
||||
# Check for MFA required in the 400 response
|
||||
if response_json.get("mfa_required"):
|
||||
return response_json
|
||||
else:
|
||||
# For other 400 errors, raise an exception
|
||||
response.raise_for_status()
|
||||
else:
|
||||
# For any other status code, raise an exception
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"HTTP Status Error: {e}")
|
||||
return {"success": False, "error": str(e), "status_code": e.response.status_code}
|
||||
except httpx.RequestError as e:
|
||||
print(f"HTTP Request Error: {e}")
|
||||
return {"success": False, "error": f"Network error: {e}"}
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
return {"success": False, "error": f"An unexpected error occurred: {e}"}
|
||||
|
||||
async def get_sync_status(self, job_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
if job_id:
|
||||
url = f"{self.get_base_url()}/api/sync/cli/status/{job_id}"
|
||||
else:
|
||||
url = f"{self.get_base_url()}/api/sync/cli/status"
|
||||
|
||||
print(f"Attempting to connect to: {url}")
|
||||
try:
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status() # Raise for non-2xx status codes
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"HTTP Status Error: {e}")
|
||||
return {"success": False, "error": str(e), "status_code": e.response.status_code}
|
||||
except httpx.RequestError as e:
|
||||
print(f"HTTP Request Error: {e}")
|
||||
return {"success": False, "error": f"Network error: {e}"}
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
return {"success": False, "error": f"An unexpected error occurred: {e}"}
|
||||
|
||||
async def trigger_sync(
|
||||
self,
|
||||
sync_type: str,
|
||||
date_range: Optional[Dict[str, str]] = None,
|
||||
force_full_sync: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
url = f"{self.get_base_url()}/api/sync/cli/trigger"
|
||||
print(f"Attempting to connect to: {url}")
|
||||
|
||||
payload = {"sync_type": sync_type, "force_full_sync": force_full_sync}
|
||||
if date_range:
|
||||
payload["date_range"] = date_range
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
response.raise_for_status() # Raise for non-2xx status codes
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"HTTP Status Error: {e}")
|
||||
return {"success": False, "error": str(e), "status_code": e.response.status_code}
|
||||
except httpx.RequestError as e:
|
||||
print(f"HTTP Request Error: {e}")
|
||||
return {"success": False, "error": f"Network error: {e}"}
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
return {"success": False, "error": f"An unexpected error occurred: {e}"}
|
||||
|
||||
async def close(self):
|
||||
print("Closing ApiClient HTTP session.")
|
||||
await self.client.aclose()
|
||||
|
||||
# Create a default client instance for direct use in CLI commands if needed
|
||||
client = ApiClient(base_url="http://localhost:8001")
|
||||
@@ -1,16 +1,15 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from ..api.client import ApiClient
|
||||
from ..auth.token_manager import TokenManager
|
||||
from ..models.session import UserSession
|
||||
from ..models.token import AuthenticationToken
|
||||
from ..api.client import ApiClient
|
||||
from ..auth.token_manager import TokenManager
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Handles authentication flows with MFA support"""
|
||||
|
||||
|
||||
def __init__(self, api_client: ApiClient, token_manager: TokenManager):
|
||||
self.api_client = api_client
|
||||
self.token_manager = token_manager
|
||||
@@ -20,11 +19,14 @@ class AuthManager:
|
||||
) -> Optional[UserSession]:
|
||||
"""Authenticate user with optional MFA code"""
|
||||
try:
|
||||
# Try to authenticate via API
|
||||
# First, try to authenticate via API with username and password only
|
||||
auth_response = await self.api_client.authenticate_user(
|
||||
username, password, mfa_code
|
||||
)
|
||||
|
||||
print(f"Auth response received: {auth_response}") # Debug logging
|
||||
|
||||
# Check if authentication was successful
|
||||
if auth_response.get("success"):
|
||||
# Extract token information
|
||||
access_token = auth_response.get("access_token")
|
||||
@@ -60,27 +62,40 @@ class AuthManager:
|
||||
|
||||
return session
|
||||
else:
|
||||
# Authentication failed
|
||||
# Check if MFA is required but not provided yet
|
||||
if auth_response.get("mfa_required", False) and not mfa_code:
|
||||
print("MFA required but not provided yet") # Debug logging
|
||||
# MFA required but not provided - return response indicating this
|
||||
return None # Indicate that MFA code is required
|
||||
|
||||
# Authentication failed for other reasons
|
||||
error_msg = auth_response.get("error", "Authentication failed")
|
||||
print(f"Authentication failed: {error_msg}") # Debug logging
|
||||
raise Exception(f"Authentication failed: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
# Handle any errors during authentication
|
||||
print(f"Exception during authentication: {str(e)}") # Debug logging
|
||||
raise e
|
||||
|
||||
async def logout(self) -> bool:
|
||||
"""Log out the current user and clear stored tokens"""
|
||||
try:
|
||||
# Clear stored token
|
||||
# Clear stored token in token_manager
|
||||
self.token_manager.clear_token()
|
||||
|
||||
# Clear token from API client
|
||||
self.api_client.token = None
|
||||
if "Authorization" in self.api_client.client.headers:
|
||||
del self.api_client.client.headers["Authorization"]
|
||||
# Clear token from API client headers
|
||||
# Check if headers exist and then remove Authorization
|
||||
if hasattr(self.api_client.client, 'headers'):
|
||||
if "Authorization" in self.api_client.client.headers:
|
||||
del self.api_client.client.headers["Authorization"]
|
||||
|
||||
# Close the API client session
|
||||
await self.api_client.close()
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
print(f"Error during logout: {e}") # Log the error for debugging
|
||||
return False
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
@@ -100,11 +115,7 @@ class AuthManager:
|
||||
token = self.token_manager.load_token()
|
||||
|
||||
if not token or not token.expires_in:
|
||||
return (
|
||||
True # If we don't have a token or expiration info, consider it expired
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
return True # If we don't have a token or expiration info, consider it expired
|
||||
|
||||
# Calculate when the token should expire based on creation time + expires_in
|
||||
if token.created_at:
|
||||
@@ -113,35 +124,17 @@ class AuthManager:
|
||||
else:
|
||||
return True # If no creation time, consider expired
|
||||
|
||||
async def refresh_token_if_needed(self) -> bool:
|
||||
"""Refresh token if it's expired or about to expire"""
|
||||
current_token = self.token_manager.load_token()
|
||||
def is_token_expired(self, token: Optional[AuthenticationToken] = None) -> bool:
|
||||
"""Check if the current token is expired"""
|
||||
if token is None:
|
||||
token = self.token_manager.load_token()
|
||||
|
||||
if not current_token:
|
||||
return False
|
||||
if not token or not token.expires_in:
|
||||
return True # If we don't have a token or expiration info, consider it expired
|
||||
|
||||
if not self.is_token_expired(current_token):
|
||||
return True # Token is still valid
|
||||
|
||||
# In a real implementation, we would call the API to get a new token using the refresh token
|
||||
# For this example, we'll return False to indicate that re-authentication is needed
|
||||
# since we don't have a refresh API endpoint defined
|
||||
return False
|
||||
|
||||
async def get_valid_token(self) -> Optional[AuthenticationToken]:
|
||||
"""Get a valid token, refreshing if needed"""
|
||||
current_token = self.token_manager.load_token()
|
||||
|
||||
if not current_token:
|
||||
return None
|
||||
|
||||
if not self.is_token_expired(current_token):
|
||||
return current_token
|
||||
|
||||
# Try to refresh the token
|
||||
refresh_success = await self.refresh_token_if_needed()
|
||||
if refresh_success:
|
||||
return self.token_manager.load_token()
|
||||
# Calculate when the token should expire based on creation time + expires_in
|
||||
if token.created_at:
|
||||
expiry_time = token.created_at + timedelta(seconds=token.expires_in)
|
||||
return datetime.now() > expiry_time
|
||||
else:
|
||||
# Could not refresh, token is invalid
|
||||
return None
|
||||
return True # If no creation time, consider expired
|
||||
Reference in New Issue
Block a user