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:
parent
50eae17c34
commit
69320e1e82
10 changed files with 97 additions and 25 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
11
setup.sh
11
setup.sh
|
|
@ -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}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue