Harden auth/upload, fix PR-delete cascade and sync backfill
- OIDC: require signed short-lived state on login callback; reject missing userinfo sub (account-takeover guard); validate token exchange + userinfo responses - Upload: safe zip extraction (path-traversal + zip-bomb cap), streamed size-capped writes, sanitised filenames - Garmin: increasing lookback resets last_sync_at for one-time backfill - Activities: delete/reprocess remove PersonalRecord rows (no FK cascade) - Profile: validate /weight limit; sync lookback UI copy - Dashboard: sleep shading uses same day as charted body battery Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+38
-1
@@ -19,6 +19,7 @@ router = APIRouter()
|
||||
# to a normal sign-in), so the callback attaches the passkey to a known user
|
||||
# instead of creating/looking-up by identity.
|
||||
LINK_STATE_PURPOSE = "pocketid-link"
|
||||
LOGIN_STATE_PURPOSE = "pocketid-login"
|
||||
|
||||
|
||||
def _make_link_state(user_id: int) -> str:
|
||||
@@ -29,6 +30,25 @@ def _make_link_state(user_id: int) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _make_login_state() -> str:
|
||||
"""Signed, short-lived CSRF token proving the login flow started from this app."""
|
||||
return create_access_token(
|
||||
{"sub": "login", "purpose": LOGIN_STATE_PURPOSE},
|
||||
expires_delta=timedelta(minutes=10),
|
||||
)
|
||||
|
||||
|
||||
def _valid_login_state(state: Optional[str]) -> bool:
|
||||
"""True if `state` is a valid, unexpired login-state token we issued."""
|
||||
if not state:
|
||||
return False
|
||||
try:
|
||||
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
|
||||
return payload.get("purpose") == LOGIN_STATE_PURPOSE
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
|
||||
def _decode_link_state(state: Optional[str]) -> Optional[int]:
|
||||
"""Return the user id from a valid link-state token, else None."""
|
||||
if not state:
|
||||
@@ -157,6 +177,7 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email groups",
|
||||
"state": _make_login_state(),
|
||||
}
|
||||
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||
|
||||
@@ -202,10 +223,15 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
|
||||
print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}")
|
||||
raise HTTPException(status_code=400, detail="Token exchange failed")
|
||||
tokens = resp.json()
|
||||
access_token = tokens.get("access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=400, detail="Token exchange failed")
|
||||
userinfo_resp = await client.get(
|
||||
f"{issuer}/api/oidc/userinfo",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if userinfo_resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Failed to fetch user info")
|
||||
userinfo = userinfo_resp.json()
|
||||
|
||||
from fastapi.responses import RedirectResponse
|
||||
@@ -214,6 +240,12 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
|
||||
email = userinfo.get("email")
|
||||
preferred_username = userinfo.get("preferred_username") or email
|
||||
|
||||
# A missing subject means we cannot identify the user. Never continue, or the
|
||||
# `pocketid_sub == sub` (== None → IS NULL) lookups below would match any
|
||||
# password-only account and log the caller in as someone else.
|
||||
if not sub:
|
||||
return RedirectResponse(url="/login?auth_error=no_identity")
|
||||
|
||||
# ── Explicit account-link flow ──────────────────────────────────────────
|
||||
# Initiated by an already-authenticated user from their profile. Attach the
|
||||
# passkey to that account. No group gating here: this is identity linking,
|
||||
@@ -238,6 +270,11 @@ async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSes
|
||||
target.email = email
|
||||
return RedirectResponse(url="/profile?linked=1")
|
||||
|
||||
# Normal sign-in: require the signed, short-lived state we issued in
|
||||
# /pocketid/login-url, so the callback can't be driven by an injected code.
|
||||
if not _valid_login_state(state):
|
||||
return RedirectResponse(url="/login?auth_error=invalid_state")
|
||||
|
||||
# Group gating: if an allowed group is configured, the user must be in it.
|
||||
allowed_group = await _get_allowed_group(db)
|
||||
if allowed_group:
|
||||
|
||||
Reference in New Issue
Block a user