""" WordPress Authentication and User Synchronization Service. Handles: - JWT token validation via WordPress REST API - User synchronization from WordPress to local database - Multi-site support via website_id isolation """ import logging from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Optional import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.models.user import User from app.models.website import Website logger = logging.getLogger(__name__) settings = get_settings() # Custom exceptions for WordPress integration class WordPressAuthError(Exception): """Base exception for WordPress authentication errors.""" pass class WordPressTokenInvalidError(WordPressAuthError): """Raised when WordPress token is invalid or expired.""" pass class WordPressAPIError(WordPressAuthError): """Raised when WordPress API is unreachable or returns error.""" pass class WordPressRateLimitError(WordPressAuthError): """Raised when WordPress API rate limit is exceeded.""" pass class WebsiteNotFoundError(WordPressAuthError): """Raised when website_id is not found in local database.""" pass @dataclass class WordPressUserInfo: """Data class for WordPress user information.""" wp_user_id: str username: str email: str display_name: str roles: list[str] raw_data: dict[str, Any] @dataclass class SyncStats: """Data class for user synchronization statistics.""" inserted: int updated: int total: int errors: int async def get_wordpress_api_base(website: Website) -> str: """ Get WordPress API base URL for a website. Args: website: Website model instance Returns: WordPress REST API base URL """ # Use website's site_url if configured, otherwise use global config base_url = website.site_url.rstrip('/') return f"{base_url}/wp-json" async def verify_wordpress_token( token: str, website_id: int, wp_user_id: str, db: AsyncSession, ) -> Optional[WordPressUserInfo]: """ Verify WordPress JWT token and validate user identity. Calls WordPress REST API GET /wp/v2/users/me with Authorization header. Verifies response contains matching wp_user_id. Verifies website_id exists in local database. Args: token: WordPress JWT authentication token website_id: Website identifier for multi-site isolation wp_user_id: Expected WordPress user ID to verify db: Async database session Returns: WordPressUserInfo if valid, None if invalid Raises: WebsiteNotFoundError: If website_id doesn't exist WordPressTokenInvalidError: If token is invalid WordPressAPIError: If API is unreachable WordPressRateLimitError: If rate limited """ # Verify website exists website_result = await db.execute( select(Website).where(Website.id == website_id) ) website = website_result.scalar_one_or_none() if website is None: raise WebsiteNotFoundError(f"Website {website_id} not found") api_base = await get_wordpress_api_base(website) url = f"{api_base}/wp/v2/users/me" headers = { "Authorization": f"Bearer {token}", "Accept": "application/json", } timeout = httpx.Timeout(10.0, connect=5.0) try: async with httpx.AsyncClient(timeout=timeout) as client: response = await client.get(url, headers=headers) if response.status_code == 401: raise WordPressTokenInvalidError("Invalid or expired WordPress token") if response.status_code == 429: raise WordPressRateLimitError("WordPress API rate limit exceeded") if response.status_code == 503: raise WordPressAPIError("WordPress API service unavailable") if response.status_code != 200: raise WordPressAPIError( f"WordPress API error: {response.status_code} - {response.text}" ) data = response.json() # Verify user ID matches response_user_id = str(data.get("id", "")) if response_user_id != str(wp_user_id): logger.warning( f"User ID mismatch: expected {wp_user_id}, got {response_user_id}" ) return None # Extract user info user_info = WordPressUserInfo( wp_user_id=response_user_id, username=data.get("username", ""), email=data.get("email", ""), display_name=data.get("name", ""), roles=data.get("roles", []), raw_data=data, ) return user_info except httpx.TimeoutException: raise WordPressAPIError("WordPress API request timed out") except httpx.ConnectError: raise WordPressAPIError("Unable to connect to WordPress API") except httpx.HTTPError as e: raise WordPressAPIError(f"HTTP error communicating with WordPress: {str(e)}") async def fetch_wordpress_users( website: Website, admin_token: str, page: int = 1, per_page: int = 100, ) -> list[dict[str, Any]]: """ Fetch users from WordPress API (requires admin token). Calls WordPress REST API GET /wp/v2/users with admin authorization. Args: website: Website model instance admin_token: WordPress admin JWT token page: Page number for pagination per_page: Number of users per page (max 100) Returns: List of WordPress user data dictionaries Raises: WordPressTokenInvalidError: If admin token is invalid WordPressAPIError: If API is unreachable WordPressRateLimitError: If rate limited """ api_base = await get_wordpress_api_base(website) url = f"{api_base}/wp/v2/users" headers = { "Authorization": f"Bearer {admin_token}", "Accept": "application/json", } params = { "page": page, "per_page": min(per_page, 100), "context": "edit", # Get full user data } timeout = httpx.Timeout(30.0, connect=10.0) try: async with httpx.AsyncClient(timeout=timeout) as client: response = await client.get(url, headers=headers, params=params) if response.status_code == 401: raise WordPressTokenInvalidError("Invalid admin token for user sync") if response.status_code == 403: raise WordPressTokenInvalidError( "Admin token lacks permission to list users" ) if response.status_code == 429: raise WordPressRateLimitError("WordPress API rate limit exceeded") if response.status_code == 503: raise WordPressAPIError("WordPress API service unavailable") if response.status_code != 200: raise WordPressAPIError( f"WordPress API error: {response.status_code} - {response.text}" ) return response.json() except httpx.TimeoutException: raise WordPressAPIError("WordPress API request timed out") except httpx.ConnectError: raise WordPressAPIError("Unable to connect to WordPress API") except httpx.HTTPError as e: raise WordPressAPIError(f"HTTP error communicating with WordPress: {str(e)}") async def sync_wordpress_users( website_id: int, admin_token: str, db: AsyncSession, ) -> SyncStats: """ Synchronize users from WordPress to local database. Fetches all users from WordPress API and performs upsert: - Updates existing users - Inserts new users Args: website_id: Website identifier for multi-site isolation admin_token: WordPress admin JWT token db: Async database session Returns: SyncStats with insertion/update counts Raises: WebsiteNotFoundError: If website_id doesn't exist WordPressTokenInvalidError: If admin token is invalid WordPressAPIError: If API is unreachable """ # Verify website exists website_result = await db.execute( select(Website).where(Website.id == website_id) ) website = website_result.scalar_one_or_none() if website is None: raise WebsiteNotFoundError(f"Website {website_id} not found") # Fetch existing users from local database existing_users_result = await db.execute( select(User).where(User.website_id == website_id) ) existing_users = { str(user.wp_user_id): user for user in existing_users_result.scalars().all() } # Fetch users from WordPress (with pagination) all_wp_users = [] page = 1 per_page = 100 while True: wp_users = await fetch_wordpress_users( website, admin_token, page, per_page ) if not wp_users: break all_wp_users.extend(wp_users) # Check if more pages if len(wp_users) < per_page: break page += 1 # Sync users inserted = 0 updated = 0 errors = 0 for wp_user in all_wp_users: try: wp_user_id = str(wp_user.get("id", "")) if not wp_user_id: errors += 1 continue if wp_user_id in existing_users: # Update existing user (timestamp update) existing_user = existing_users[wp_user_id] existing_user.updated_at = datetime.now(timezone.utc) updated += 1 else: # Insert new user new_user = User( wp_user_id=wp_user_id, website_id=website_id, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) db.add(new_user) inserted += 1 except Exception as e: logger.error(f"Error syncing user {wp_user.get('id')}: {e}") errors += 1 await db.commit() total = inserted + updated logger.info( f"WordPress user sync complete for website {website_id}: " f"{inserted} inserted, {updated} updated, {errors} errors" ) return SyncStats( inserted=inserted, updated=updated, total=total, errors=errors, ) async def get_wordpress_user( wp_user_id: str, website_id: int, db: AsyncSession, ) -> Optional[User]: """ Get user from local database by WordPress user ID and website ID. Args: wp_user_id: WordPress user ID website_id: Website identifier for multi-site isolation db: Async database session Returns: User object if found, None otherwise """ result = await db.execute( select(User).where( User.wp_user_id == wp_user_id, User.website_id == website_id, ) ) return result.scalar_one_or_none() async def verify_website_exists( website_id: int, db: AsyncSession, ) -> Website: """ Verify website exists in database. Args: website_id: Website identifier db: Async database session Returns: Website model instance Raises: WebsiteNotFoundError: If website doesn't exist """ result = await db.execute( select(Website).where(Website.id == website_id) ) website = result.scalar_one_or_none() if website is None: raise WebsiteNotFoundError(f"Website {website_id} not found") return website async def get_or_create_user( wp_user_id: str, website_id: int, db: AsyncSession, ) -> User: """ Get existing user or create new one if not exists. Args: wp_user_id: WordPress user ID website_id: Website identifier db: Async database session Returns: User model instance """ existing = await get_wordpress_user(wp_user_id, website_id, db) if existing: return existing # Create new user new_user = User( wp_user_id=wp_user_id, website_id=website_id, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) db.add(new_user) await db.commit() await db.refresh(new_user) return new_user