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