2026-06-16 02:00:22 +08:00
|
|
|
"""Authentication guards and identity resolution for PolyWeather."""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
from typing import Any, Dict
|
|
|
|
|
|
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
|
|
|
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _env_bool(name: str, default: bool = False) -> bool:
|
|
|
|
|
raw = os.getenv(name)
|
|
|
|
|
if raw is None:
|
|
|
|
|
return default
|
|
|
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_ENTITLEMENT_HEADER = "x-polyweather-entitlement"
|
|
|
|
|
_FORWARDED_SUPABASE_USER_ID_HEADER = "x-polyweather-auth-user-id"
|
|
|
|
|
_FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email"
|
|
|
|
|
_OPS_ADMIN_EMAILS = {
|
|
|
|
|
item.strip().lower()
|
|
|
|
|
for item in str(os.getenv("POLYWEATHER_OPS_ADMIN_EMAILS") or "").split(",")
|
|
|
|
|
if item.strip()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Config values imported lazily from web.core to avoid circular imports
|
|
|
|
|
# and allow monkeypatching to target web.core directly.
|
|
|
|
|
def _get_entitlement_token():
|
|
|
|
|
import web.core as _core
|
|
|
|
|
return _core._ENTITLEMENT_TOKEN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_supabase_auth_required():
|
|
|
|
|
import web.core as _core
|
|
|
|
|
return _core._SUPABASE_AUTH_REQUIRED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_entitlement_guard_enabled():
|
|
|
|
|
import web.core as _core
|
|
|
|
|
return _core._ENTITLEMENT_GUARD_ENABLED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _legacy_service_token_valid(request: Request) -> bool:
|
|
|
|
|
token = request.headers.get(_ENTITLEMENT_HEADER)
|
|
|
|
|
if not token:
|
|
|
|
|
token = extract_bearer_token(request.headers.get("authorization"))
|
|
|
|
|
token_hint = _get_entitlement_token()
|
|
|
|
|
return bool(token_hint and token == token_hint)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bind_forwarded_supabase_identity(request: Request) -> bool:
|
|
|
|
|
if not _legacy_service_token_valid(request):
|
|
|
|
|
return False
|
|
|
|
|
forwarded_user_id = str(
|
|
|
|
|
request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or ""
|
|
|
|
|
).strip()
|
|
|
|
|
if not forwarded_user_id:
|
|
|
|
|
return False
|
|
|
|
|
request.state.auth_user_id = forwarded_user_id
|
|
|
|
|
request.state.auth_email = str(
|
|
|
|
|
request.headers.get(_FORWARDED_SUPABASE_EMAIL_HEADER) or ""
|
|
|
|
|
).strip()
|
2026-06-22 12:08:20 +08:00
|
|
|
from src.utils.online_tracker import record_activity
|
|
|
|
|
record_activity(forwarded_user_id)
|
2026-06-16 02:00:22 +08:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bind_optional_supabase_identity(request: Request) -> None:
|
|
|
|
|
if _bind_forwarded_supabase_identity(request):
|
|
|
|
|
return
|
|
|
|
|
if not SUPABASE_ENTITLEMENT.configured:
|
|
|
|
|
return
|
|
|
|
|
access_token = extract_bearer_token(request.headers.get("authorization"))
|
|
|
|
|
if not access_token:
|
|
|
|
|
return
|
|
|
|
|
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
|
|
|
|
|
if not identity:
|
|
|
|
|
return
|
|
|
|
|
request.state.auth_user_id = identity.user_id
|
|
|
|
|
request.state.auth_email = identity.email
|
|
|
|
|
request.state.auth_points = identity.points
|
|
|
|
|
request.state.auth_created_at = identity.created_at
|
|
|
|
|
from src.utils.online_tracker import record_activity
|
|
|
|
|
record_activity(identity.user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_auth_points(request: Request, account_db=None) -> int:
|
|
|
|
|
|
|
|
|
|
if account_db is None:
|
|
|
|
|
# imported lazily to avoid circular dependency at module level
|
|
|
|
|
from web.core import _account_db as _db
|
|
|
|
|
account_db = _db
|
|
|
|
|
|
|
|
|
|
raw_points = getattr(request.state, "auth_points", 0)
|
|
|
|
|
try:
|
|
|
|
|
points = max(0, int(raw_points or 0))
|
|
|
|
|
except Exception:
|
|
|
|
|
points = 0
|
|
|
|
|
|
|
|
|
|
user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
|
|
|
|
|
|
|
|
|
|
if user_id:
|
|
|
|
|
try:
|
|
|
|
|
db_points = account_db.get_points_by_supabase_user_id(user_id)
|
|
|
|
|
if db_points > points:
|
|
|
|
|
request.state.auth_points = db_points
|
|
|
|
|
points = db_points
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning(f"auth points fallback failed user_id={user_id}: {exc}")
|
|
|
|
|
|
|
|
|
|
if points <= 0:
|
|
|
|
|
email = str(getattr(request.state, "auth_email", "") or "").strip().lower()
|
|
|
|
|
if email:
|
|
|
|
|
try:
|
|
|
|
|
email_points = account_db.get_points_by_supabase_email(email)
|
|
|
|
|
if email_points > points:
|
|
|
|
|
request.state.auth_points = email_points
|
|
|
|
|
points = email_points
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"auth points email fallback failed email={email}: {exc}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return points
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_weekly_profile(request: Request, account_db=None) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
if account_db is None:
|
|
|
|
|
from web.core import _account_db as _db
|
|
|
|
|
account_db = _db
|
|
|
|
|
|
|
|
|
|
user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
|
|
|
|
|
if not user_id:
|
|
|
|
|
return {"weekly_points": 0, "weekly_rank": None}
|
|
|
|
|
try:
|
|
|
|
|
profile = account_db.get_weekly_profile_by_supabase_user_id(user_id)
|
|
|
|
|
return {
|
|
|
|
|
"weekly_points": int(profile.get("weekly_points") or 0),
|
|
|
|
|
"weekly_rank": profile.get("weekly_rank"),
|
|
|
|
|
}
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning(f"auth weekly profile fallback failed user_id={user_id}: {exc}")
|
|
|
|
|
return {"weekly_points": 0, "weekly_rank": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_entitlement(request: Request) -> None:
|
|
|
|
|
if SUPABASE_ENTITLEMENT.enabled:
|
|
|
|
|
if _legacy_service_token_valid(request):
|
|
|
|
|
if _bind_forwarded_supabase_identity(request):
|
|
|
|
|
return
|
|
|
|
|
bearer_token = extract_bearer_token(request.headers.get("authorization"))
|
|
|
|
|
if not bearer_token or bearer_token == _get_entitlement_token():
|
|
|
|
|
return
|
|
|
|
|
if not _get_supabase_auth_required():
|
|
|
|
|
_bind_optional_supabase_identity(request)
|
|
|
|
|
return
|
|
|
|
|
if not SUPABASE_ENTITLEMENT.configured:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=503,
|
|
|
|
|
detail="Supabase auth is enabled but SUPABASE_URL / SUPABASE_ANON_KEY is not configured",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
access_token = extract_bearer_token(request.headers.get("authorization"))
|
|
|
|
|
if not access_token:
|
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
|
|
|
|
|
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
|
|
|
|
|
if not identity:
|
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
skip_subscription_gate = bool(
|
|
|
|
|
getattr(request.state, "skip_subscription_gate", False)
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
not skip_subscription_gate
|
|
|
|
|
and not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id)
|
|
|
|
|
):
|
|
|
|
|
raise HTTPException(status_code=403, detail="Subscription required")
|
|
|
|
|
|
|
|
|
|
request.state.auth_user_id = identity.user_id
|
|
|
|
|
request.state.auth_email = identity.email
|
|
|
|
|
request.state.auth_points = identity.points
|
|
|
|
|
request.state.auth_created_at = identity.created_at
|
|
|
|
|
from src.utils.online_tracker import record_activity
|
|
|
|
|
record_activity(identity.user_id)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not _get_entitlement_guard_enabled():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not _get_entitlement_token():
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=503,
|
|
|
|
|
detail="Entitlement guard is enabled but backend token is not configured",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not _legacy_service_token_valid(request):
|
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_supabase_identity(request: Request) -> Dict[str, str]:
|
|
|
|
|
if not SUPABASE_ENTITLEMENT.enabled:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=503, detail="payment requires POLYWEATHER_AUTH_ENABLED=true"
|
|
|
|
|
)
|
|
|
|
|
if not SUPABASE_ENTITLEMENT.configured:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=503,
|
|
|
|
|
detail="payment requires SUPABASE_URL and SUPABASE_ANON_KEY",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
state_user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
|
|
|
|
|
if state_user_id:
|
|
|
|
|
state_email = str(getattr(request.state, "auth_email", "") or "").strip()
|
|
|
|
|
return {"user_id": state_user_id, "email": state_email}
|
|
|
|
|
|
|
|
|
|
token = extract_bearer_token(request.headers.get("authorization"))
|
|
|
|
|
if token:
|
|
|
|
|
identity = SUPABASE_ENTITLEMENT.get_identity(token)
|
|
|
|
|
if identity:
|
|
|
|
|
return {"user_id": identity.user_id, "email": identity.email}
|
|
|
|
|
|
|
|
|
|
legacy_ok = _legacy_service_token_valid(request)
|
|
|
|
|
if legacy_ok:
|
|
|
|
|
forwarded_user_id = str(
|
|
|
|
|
request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or ""
|
|
|
|
|
).strip()
|
|
|
|
|
if forwarded_user_id:
|
|
|
|
|
forwarded_email = str(
|
|
|
|
|
request.headers.get(_FORWARDED_SUPABASE_EMAIL_HEADER) or ""
|
|
|
|
|
).strip()
|
|
|
|
|
return {"user_id": forwarded_user_id, "email": forwarded_email}
|
|
|
|
|
return {"user_id": "entitlement", "email": ""}
|
|
|
|
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
"payment auth identity missing state_user={} auth_bearer={} legacy_ok={} forwarded_user={}".format(
|
|
|
|
|
bool(state_user_id),
|
|
|
|
|
bool(token),
|
|
|
|
|
bool(legacy_ok),
|
|
|
|
|
bool(
|
|
|
|
|
str(
|
|
|
|
|
request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or ""
|
|
|
|
|
).strip()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_ops_admin(request: Request) -> Dict[str, str]:
|
|
|
|
|
identity = _require_supabase_identity(request)
|
|
|
|
|
email = str(identity.get("email") or "").strip().lower()
|
|
|
|
|
if email and email in _OPS_ADMIN_EMAILS:
|
|
|
|
|
return identity
|
|
|
|
|
|
|
|
|
|
user_id = identity.get("user_id")
|
|
|
|
|
if user_id and user_id.lower() == "entitlement":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=401,
|
|
|
|
|
detail="entitlement bearer is not suitable for ops admin authorization",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=403,
|
|
|
|
|
detail="ops access restricted to configured admin emails",
|
|
|
|
|
)
|