457 lines
13 KiB
Python
457 lines
13 KiB
Python
"""
|
|
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
|