# 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": "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_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": "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": "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_oauth", "module": "routers.bing_oauth", "attr": "router", "features": {"all", "core"}},
{"name": "bing_analytics", "module": "routers.bing_analytics", "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": "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": "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", "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": "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": "persona", "module": "api.persona_routes", "attr": "router", "features": {"all", "persona"}},
{"name": "video_studio", "module": "api.video_studio.router", "attr": "router", "features": {"all", "video_studio"}}, {"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}...") logger.info(f"Including {group_name} routers with features: {enabled_features}...")
for entry in registry: 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): if not self._should_include_router(entry, enabled_features):
reason = f"features {enabled_features} not matching {entry.get('features', set())}" reason = f"features {enabled_features} not matching {entry.get('features', set())}"
self.skipped_routers.append({"name": entry["name"], "reason": reason}) self.skipped_routers.append({"name": entry["name"], "reason": reason})
@@ -178,6 +185,13 @@ class RouterManager:
except Exception as e: except Exception as e:
logger.error(f"❌ Error including {group_name} routers: {e}") logger.error(f"❌ Error including {group_name} routers: {e}")
return False 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: def include_core_routers(self) -> bool:
"""Include core application routers.""" """Include core application routers."""

View File

@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
import uuid
from services.wix_service import WixService from services.wix_service import WixService
from services.integrations.wix_oauth import WixOAuthService from services.integrations.wix_oauth import WixOAuthService
@@ -18,6 +19,7 @@ import json
from urllib.parse import urlparse from urllib.parse import urlparse
router = APIRouter(prefix="/api/wix", tags=["Wix Integration"]) 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: def _sanitize_error_message(error: Exception) -> str:
@@ -122,8 +124,41 @@ class WixConnectionStatus(BaseModel):
error: Optional[str] = None 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") @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 Get Wix OAuth authorization URL
@@ -134,8 +169,21 @@ async def get_authorization_url(state: Optional[str] = None) -> Dict[str, str]:
Authorization URL Authorization URL
""" """
try: try:
url = wix_service.get_authorization_url(state) user_id = current_user.get('id') if current_user else None
return {"authorization_url": url} 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: except Exception as e:
logger.error(f"Failed to generate authorization URL: {e}") logger.error(f"Failed to generate authorization URL: {e}")
raise HTTPException(status_code=500, detail=str(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: if not user_id:
raise HTTPException(status_code=400, detail="User ID not found") 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 # 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 # Get site information to extract site_id and member_id
site_info = wix_service.get_site_info(tokens['access_token']) 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)): 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.""" """HTML callback page for Wix OAuth that exchanges code and notifies opener via postMessage."""
try: 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']) site_info = wix_service.get_site_info(tokens['access_token'])
permissions = wix_service.check_blog_permissions(tokens['access_token']) permissions = wix_service.check_blog_permissions(tokens['access_token'])
# Store tokens in database if we have user_id # Store tokens in database if we have user_id
user_id = current_user.get('id') if current_user else None site_id = site_info.get('siteId') or site_info.get('site_id')
if user_id: member_id = None
site_id = site_info.get('siteId') or site_info.get('site_id') try:
member_id = None member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
try: except Exception:
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token']) pass
except Exception:
pass stored = wix_oauth_service.store_tokens(
user_id=user_id,
stored = wix_oauth_service.store_tokens( access_token=tokens['access_token'],
user_id=user_id, refresh_token=tokens.get('refresh_token'),
access_token=tokens['access_token'], expires_in=tokens.get('expires_in'),
refresh_token=tokens.get('refresh_token'), token_type=tokens.get('token_type', 'Bearer'),
expires_in=tokens.get('expires_in'), scope=tokens.get('scope'),
token_type=tokens.get('token_type', 'Bearer'), site_id=site_id,
scope=tokens.get('scope'), member_id=member_id
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")
if not stored:
logger.warning(f"Failed to store Wix tokens for user {user_id} in GET callback")
# Build success payload for postMessage # Build success payload for postMessage
payload = { payload = {
@@ -493,8 +555,8 @@ async def disconnect_wix(current_user: dict = Depends(get_current_user)) -> Dict
# TEST ENDPOINTS - No authentication required for testing # TEST ENDPOINTS - No authentication required for testing
# ============================================================================= # =============================================================================
@router.get("/test/connection/status") @qa_router.get("/connection/status")
async def get_test_connection_status() -> WixConnectionStatus: async def get_test_connection_status(_: Dict[str, Any] = Depends(_require_wix_test_access)) -> WixConnectionStatus:
""" """
TEST ENDPOINT: Check Wix connection status without authentication TEST ENDPOINT: Check Wix connection status without authentication
@@ -519,8 +581,8 @@ async def get_test_connection_status() -> WixConnectionStatus:
) )
@router.get("/test/auth/url") @qa_router.get("/auth/url")
async def get_test_authorization_url(state: Optional[str] = None) -> Dict[str, str]: 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 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)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/test/publish") @qa_router.post("/publish")
async def test_publish_to_wix(request: WixPublishRequest) -> Dict[str, Any]: 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. 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)}") raise HTTPException(status_code=500, detail=f"Failed to refresh token: {str(e)}")
@router.post("/test/publish/real") @qa_router.post("/publish/real")
async def test_publish_real(payload: Dict[str, Any]) -> Dict[str, Any]: 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. 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)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/test/category") @qa_router.post("/category")
async def test_create_category(request: WixCreateCategoryRequest) -> Dict[str, Any]: async def test_create_category(request: WixCreateCategoryRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
try: try:
result = wix_service.create_category( result = wix_service.create_category(
access_token=request.access_token, 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)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/test/tag") @qa_router.post("/tag")
async def test_create_tag(request: WixCreateTagRequest) -> Dict[str, Any]: async def test_create_tag(request: WixCreateTagRequest, _: Dict[str, Any] = Depends(_require_wix_test_access)) -> Dict[str, Any]:
try: try:
result = wix_service.create_tag( result = wix_service.create_tag(
access_token=request.access_token, access_token=request.access_token,

View File

@@ -48,7 +48,94 @@ class WixOAuthService:
is_active BOOLEAN DEFAULT TRUE 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() 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( def store_tokens(
self, self,
@@ -302,4 +389,3 @@ class WixOAuthService:
except Exception as e: except Exception as e:
logger.error(f"Error revoking Wix token: {e}") logger.error(f"Error revoking Wix token: {e}")
return False return False

View File

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