diff --git a/FitnessSync/SAVE_GARMIN_CREDS.md b/FitnessSync/SAVE_GARMIN_CREDS.md new file mode 100644 index 0000000..2b2893d --- /dev/null +++ b/FitnessSync/SAVE_GARMIN_CREDS.md @@ -0,0 +1,52 @@ +# Save Garmin Credentials Script + +This script mimics the web UI call when hitting "Save Garmin Credentials". It loads Garmin credentials from a .env file and sends them to the backend API. + +## Usage + +1. Create a `.env` file based on the `.env.example` template: + ```bash + cp .env.example .env + ``` + +2. Update the `.env` file with your actual Garmin credentials: + ```bash + nano .env + ``` + +3. Run the script: + ```bash + python save_garmin_creds.py + ``` + +## Prerequisites + +- Make sure the backend service is running on the specified host and port (default: localhost:8000) +- Ensure the required dependencies are installed (they should be in the main project requirements.txt) + +## Expected Response + +Upon successful authentication, you'll see a response like: +``` +Response: { + "status": "success", + "message": "Garmin credentials saved and authenticated successfully" +} +``` + +If MFA is required: +``` +Response: { + "status": "mfa_required", + "message": "Multi-factor authentication required", + "session_id": "some_session_id" +} +``` + +## Environment Variables + +- `GARMIN_USERNAME` (required): Your Garmin Connect username +- `GARMIN_PASSWORD` (required): Your Garmin Connect password +- `GARMIN_IS_CHINA` (optional): Set to 'true' if you're using Garmin China (default: false) +- `BACKEND_HOST` (optional): Backend host (default: localhost) +- `BACKEND_PORT` (optional): Backend port (default: 8000) \ No newline at end of file diff --git a/FitnessSync/backend/Dockerfile b/FitnessSync/backend/Dockerfile deleted file mode 100644 index 89f7120..0000000 --- a/FitnessSync/backend/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 8000 - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/FitnessSync/backend/__pycache__/main.cpython-313.pyc b/FitnessSync/backend/__pycache__/main.cpython-313.pyc index 95b35f1..57ada71 100644 Binary files a/FitnessSync/backend/__pycache__/main.cpython-313.pyc and b/FitnessSync/backend/__pycache__/main.cpython-313.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/299d39b0f13d_add_mfa_state_to_api_tokens.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/299d39b0f13d_add_mfa_state_to_api_tokens.cpython-313.pyc index a593a71..970a11e 100644 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/299d39b0f13d_add_mfa_state_to_api_tokens.cpython-313.pyc and b/FitnessSync/backend/alembic/versions/__pycache__/299d39b0f13d_add_mfa_state_to_api_tokens.cpython-313.pyc differ diff --git a/FitnessSync/backend/docker-compose.yml b/FitnessSync/backend/docker-compose.yml deleted file mode 100644 index 55d203c..0000000 --- a/FitnessSync/backend/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: '3.8' - -services: - app: - build: . - ports: - - "8000:8000" - environment: - - DATABASE_URL=postgresql://postgres:password@db:5432/fitbit_garmin_sync - - FITBIT_CLIENT_ID=${FITBIT_CLIENT_ID:-} - - FITBIT_CLIENT_SECRET=${FITBIT_CLIENT_SECRET:-} - - FITBIT_REDIRECT_URI=${FITBIT_REDIRECT_URI:-http://localhost:8000/api/setup/fitbit/callback} - depends_on: - - db - volumes: - - ./data:/app/data # For activity files - - ./logs:/app/logs # For application logs - - db: - image: postgres:15 - environment: - - POSTGRES_DB=fitbit_garmin_sync - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=password - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - -volumes: - postgres_data: \ No newline at end of file diff --git a/FitnessSync/backend/src/api/__pycache__/setup.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/setup.cpython-313.pyc index d4a9dbf..aee0c87 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/setup.cpython-313.pyc and b/FitnessSync/backend/src/api/__pycache__/setup.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/api/setup.py b/FitnessSync/backend/src/api/setup.py index 720c0e9..754089b 100644 --- a/FitnessSync/backend/src/api/setup.py +++ b/FitnessSync/backend/src/api/setup.py @@ -4,8 +4,12 @@ from pydantic import BaseModel from typing import Optional from sqlalchemy.orm import Session import traceback +import httpx +import base64 +import json from ..services.postgresql_manager import PostgreSQLManager from ..utils.config import config +import garth from ..services.garmin.client import GarminClient router = APIRouter() @@ -35,22 +39,117 @@ class AuthStatusResponse(BaseModel): garmin: Optional[dict] = None fitbit: Optional[dict] = None +class AuthStatusResponse(BaseModel): + garmin: Optional[dict] = None + fitbit: Optional[dict] = None + +@router.post("/setup/load-consul-config") +async def load_consul_config(db: Session = Depends(get_db)): + """ + Load configuration from Consul and save it to the database. + It first tries to use tokens from Consul, if they are not present, it falls back to username/password login. + """ + consul_url = "http://consul.service.dc1.consul:8500/v1/kv/fitbit-garmin-sync/config" + try: + async with httpx.AsyncClient() as client: + response = await client.get(consul_url) + response.raise_for_status() + data = response.json() + if not (data and 'Value' in data[0]): + raise HTTPException(status_code=404, detail="Config not found in Consul") + + config_value = base64.b64decode(data[0]['Value']).decode('utf-8') + config = json.loads(config_value) + + if 'garmin' in config: + garmin_config = config['garmin'] + from ..models.api_token import APIToken + from datetime import datetime + + # Prefer tokens if available + if 'garth_oauth1_token' in garmin_config and 'garth_oauth2_token' in garmin_config: + token_record = db.query(APIToken).filter_by(token_type='garmin').first() + if not token_record: + token_record = APIToken(token_type='garmin') + db.add(token_record) + + token_record.garth_oauth1_token = garmin_config['garth_oauth1_token'] + token_record.garth_oauth2_token = garmin_config['garth_oauth2_token'] + token_record.updated_at = datetime.now() + db.commit() + + return {"status": "success", "message": "Garmin tokens from Consul have been saved."} + + # Fallback to username/password login + elif 'username' in garmin_config and 'password' in garmin_config: + garmin_creds = GarminCredentials(**garmin_config) + garmin_client = GarminClient(garmin_creds.username, garmin_creds.password, garmin_creds.is_china) + status = garmin_client.login() + + if status == "mfa_required": + return {"status": "mfa_required", "message": "Garmin login from Consul requires MFA. Please complete it manually."} + elif status != "success": + raise HTTPException(status_code=400, detail=f"Failed to login to Garmin with Consul credentials: {status}") + + # TODO: Add Fitbit credentials handling + + return {"status": "success", "message": "Configuration from Consul processed."} + + except httpx.RequestError as e: + raise HTTPException(status_code=500, detail=f"Failed to connect to Consul: {e}") + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + @router.get("/setup/auth-status", response_model=AuthStatusResponse) async def get_auth_status(db: Session = Depends(get_db)): - return AuthStatusResponse( - garmin={ - "username": "example@example.com", - "authenticated": False, - "token_expires_at": None, - "last_login": None, - "is_china": False - }, - fitbit={ - "client_id": "example_client_id", - "authenticated": False, - "token_expires_at": None, - "last_login": None + from ..models.api_token import APIToken + + garmin_status = {} + fitbit_status = {} + + # Garmin Status + garmin_token = db.query(APIToken).filter_by(token_type='garmin').first() + if garmin_token: + garmin_status = { + "token_stored": True, + "authenticated": garmin_token.garth_oauth1_token is not None and garmin_token.garth_oauth2_token is not None, + "garth_oauth1_token_exists": garmin_token.garth_oauth1_token is not None, + "garth_oauth2_token_exists": garmin_token.garth_oauth2_token is not None, + "mfa_state_exists": garmin_token.mfa_state is not None, + "mfa_expires_at": garmin_token.mfa_expires_at, + "last_used": garmin_token.last_used, + "updated_at": garmin_token.updated_at, + "username": "N/A", # Placeholder, username is not stored in APIToken + "is_china": False # Placeholder } + else: + garmin_status = { + "token_stored": False, + "authenticated": False + } + + # Fitbit Status (Existing logic, might need adjustment if Fitbit tokens are stored differently) + fitbit_token = db.query(APIToken).filter_by(token_type='fitbit').first() + if fitbit_token: + fitbit_status = { + "token_stored": True, + "authenticated": fitbit_token.access_token is not None, + "client_id": fitbit_token.access_token[:10] + "..." if fitbit_token.access_token else "N/A", + "expires_at": fitbit_token.expires_at, + "last_used": fitbit_token.last_used, + "updated_at": fitbit_token.updated_at + } + else: + fitbit_status = { + "token_stored": False, + "authenticated": False + } + + return AuthStatusResponse( + garmin=garmin_status, + fitbit=fitbit_status ) @router.post("/setup/garmin") @@ -63,44 +162,25 @@ async def save_garmin_credentials(credentials: GarminCredentials, db: Session = garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china) logger.debug("GarminClient instance created successfully") - try: - logger.debug("Attempting to log in to Garmin") - garmin_client.login() - - logger.info(f"Successfully authenticated Garmin user: {credentials.username}") + logger.debug("Attempting to log in to Garmin") + # Check the status returned directly + status = garmin_client.login() + + if status == "mfa_required": + # Hardcode the session_id as 'garmin' since you use a single record in APIToken return JSONResponse( status_code=200, - content={"status": "success", "message": "Garmin credentials saved and authenticated successfully"} + content={ + "status": "mfa_required", + "message": "MFA Required", + "session_id": "garmin" + } ) - except Exception as e: - logger.error(f"Error during Garmin authentication: {str(e)}") - - error_message = str(e) - - if "MFA" in error_message or "mfa" in error_message.lower() or "MFA Required" in error_message: - logger.info("MFA required for Garmin authentication") - try: - session_id = garmin_client.initiate_mfa(credentials.username) - return JSONResponse( - status_code=200, - content={ - "status": "mfa_required", - "message": "Multi-factor authentication required", - "session_id": session_id - } - ) - except Exception as mfa_error: - logger.error(f"Error initiating MFA: {str(mfa_error)}") - return JSONResponse( - status_code=500, - content={"status": "error", "message": f"Error initiating MFA: {str(mfa_error)}"} - ) - else: - # For other exceptions during login, return a generic error - return JSONResponse( - status_code=500, - content={"status": "error", "message": f"An unexpected error occurred: {error_message}"} - ) + + return JSONResponse( + status_code=200, + content={"status": "success", "message": "Logged in!"} + ) @router.post("/setup/garmin/mfa") async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)): @@ -156,3 +236,48 @@ async def save_fitbit_credentials(credentials: FitbitCredentials, db: Session = @router.post("/setup/fitbit/callback") async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)): return {"status": "success", "message": "Fitbit OAuth flow completed successfully"} + +@router.post("/setup/garmin/test-token") +async def test_garmin_token(db: Session = Depends(get_db)): + from ..models.api_token import APIToken + from garth.auth_tokens import OAuth1Token, OAuth2Token + import json + + token_record = db.query(APIToken).filter_by(token_type='garmin').first() + if not token_record or not token_record.garth_oauth1_token or not token_record.garth_oauth2_token: + raise HTTPException(status_code=404, detail="Garmin token not found or incomplete.") + + try: + from ..utils.helpers import setup_logger + logger = setup_logger(__name__) + + logger.info("garth_oauth1_token from DB: %s", token_record.garth_oauth1_token) + logger.info("Type of garth_oauth1_token: %s", type(token_record.garth_oauth1_token)) + logger.info("garth_oauth2_token from DB: %s", token_record.garth_oauth2_token) + logger.info("Type of garth_oauth2_token: %s", type(token_record.garth_oauth2_token)) + + if not token_record.garth_oauth1_token or not token_record.garth_oauth2_token: + raise HTTPException(status_code=400, detail="OAuth1 or OAuth2 token is empty.") + + import garth + + # Parse JSON to dictionaries + oauth1_dict = json.loads(token_record.garth_oauth1_token) + oauth2_dict = json.loads(token_record.garth_oauth2_token) + + # Convert to proper token objects + garth.client.oauth1_token = OAuth1Token(**oauth1_dict) + garth.client.oauth2_token = OAuth2Token(**oauth2_dict) + + # Also configure the domain if present + if oauth1_dict.get('domain'): + garth.configure(domain=oauth1_dict['domain']) + + profile_info = garth.UserProfile.get() + return profile_info + + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Failed to test Garmin token: {e}") + diff --git a/FitnessSync/backend/src/models/__pycache__/api_token.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/api_token.cpython-313.pyc index 7008cf3..7241c58 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/api_token.cpython-313.pyc and b/FitnessSync/backend/src/models/__pycache__/api_token.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/config.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/config.cpython-313.pyc index 24ecf1f..a67ff17 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/config.cpython-313.pyc and b/FitnessSync/backend/src/models/__pycache__/config.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/models/api_token.py b/FitnessSync/backend/src/models/api_token.py index a046711..4824afb 100644 --- a/FitnessSync/backend/src/models/api_token.py +++ b/FitnessSync/backend/src/models/api_token.py @@ -8,9 +8,9 @@ class APIToken(Base): id = Column(Integer, primary_key=True, index=True) token_type = Column(String, nullable=False) # 'fitbit' or 'garmin' - access_token = Column(String, nullable=False) # This should be encrypted in production + access_token = Column(String, nullable=True) # This should be encrypted in production refresh_token = Column(String, nullable=True) # This should be encrypted in production - expires_at = Column(DateTime, nullable=False) + expires_at = Column(DateTime, nullable=True) scopes = Column(String, nullable=True) garth_oauth1_token = Column(String, nullable=True) # OAuth1 token for garmin (JSON) garth_oauth2_token = Column(String, nullable=True) # OAuth2 token for garmin (JSON) diff --git a/FitnessSync/backend/src/services/garmin/__pycache__/__init__.cpython-313.pyc b/FitnessSync/backend/src/services/garmin/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d83ecc2 Binary files /dev/null and b/FitnessSync/backend/src/services/garmin/__pycache__/__init__.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/garmin/__pycache__/auth.cpython-313.pyc b/FitnessSync/backend/src/services/garmin/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..b58d86d Binary files /dev/null and b/FitnessSync/backend/src/services/garmin/__pycache__/auth.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/garmin/__pycache__/client.cpython-313.pyc b/FitnessSync/backend/src/services/garmin/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000..af43029 Binary files /dev/null and b/FitnessSync/backend/src/services/garmin/__pycache__/client.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/garmin/__pycache__/data.cpython-313.pyc b/FitnessSync/backend/src/services/garmin/__pycache__/data.cpython-313.pyc new file mode 100644 index 0000000..3caa8c0 Binary files /dev/null and b/FitnessSync/backend/src/services/garmin/__pycache__/data.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/garmin/auth.py b/FitnessSync/backend/src/services/garmin/auth.py index 17ea2aa..dea2565 100644 --- a/FitnessSync/backend/src/services/garmin/auth.py +++ b/FitnessSync/backend/src/services/garmin/auth.py @@ -1,86 +1,38 @@ import garth -import garminconnect -from garth.exc import GarthException -from datetime import datetime, timedelta -from uuid import uuid4 import json -import traceback - -from src.utils.helpers import setup_logger +from datetime import datetime, timedelta +from garth.exc import GarthException from src.models.api_token import APIToken from src.services.postgresql_manager import PostgreSQLManager from src.utils.config import config +from src.utils.helpers import setup_logger logger = setup_logger(__name__) class AuthMixin: def login(self): - """Login to Garmin Connect, handling MFA.""" - logger.info(f"Starting login process for Garmin user: {self.username}") - + """Login to Garmin Connect, returning status instead of raising exceptions.""" + logger.info(f"Starting login for: {self.username}") try: + # result1 is status, result2 is the mfa_state dict or tokens result1, result2 = garth.login(self.username, self.password, return_on_mfa=True) if result1 == "needs_mfa": logger.info("MFA required for Garmin authentication.") - self.initiate_mfa(result2) - raise Exception("MFA Required: Please provide verification code") - - logger.info(f"Successfully logged in to Garmin Connect as {self.username}") + self.initiate_mfa(result2) # Fixed below + return "mfa_required" + self.update_tokens(result1, result2) self.is_connected = True - + return "success" except GarthException as e: - logger.error(f"GarthException during login for {self.username}: {e}") - raise Exception(f"Garmin authentication failed: {e}") - - def initiate_mfa(self, mfa_state): - """Saves MFA state to the database.""" - logger.info(f"Initiating MFA process for user: {self.username}") - db_manager = PostgreSQLManager(config.DATABASE_URL) - with db_manager.get_db_session() as session: - token_record = session.query(APIToken).filter_by(token_type='garmin').first() - if not token_record: - token_record = APIToken(token_type='garmin') - session.add(token_record) - - token_record.mfa_state = json.dumps(mfa_state) - token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10) - session.commit() - logger.info(f"MFA state saved for user: {self.username}") - - def handle_mfa(self, verification_code: str): - """Completes authentication using MFA code.""" - logger.info(f"Handling MFA for user: {self.username}") - db_manager = PostgreSQLManager(config.DATABASE_URL) - with db_manager.get_db_session() as session: - token_record = session.query(APIToken).filter_by(token_type='garmin').first() - if not token_record or not token_record.mfa_state: - raise Exception("No pending MFA session found.") - - if token_record.mfa_expires_at and datetime.now() > token_record.mfa_expires_at: - raise Exception("MFA session expired.") - - mfa_state = json.loads(token_record.mfa_state) - - try: - oauth1, oauth2 = garth.resume_login(mfa_state, verification_code) - self.update_tokens(oauth1, oauth2) - - token_record.mfa_state = None - token_record.mfa_expires_at = None - session.commit() - - self.is_connected = True - logger.info(f"MFA authentication successful for user: {self.username}") - return True - except GarthException as e: - logger.error(f"MFA handling failed for {self.username}: {e}") - raise + logger.error(f"Login failed: {e}") + return "error" def update_tokens(self, oauth1, oauth2): - """Saves OAuth tokens to the database.""" - logger.info(f"Updating tokens for user: {self.username}") + """Saves the Garmin OAuth tokens to the database.""" + logger.info(f"Updating Garmin tokens for user: {self.username}") + db_manager = PostgreSQLManager(config.DATABASE_URL) with db_manager.get_db_session() as session: token_record = session.query(APIToken).filter_by(token_type='garmin').first() @@ -90,10 +42,63 @@ class AuthMixin: token_record.garth_oauth1_token = json.dumps(oauth1) token_record.garth_oauth2_token = json.dumps(oauth2) + token_record.updated_at = datetime.now() + + # Clear MFA state as it's no longer needed + token_record.mfa_state = None + token_record.mfa_expires_at = None + session.commit() - logger.info(f"Tokens successfully updated for user: {self.username}") + logger.info("Garmin tokens updated successfully.") - def load_tokens(self): - """Load garth tokens to resume a session.""" - logger.info(f"Starting token loading process for user: {self.username}") - # ... (rest of the load_tokens method remains the same) + def initiate_mfa(self, mfa_state): + """Saves ONLY serializable parts of the MFA state to the database.""" + logger.info(f"Initiating MFA process for user: {self.username}") + + # FIX: Extract serializable data. We cannot dump the 'client' object directly. + serializable_state = { + "signin_params": mfa_state["signin_params"], + "cookies": mfa_state["client"].sess.cookies.get_dict(), + "domain": mfa_state["client"].domain + } + + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + token_record = session.query(APIToken).filter_by(token_type='garmin').first() + if not token_record: + token_record = APIToken(token_type='garmin') + session.add(token_record) + + # Save the dictionary as a string + token_record.mfa_state = json.dumps(serializable_state) + token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10) + session.commit() + + def handle_mfa(self, verification_code: str, session_id: str = None): + """Reconstructs the Garth state and completes authentication.""" + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + token_record = session.query(APIToken).filter_by(token_type='garmin').first() + if not token_record or not token_record.mfa_state: + raise Exception("No pending MFA session found.") + + saved_data = json.loads(token_record.mfa_state) + + # FIX: Reconstruct the Garth Client and State object + from garth.http import Client + client = Client(domain=saved_data["domain"]) + client.sess.cookies.update(saved_data["cookies"]) + + mfa_state = { + "client": client, + "signin_params": saved_data["signin_params"] + } + + try: + oauth1, oauth2 = garth.resume_login(mfa_state, verification_code) + self.update_tokens(oauth1, oauth2) + # ... rest of your session cleanup ... + return True + except GarthException as e: + logger.error(f"MFA handling failed: {e}") + raise \ No newline at end of file diff --git a/FitnessSync/backend/src/services/garmin/client.py b/FitnessSync/backend/src/services/garmin/client.py index 02123ed..51c8910 100644 --- a/FitnessSync/backend/src/services/garmin/client.py +++ b/FitnessSync/backend/src/services/garmin/client.py @@ -32,3 +32,11 @@ class GarminClient(AuthMixin, DataMixin): except: self.is_connected = False return False + + def get_profile_info(self): + """Get user profile information.""" + if not self.is_connected: + self.login() + if self.is_connected: + return garth.UserProfile.get() + return None diff --git a/FitnessSync/backend/src/utils/__pycache__/helpers.cpython-313.pyc b/FitnessSync/backend/src/utils/__pycache__/helpers.cpython-313.pyc index 3178283..1bdb725 100644 Binary files a/FitnessSync/backend/src/utils/__pycache__/helpers.cpython-313.pyc and b/FitnessSync/backend/src/utils/__pycache__/helpers.cpython-313.pyc differ diff --git a/FitnessSync/backend/templates/setup.html b/FitnessSync/backend/templates/setup.html index a9ba32d..9bd4bba 100644 --- a/FitnessSync/backend/templates/setup.html +++ b/FitnessSync/backend/templates/setup.html @@ -10,6 +10,10 @@

Fitbit-Garmin Sync - Setup

+
+ +
+
@@ -32,19 +36,28 @@
- +
- +
- + + +
+ +
+

Current auth state: Not Tested

+
+ +
+

Loading Garmin authentication status...

@@ -70,13 +83,14 @@
- +
- +
- + +
-
+