From 9afd0d322d93737bb30f3184eda69f5d1de3f209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Mon, 11 May 2026 15:48:56 +0530 Subject: [PATCH] # Harden Wix test routes behind admin+env gating --- backend/alwrity_utils/router_manager.py | 16 ++- backend/api/wix_routes.py | 138 +++++++++++++++------ backend/services/integrations/wix_oauth.py | 88 ++++++++++++- backend/services/wix_service.py | 13 +- 4 files changed, 207 insertions(+), 48 deletions(-) diff --git a/backend/alwrity_utils/router_manager.py b/backend/alwrity_utils/router_manager.py index bdba05d2..16e155c1 100644 --- a/backend/alwrity_utils/router_manager.py +++ b/backend/alwrity_utils/router_manager.py @@ -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.""" diff --git a/backend/api/wix_routes.py b/backend/api/wix_routes.py index d33e188b..ecbcaee2 100644 --- a/backend/api/wix_routes.py +++ b/backend/api/wix_routes.py @@ -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, diff --git a/backend/services/integrations/wix_oauth.py b/backend/services/integrations/wix_oauth.py index 5fa1f173..9e3a3e3b 100644 --- a/backend/services/integrations/wix_oauth.py +++ b/backend/services/integrations/wix_oauth.py @@ -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 - diff --git a/backend/services/wix_service.py b/backend/services/wix_service.py index 855adb1e..1d91a17d 100644 --- a/backend/services/wix_service.py +++ b/backend/services/wix_service.py @@ -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: