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(-)
This commit is contained in:
Claude 2026-06-16 01:50:09 +00:00
parent 50eae17c34
commit 69320e1e82
10 changed files with 97 additions and 25 deletions

View file

@ -1,5 +1,6 @@
# === REQUIRED (generated automatically by setup.sh) === # === REQUIRED (generated automatically by setup.sh) ===
SECRET_KEY= SECRET_KEY=
API_TOKEN=
# === APP URL (change for production) === # === APP URL (change for production) ===
BASE_URL=http://localhost BASE_URL=http://localhost
@ -8,6 +9,11 @@ BASE_URL=http://localhost
DB_USER=fitness DB_USER=fitness
DB_NAME=fitness_rewards 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, # Everything else — Strava, Polar, Garmin, Fitbit, Telegram,
# USDA, Groq, etc. — is configured through the app UI or your AI agent. # USDA, Groq, etc. — is configured through the app UI or your AI agent.
# No container restart needed. # No container restart needed.

View file

@ -12,6 +12,7 @@ class Settings(BaseSettings):
secret_key: str = "change-me-in-production" secret_key: str = "change-me-in-production"
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 1440 # 24 hours access_token_expire_minutes: int = 1440 # 24 hours
api_token: str = "" # MCP connector auth — generated by setup.sh
# Strava # Strava
strava_client_id: str = "" strava_client_id: str = ""

View file

@ -1,11 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import traceback
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import select from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db 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(): if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken") 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( user = User(
username=req.username, username=req.username,
display_name=req.display_name, display_name=req.display_name,
password_hash=hash_password(req.password), password_hash=hash_password(req.password),
email=req.email, email=req.email,
is_admin=is_first_user,
) )
db.add(user) db.add(user)
await db.flush() await db.flush()
@ -52,9 +56,8 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
tb = traceback.format_exc() logger.error(f"Registration failed: {e}", exc_info=True)
logger.error(f"Registration failed: {e}\n{tb}") return JSONResponse(status_code=500, content={"detail": "Internal server error"})
return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
@router.post("/login") @router.post("/login")
@ -69,9 +72,8 @@ async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)])
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
tb = traceback.format_exc() logger.error(f"Login failed: {e}", exc_info=True)
logger.error(f"Login failed: {e}\n{tb}") return JSONResponse(status_code=500, content={"detail": "Internal server error"})
return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
@router.get("/me") @router.get("/me")
@ -82,4 +84,5 @@ async def get_me(user: Annotated[User, Depends(get_current_user)]):
"display_name": user.display_name, "display_name": user.display_name,
"email": user.email, "email": user.email,
"timezone": user.timezone, "timezone": user.timezone,
"is_admin": getattr(user, "is_admin", False),
} }

View file

@ -39,7 +39,10 @@ async def integration_status(
# --- Strava --- # --- Strava ---
@router.get("/strava/auth") @router.get("/strava/auth")
async def strava_auth(user: Annotated[User, Depends(get_current_user)]): 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") @router.get("/strava/callback")
@ -48,7 +51,14 @@ async def strava_callback(
state: str = Query(...), state: str = Query(...),
db: AsyncSession = Depends(get_db), 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) data = await exchange_strava_code(code)
# Upsert token # Upsert token
@ -91,7 +101,10 @@ async def strava_sync(
# --- Polar --- # --- Polar ---
@router.get("/polar/auth") @router.get("/polar/auth")
async def polar_auth(user: Annotated[User, Depends(get_current_user)]): 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") @router.get("/polar/callback")
@ -100,7 +113,14 @@ async def polar_callback(
state: str = Query(...), state: str = Query(...),
db: AsyncSession = Depends(get_db), 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) data = await exchange_polar_code(code)
# Register user with Polar # Register user with Polar

View file

@ -24,7 +24,6 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/support", tags=["support"]) 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 MAX_MESSAGES_PER_DAY = 10
@ -279,8 +278,8 @@ async def get_unread_count(
# ── Admin Endpoints ──────────────────────────────────────────────────────── # ── Admin Endpoints ────────────────────────────────────────────────────────
def require_admin(user: User): def require_admin(user: User):
"""Check that the user is the admin.""" """Check that the user has admin privileges."""
if user.username != ADMIN_USERNAME: if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="Admin access required") raise HTTPException(status_code=403, detail="Admin access required")

View file

@ -13,7 +13,7 @@ from app.config import get_settings
from app.database import get_db from app.database import get_db
settings = get_settings() 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: 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( async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)], token: Annotated[str | None, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
credentials_exception = HTTPException( credentials_exception = HTTPException(
@ -39,6 +39,30 @@ async def get_current_user(
detail="Invalid authentication credentials", detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"}, 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: try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id: str = payload.get("sub") user_id: str = payload.get("sub")
@ -47,7 +71,6 @@ async def get_current_user(
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
from app.models.user import User
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id))) result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:

View file

@ -2,6 +2,8 @@ services:
frontend: frontend:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped
ports:
- "80:80"
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
@ -18,6 +20,7 @@ services:
env_file: .env env_file: .env
environment: environment:
- BASE_URL=${BASE_URL:-http://localhost} - BASE_URL=${BASE_URL:-http://localhost}
- API_TOKEN=${API_TOKEN:-}
depends_on: depends_on:
fitness-db: fitness-db:
condition: service_healthy condition: service_healthy
@ -33,6 +36,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- FITNESS_API_URL=http://backend:8000 - FITNESS_API_URL=http://backend:8000
- API_TOKEN=${API_TOKEN:-}
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy

View file

@ -31,10 +31,9 @@ function HelpButton() {
// Don't show on login/onboarding/support pages // Don't show on login/onboarding/support pages
const hidden = ['/login', '/onboarding', '/support'].some(p => location.pathname.startsWith(p)) const hidden = ['/login', '/onboarding', '/support'].some(p => location.pathname.startsWith(p))
if (hidden) return null
useEffect(() => { useEffect(() => {
if (!hasToken()) return if (hidden || !hasToken()) return
api.getUnreadCount() api.getUnreadCount()
.then(d => setUnread(d.unread || 0)) .then(d => setUnread(d.unread || 0))
.catch(() => {}) .catch(() => {})
@ -45,7 +44,9 @@ function HelpButton() {
.catch(() => {}) .catch(() => {})
}, 60000) }, 60000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [location.pathname]) }, [location.pathname, hidden])
if (hidden) return null
return ( return (
<button <button
@ -117,6 +118,9 @@ export default function App() {
<Route path="/support" element={<ProtectedRoute><Support /></ProtectedRoute>} /> <Route path="/support" element={<ProtectedRoute><Support /></ProtectedRoute>} />
<Route path="/support/admin" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} /> <Route path="/support/admin" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
<Route path="/support/admin/:threadId" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} /> <Route path="/support/admin/:threadId" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
<Route path="/welcome" element={<Welcome />} />
<Route path="/settings/integrations" element={<ProtectedRoute><SettingsIntegrations /></ProtectedRoute>} />
<Route path="/meal-plan" element={<ProtectedRoute><MealPlan /></ProtectedRoute>} />
</Routes> </Routes>
{hasToken() && <NavBar />} {hasToken() && <NavBar />}
</> </>

View file

@ -12,6 +12,7 @@ from datetime import date
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
FITNESS_API_URL = os.getenv("FITNESS_API_URL", "http://backend:8000") FITNESS_API_URL = os.getenv("FITNESS_API_URL", "http://backend:8000")
API_TOKEN = os.getenv("API_TOKEN", "")
mcp = FastMCP("Diligence Fitness", port=3001) mcp = FastMCP("Diligence Fitness", port=3001)
@ -21,8 +22,12 @@ _client: httpx.AsyncClient | None = None
async def api(method: str, path: str, **kwargs) -> dict: async def api(method: str, path: str, **kwargs) -> dict:
global _client global _client
if _client is None: if _client is None:
_client = httpx.AsyncClient(base_url=FITNESS_API_URL, timeout=30) headers = {}
# TODO: add service account API key auth header if API_TOKEN:
headers["Authorization"] = f"Bearer {API_TOKEN}"
_client = httpx.AsyncClient(
base_url=FITNESS_API_URL, timeout=30, headers=headers
)
resp = await getattr(_client, method)(f"/api{path}", **kwargs) resp = await getattr(_client, method)(f"/api{path}", **kwargs)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()

View file

@ -7,13 +7,20 @@ if [ -f .env ]; then
fi fi
SECRET=$(openssl rand -hex 32) SECRET=$(openssl rand -hex 32)
TOKEN=$(openssl rand -hex 32)
cp .env.example .env cp .env.example .env
sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env
rm -f .env.bak
echo "✅ .env created with random SECRET_KEY" echo "✅ .env created with random SECRET_KEY and API_TOKEN"
echo "" echo ""
echo "Next steps:" echo "Next steps:"
echo " docker compose up -d" echo " docker compose up -d"
echo " Open http://localhost" echo " Open http://localhost"
echo " Register your account" echo " Register your account"
echo " Configure integrations via Settings or your AI agent" echo " Configure integrations via Settings or your AI agent"
echo ""
echo "MCP agent connection:"
echo " URL: http://localhost:3001/sse"
echo " Header: Authorization: Bearer ${TOKEN}"