# Harden Wix test routes behind admin+env gating

This commit is contained in:
ي
2026-05-11 15:48:56 +05:30
committed by ajaysi
parent 439a9b6be3
commit 9afd0d322d
4 changed files with 207 additions and 48 deletions

View File

@@ -18,7 +18,7 @@ CORE_ROUTER_REGISTRY = [
{"name": "step3_research", "module": "api.onboarding_utils.step3_routes", "attr": "router", "features": {"all", "core"}},
{"name": "step4_assets", "module": "api.onboarding_utils.step4_asset_routes", "attr": "router", "features": {"all", "core", "podcast"}},
{"name": "step4_persona", "module": "api.onboarding_utils.step4_persona_routes_optimized", "attr": "router", "features": {"all", "core"}},
{"name": "gsc_auth", "module": "routers.gsc_auth", "attr": "router", "features": {"all", "core", "seo"}},
{"name": "gsc_auth", "module": "routers.gsc_auth", "attr": "router", "features": {"all", "core", "seo", "blog_writer"}},
{"name": "wordpress_oauth", "module": "routers.wordpress_oauth", "attr": "router", "features": {"all", "core"}},
{"name": "bing_oauth", "module": "routers.bing_oauth", "attr": "router", "features": {"all", "core"}},
{"name": "bing_analytics", "module": "routers.bing_analytics", "attr": "router", "features": {"all", "core"}},
@@ -45,6 +45,7 @@ OPTIONAL_ROUTER_REGISTRY = [
{"name": "blog_writer", "module": "api.blog_writer.router", "attr": "router", "features": {"all", "blog_writer"}},
{"name": "story_writer", "module": "api.story_writer.router", "attr": "router", "features": {"all", "story_writer"}},
{"name": "wix", "module": "api.wix_routes", "attr": "router", "features": {"all"}},
{"name": "wix_test", "module": "api.wix_routes", "attr": "qa_router", "features": {"all"}},
{"name": "blog_seo_analysis", "module": "api.blog_writer.seo_analysis", "attr": "router", "features": {"all", "blog_writer"}},
{"name": "persona", "module": "api.persona_routes", "attr": "router", "features": {"all", "persona"}},
{"name": "video_studio", "module": "api.video_studio.router", "attr": "router", "features": {"all", "video_studio"}},
@@ -159,6 +160,12 @@ class RouterManager:
logger.info(f"Including {group_name} routers with features: {enabled_features}...")
for entry in registry:
if entry["name"] == "wix_test" and not self._should_include_wix_test_router():
reason = "wix test routes disabled or running in production environment"
self.skipped_routers.append({"name": entry["name"], "reason": reason})
if verbose:
logger.info(f"⏭️ Skipping {entry['name']}: {reason}")
continue
if not self._should_include_router(entry, enabled_features):
reason = f"features {enabled_features} not matching {entry.get('features', set())}"
self.skipped_routers.append({"name": entry["name"], "reason": reason})
@@ -178,6 +185,13 @@ class RouterManager:
except Exception as e:
logger.error(f"❌ Error including {group_name} routers: {e}")
return False
@staticmethod
def _should_include_wix_test_router() -> bool:
environment = (os.getenv("ENVIRONMENT") or os.getenv("APP_ENV") or "development").strip().lower()
is_production = environment in {"prod", "production"}
wix_test_enabled = os.getenv("WIX_TEST_ROUTES_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
return wix_test_enabled and not is_production
def include_core_routers(self) -> bool:
"""Include core application routers."""

View File

@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse
from typing import Dict, Any, Optional
from loguru import logger
from pydantic import BaseModel
import uuid
from services.wix_service import WixService
from services.integrations.wix_oauth import WixOAuthService
@@ -18,6 +19,7 @@ import json
from urllib.parse import urlparse
router = APIRouter(prefix="/api/wix", tags=["Wix Integration"])
qa_router = APIRouter(prefix="/api/wix/test", tags=["Wix Integration QA"])
def _sanitize_error_message(error: Exception) -> str:
@@ -122,8 +124,41 @@ class WixConnectionStatus(BaseModel):
error: Optional[str] = None
def _is_wix_test_mode_enabled() -> bool:
return os.getenv("WIX_TEST_ROUTES_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
def _is_admin_user(current_user: Dict[str, Any]) -> bool:
email = (current_user.get("email") or "").lower()
role = current_user.get("role")
public_metadata = current_user.get("public_metadata")
if isinstance(public_metadata, dict):
role = public_metadata.get("role") or role
admin_emails = {
e.strip().lower()
for e in os.getenv("ADMIN_EMAILS", "").split(",")
if e.strip()
}
admin_domain = (os.getenv("ADMIN_EMAIL_DOMAIN") or "").lower().strip()
return bool(
role == "admin"
or (email and email in admin_emails)
or (email and admin_domain and email.endswith(f"@{admin_domain}"))
)
def _require_wix_test_access(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if not _is_wix_test_mode_enabled():
raise HTTPException(status_code=404, detail="Not found")
if not _is_admin_user(current_user):
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
@router.get("/auth/url")
async def get_authorization_url(state: Optional[str] = None) -> Dict[str, str]:
async def get_authorization_url(state: Optional[str] = None, current_user: dict = Depends(get_current_user)) -> Dict[str, str]:
"""
Get Wix OAuth authorization URL
@@ -134,8 +169,21 @@ async def get_authorization_url(state: Optional[str] = None) -> Dict[str, str]:
Authorization URL
"""
try:
url = wix_service.get_authorization_url(state)
return {"authorization_url": url}
user_id = current_user.get('id') if current_user else None
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
oauth_state = state or str(uuid.uuid4())
oauth_payload = wix_service.get_authorization_url(oauth_state)
saved = wix_oauth_service.store_pkce_verifier(
user_id=user_id,
state=oauth_state,
code_verifier=oauth_payload["code_verifier"],
ttl_seconds=600
)
if not saved:
raise HTTPException(status_code=500, detail="Failed to persist OAuth verifier state")
return {"authorization_url": oauth_payload["authorization_url"], "state": oauth_state}
except Exception as e:
logger.error(f"Failed to generate authorization URL: {e}")
raise HTTPException(status_code=500, detail=str(e))
@@ -158,8 +206,16 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
if not user_id:
raise HTTPException(status_code=400, detail="User ID not found")
if not request.state:
raise HTTPException(status_code=400, detail="Missing OAuth state")
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=request.state)
if not code_verifier:
raise HTTPException(
status_code=400,
detail="Invalid or expired OAuth state. Please restart Wix connection."
)
# Exchange code for tokens
tokens = wix_service.exchange_code_for_tokens(request.code)
tokens = wix_service.exchange_code_for_tokens(request.code, code_verifier=code_verifier)
# Get site information to extract site_id and member_id
site_info = wix_service.get_site_info(tokens['access_token'])
@@ -212,32 +268,38 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
async def handle_oauth_callback_get(code: str, state: Optional[str] = None, request: Request = None, current_user: dict = Depends(get_current_user)):
"""HTML callback page for Wix OAuth that exchanges code and notifies opener via postMessage."""
try:
tokens = wix_service.exchange_code_for_tokens(code)
user_id = current_user.get('id') if current_user else None
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
if not state:
raise HTTPException(status_code=400, detail="Missing OAuth state")
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=state)
if not code_verifier:
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state. Please reconnect Wix.")
tokens = wix_service.exchange_code_for_tokens(code, code_verifier=code_verifier)
site_info = wix_service.get_site_info(tokens['access_token'])
permissions = wix_service.check_blog_permissions(tokens['access_token'])
# Store tokens in database if we have user_id
user_id = current_user.get('id') if current_user else None
if user_id:
site_id = site_info.get('siteId') or site_info.get('site_id')
member_id = None
try:
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
except Exception:
pass
stored = wix_oauth_service.store_tokens(
user_id=user_id,
access_token=tokens['access_token'],
refresh_token=tokens.get('refresh_token'),
expires_in=tokens.get('expires_in'),
token_type=tokens.get('token_type', 'Bearer'),
scope=tokens.get('scope'),
site_id=site_id,
member_id=member_id
)
if not stored:
logger.warning(f"Failed to store Wix tokens for user {user_id} in GET callback")
site_id = site_info.get('siteId') or site_info.get('site_id')
member_id = None
try:
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
except Exception:
pass
stored = wix_oauth_service.store_tokens(
user_id=user_id,
access_token=tokens['access_token'],
refresh_token=tokens.get('refresh_token'),
expires_in=tokens.get('expires_in'),
token_type=tokens.get('token_type', 'Bearer'),
scope=tokens.get('scope'),
site_id=site_id,
member_id=member_id
)
if not stored:
logger.warning(f"Failed to store Wix tokens for user {user_id} in GET callback")
# Build success payload for postMessage
payload = {
@@ -493,8 +555,8 @@ async def disconnect_wix(current_user: dict = Depends(get_current_user)) -> Dict
# TEST ENDPOINTS - No authentication required for testing
# =============================================================================
@router.get("/test/connection/status")
async def get_test_connection_status() -> WixConnectionStatus:
@qa_router.get("/connection/status")
async def get_test_connection_status(_: Dict[str, Any] = Depends(_require_wix_test_access)) -> WixConnectionStatus:
"""
TEST ENDPOINT: Check Wix connection status without authentication
@@ -519,8 +581,8 @@ async def get_test_connection_status() -> WixConnectionStatus:
)
@router.get("/test/auth/url")
async def get_test_authorization_url(state: Optional[str] = None) -> Dict[str, str]:
@qa_router.get("/auth/url")
async def get_test_authorization_url(state: Optional[str] = None, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, str]:
"""
TEST ENDPOINT: Get Wix OAuth authorization URL without authentication
@@ -557,8 +619,8 @@ async def get_test_authorization_url(state: Optional[str] = None) -> Dict[str, s
raise HTTPException(status_code=500, detail=str(e))
@router.post("/test/publish")
async def test_publish_to_wix(request: WixPublishRequest) -> Dict[str, Any]:
@qa_router.post("/publish")
async def test_publish_to_wix(request: WixPublishRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
"""
TEST ENDPOINT: Simulate publishing a blog post to Wix without authentication.
@@ -610,8 +672,8 @@ async def refresh_wix_token(request: Dict[str, Any]) -> Dict[str, Any]:
raise HTTPException(status_code=500, detail=f"Failed to refresh token: {str(e)}")
@router.post("/test/publish/real")
async def test_publish_real(payload: Dict[str, Any]) -> Dict[str, Any]:
@qa_router.post("/publish/real")
async def test_publish_real(payload: Dict[str, Any], _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
"""
TEST ENDPOINT: Perform a real publish to Wix using a provided access token.
@@ -688,8 +750,8 @@ async def test_publish_real(payload: Dict[str, Any]) -> Dict[str, Any]:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/test/category")
async def test_create_category(request: WixCreateCategoryRequest) -> Dict[str, Any]:
@qa_router.post("/category")
async def test_create_category(request: WixCreateCategoryRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
try:
result = wix_service.create_category(
access_token=request.access_token,
@@ -703,8 +765,8 @@ async def test_create_category(request: WixCreateCategoryRequest) -> Dict[str, A
raise HTTPException(status_code=500, detail=str(e))
@router.post("/test/tag")
async def test_create_tag(request: WixCreateTagRequest) -> Dict[str, Any]:
@qa_router.post("/tag")
async def test_create_tag(request: WixCreateTagRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
try:
result = wix_service.create_tag(
access_token=request.access_token,

View File

@@ -48,7 +48,94 @@ class WixOAuthService:
is_active BOOLEAN DEFAULT TRUE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS wix_oauth_pkce_states (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
state TEXT NOT NULL UNIQUE,
code_verifier TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
used_at TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_wix_oauth_pkce_user_state
ON wix_oauth_pkce_states (user_id, state)
''')
conn.commit()
def cleanup_expired_pkce_states(self, user_id: str) -> int:
"""Delete expired or already-used PKCE state records."""
try:
self._init_db(user_id)
db_path = self._get_db_path(user_id)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute(
'''
DELETE FROM wix_oauth_pkce_states
WHERE used_at IS NOT NULL OR expires_at <= datetime('now')
'''
)
deleted = cursor.rowcount
conn.commit()
return deleted if deleted is not None else 0
except Exception as e:
logger.warning(f"Failed to cleanup expired Wix PKCE states for user {user_id}: {e}")
return 0
def store_pkce_verifier(self, user_id: str, state: str, code_verifier: str, ttl_seconds: int = 600) -> bool:
"""Store PKCE code verifier by OAuth state with short TTL."""
try:
self._init_db(user_id)
self.cleanup_expired_pkce_states(user_id)
db_path = self._get_db_path(user_id)
expires_at = datetime.now() + timedelta(seconds=ttl_seconds)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute(
'''
INSERT OR REPLACE INTO wix_oauth_pkce_states (user_id, state, code_verifier, expires_at, created_at, used_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL)
''',
(user_id, state, code_verifier, expires_at)
)
conn.commit()
return True
except Exception as e:
logger.error(f"Failed storing Wix PKCE verifier for user {user_id}, state {state}: {e}")
return False
def consume_pkce_verifier(self, user_id: str, state: str) -> Optional[str]:
"""Get and invalidate one-time PKCE verifier for a state if valid and unexpired."""
try:
self._init_db(user_id)
self.cleanup_expired_pkce_states(user_id)
db_path = self._get_db_path(user_id)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute(
'''
SELECT id, code_verifier
FROM wix_oauth_pkce_states
WHERE user_id = ? AND state = ? AND used_at IS NULL AND expires_at > datetime('now')
LIMIT 1
''',
(user_id, state)
)
row = cursor.fetchone()
if not row:
return None
cursor.execute(
"UPDATE wix_oauth_pkce_states SET used_at = CURRENT_TIMESTAMP WHERE id = ?",
(row[0],)
)
conn.commit()
return row[1]
except Exception as e:
logger.error(f"Failed consuming Wix PKCE verifier for user {user_id}, state {state}: {e}")
return None
def store_tokens(
self,
@@ -302,4 +389,3 @@ class WixOAuthService:
except Exception as e:
logger.error(f"Error revoking Wix token: {e}")
return False

View File

@@ -40,7 +40,7 @@ class WixService:
if not self.client_id:
logger.warning("Wix client ID not configured. Set WIX_CLIENT_ID environment variable.")
def get_authorization_url(self, state: str = None) -> str:
def get_authorization_url(self, state: str = None) -> Dict[str, str]:
"""
Generate Wix OAuth authorization URL for "on behalf of user" authentication
@@ -54,8 +54,7 @@ class WixService:
Authorization URL for user to visit
"""
url, code_verifier = self.auth_service.generate_authorization_url(state)
self._code_verifier = code_verifier
return url
return {"authorization_url": url, "state": state, "code_verifier": code_verifier}
def _create_redirect_session_for_auth(self, redirect_uri: str, client_id: str, code_challenge: str, state: str) -> str:
"""
@@ -97,13 +96,13 @@ class WixService:
logger.error(f"Failed to create redirect session for auth: {e}")
raise
def exchange_code_for_tokens(self, code: str, code_verifier: str = None) -> Dict[str, Any]:
def exchange_code_for_tokens(self, code: str, code_verifier: str) -> Dict[str, Any]:
"""
Exchange authorization code for access and refresh tokens using PKCE
Args:
code: Authorization code from Wix
code_verifier: PKCE code verifier (uses stored one if not provided)
code_verifier: PKCE code verifier
Returns:
Token response with access_token, refresh_token, etc.
@@ -111,9 +110,7 @@ class WixService:
if not self.client_id:
raise ValueError("Wix client ID not configured")
if not code_verifier:
code_verifier = getattr(self, '_code_verifier', None)
if not code_verifier:
raise ValueError("Code verifier not found. Please provide code_verifier parameter.")
raise ValueError("Code verifier is required.")
try:
return self.auth_service.exchange_code_for_tokens(code, code_verifier)
except requests.RequestException as e: