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) ===
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.

View file

@ -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 = ""

View file

@ -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),
}

View file

@ -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

View file

@ -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")

View file

@ -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:

View file

@ -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

View file

@ -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 (
<button
@ -117,6 +118,9 @@ export default function App() {
<Route path="/support" element={<ProtectedRoute><Support /></ProtectedRoute>} />
<Route path="/support/admin" 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>
{hasToken() && <NavBar />}
</>

View file

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

View file

@ -7,13 +7,20 @@ if [ -f .env ]; then
fi
SECRET=$(openssl rand -hex 32)
TOKEN=$(openssl rand -hex 32)
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 "Next steps:"
echo " docker compose up -d"
echo " Open http://localhost"
echo " Register your account"
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}"