From 69320e1e8273887e18b6e59a710501ad3b0db686 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 01:50:09 +0000 Subject: [PATCH] Fix all 7 launch blockers from security/QA review SEC-16: Add frontend ports mapping (80:80) to docker-compose.yml UI-01: Add routes for Welcome, SettingsIntegrations, MealPlan in App.jsx SEC-01: Remove traceback leak from auth error responses SEC-02: Fix require_admin to use is_admin column (was undefined ADMIN_USERNAME) SEC-03: Add API_TOKEN auth for MCP connector -> backend communication SEC-04: OAuth state now uses signed JWT tokens (was raw user UUID) OS-01: First registered user gets admin immediately during registration 10 files changed, 97 insertions(+), 25 deletions(-) --- .env.example | 6 ++++++ backend/app/config.py | 1 + backend/app/routers/auth.py | 19 +++++++++++-------- backend/app/routers/integrations.py | 28 ++++++++++++++++++++++++---- backend/app/routers/support.py | 5 ++--- backend/app/utils/auth.py | 29 ++++++++++++++++++++++++++--- docker-compose.yml | 4 ++++ frontend/src/App.jsx | 10 +++++++--- mcp-connector/server.py | 9 +++++++-- setup.sh | 11 +++++++++-- 10 files changed, 97 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 32a93d9..4b0fdbf 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # === REQUIRED (generated automatically by setup.sh) === SECRET_KEY= +API_TOKEN= # === APP URL (change for production) === BASE_URL=http://localhost @@ -8,6 +9,11 @@ BASE_URL=http://localhost DB_USER=fitness DB_NAME=fitness_rewards +# === MCP AGENT AUTH (generated automatically by setup.sh) === +# The API_TOKEN authenticates the MCP connector with the backend. +# It is auto-configured — you don't need to touch it unless you're +# connecting an agent from outside Docker. + # Everything else — Strava, Polar, Garmin, Fitbit, Telegram, # USDA, Groq, etc. — is configured through the app UI or your AI agent. # No container restart needed. diff --git a/backend/app/config.py b/backend/app/config.py index 4ffe6be..502ebd2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,6 +12,7 @@ class Settings(BaseSettings): secret_key: str = "change-me-in-production" algorithm: str = "HS256" access_token_expire_minutes: int = 1440 # 24 hours + api_token: str = "" # MCP connector auth — generated by setup.sh # Strava strava_client_id: str = "" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index fd3e487..517998d 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,11 +1,10 @@ from __future__ import annotations import logging -import traceback from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import JSONResponse -from sqlalchemy import select +from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -26,11 +25,16 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Username already taken") + # Grant admin to first user + admin_count = await db.execute(select(func.count(User.id)).where(User.is_admin == True)) + is_first_user = (admin_count.scalar() or 0) == 0 + user = User( username=req.username, display_name=req.display_name, password_hash=hash_password(req.password), email=req.email, + is_admin=is_first_user, ) db.add(user) await db.flush() @@ -52,9 +56,8 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get except HTTPException: raise except Exception as e: - tb = traceback.format_exc() - logger.error(f"Registration failed: {e}\n{tb}") - return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb}) + logger.error(f"Registration failed: {e}", exc_info=True) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) @router.post("/login") @@ -69,9 +72,8 @@ async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)]) except HTTPException: raise except Exception as e: - tb = traceback.format_exc() - logger.error(f"Login failed: {e}\n{tb}") - return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb}) + logger.error(f"Login failed: {e}", exc_info=True) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) @router.get("/me") @@ -82,4 +84,5 @@ async def get_me(user: Annotated[User, Depends(get_current_user)]): "display_name": user.display_name, "email": user.email, "timezone": user.timezone, + "is_admin": getattr(user, "is_admin", False), } diff --git a/backend/app/routers/integrations.py b/backend/app/routers/integrations.py index 8562a7b..e3eb075 100644 --- a/backend/app/routers/integrations.py +++ b/backend/app/routers/integrations.py @@ -39,7 +39,10 @@ async def integration_status( # --- Strava --- @router.get("/strava/auth") async def strava_auth(user: Annotated[User, Depends(get_current_user)]): - return {"auth_url": get_strava_auth_url(str(user.id))} + from app.utils.auth import create_access_token + from datetime import timedelta + state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10)) + return {"auth_url": get_strava_auth_url(state_token)} @router.get("/strava/callback") @@ -48,7 +51,14 @@ async def strava_callback( state: str = Query(...), db: AsyncSession = Depends(get_db), ): - user_id = uuid_mod.UUID(state) + from jose import JWTError, jwt as jose_jwt + from app.config import get_settings + _settings = get_settings() + try: + payload = jose_jwt.decode(state, _settings.secret_key, algorithms=[_settings.algorithm]) + user_id = uuid_mod.UUID(payload["sub"]) + except (JWTError, KeyError, ValueError): + raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") data = await exchange_strava_code(code) # Upsert token @@ -91,7 +101,10 @@ async def strava_sync( # --- Polar --- @router.get("/polar/auth") async def polar_auth(user: Annotated[User, Depends(get_current_user)]): - return {"auth_url": get_polar_auth_url(str(user.id))} + from app.utils.auth import create_access_token + from datetime import timedelta + state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10)) + return {"auth_url": get_polar_auth_url(state_token)} @router.get("/polar/callback") @@ -100,7 +113,14 @@ async def polar_callback( state: str = Query(...), db: AsyncSession = Depends(get_db), ): - user_id = uuid_mod.UUID(state) + from jose import JWTError, jwt as jose_jwt + from app.config import get_settings + _settings = get_settings() + try: + payload = jose_jwt.decode(state, _settings.secret_key, algorithms=[_settings.algorithm]) + user_id = uuid_mod.UUID(payload["sub"]) + except (JWTError, KeyError, ValueError): + raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") data = await exchange_polar_code(code) # Register user with Polar diff --git a/backend/app/routers/support.py b/backend/app/routers/support.py index c243602..c8cb767 100644 --- a/backend/app/routers/support.py +++ b/backend/app/routers/support.py @@ -24,7 +24,6 @@ from app.config import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/support", tags=["support"]) -# Admin check uses is_admin column on User model (first registered user gets admin=True) MAX_MESSAGES_PER_DAY = 10 @@ -279,8 +278,8 @@ async def get_unread_count( # ── Admin Endpoints ──────────────────────────────────────────────────────── def require_admin(user: User): - """Check that the user is the admin.""" - if user.username != ADMIN_USERNAME: + """Check that the user has admin privileges.""" + if not getattr(user, "is_admin", False): raise HTTPException(status_code=403, detail="Admin access required") diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index b0835ce..41ae54a 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -13,7 +13,7 @@ from app.config import get_settings from app.database import get_db settings = get_settings() -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) def hash_password(password: str) -> str: @@ -31,7 +31,7 @@ def create_access_token(user_id: str, expires_delta: timedelta | None = None) -> async def get_current_user( - token: Annotated[str, Depends(oauth2_scheme)], + token: Annotated[str | None, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(get_db)], ): credentials_exception = HTTPException( @@ -39,6 +39,30 @@ async def get_current_user( detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, ) + + if not token: + raise credentials_exception + + from app.models.user import User + + # Check if this is an API token (MCP connector auth) + if settings.api_token and token == settings.api_token: + # Map to the first admin user + result = await db.execute( + select(User).where(User.is_admin == True).order_by(User.created_at.asc()).limit(1) + ) + user = result.scalar_one_or_none() + if user is None: + # Fall back to first user if no admin exists yet + result = await db.execute( + select(User).order_by(User.created_at.asc()).limit(1) + ) + user = result.scalar_one_or_none() + if user is None: + raise credentials_exception + return user + + # Standard JWT validation try: payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) user_id: str = payload.get("sub") @@ -47,7 +71,6 @@ async def get_current_user( except JWTError: raise credentials_exception - from app.models.user import User result = await db.execute(select(User).where(User.id == uuid.UUID(user_id))) user = result.scalar_one_or_none() if user is None: diff --git a/docker-compose.yml b/docker-compose.yml index ed1eb51..54633e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ services: frontend: build: ./frontend restart: unless-stopped + ports: + - "80:80" depends_on: backend: condition: service_healthy @@ -18,6 +20,7 @@ services: env_file: .env environment: - BASE_URL=${BASE_URL:-http://localhost} + - API_TOKEN=${API_TOKEN:-} depends_on: fitness-db: condition: service_healthy @@ -33,6 +36,7 @@ services: restart: unless-stopped environment: - FITNESS_API_URL=http://backend:8000 + - API_TOKEN=${API_TOKEN:-} depends_on: backend: condition: service_healthy diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 327f3a8..717b218 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,10 +31,9 @@ function HelpButton() { // Don't show on login/onboarding/support pages const hidden = ['/login', '/onboarding', '/support'].some(p => location.pathname.startsWith(p)) - if (hidden) return null useEffect(() => { - if (!hasToken()) return + if (hidden || !hasToken()) return api.getUnreadCount() .then(d => setUnread(d.unread || 0)) .catch(() => {}) @@ -45,7 +44,9 @@ function HelpButton() { .catch(() => {}) }, 60000) return () => clearInterval(interval) - }, [location.pathname]) + }, [location.pathname, hidden]) + + if (hidden) return null return (