first commit
This commit is contained in:
456
app/services/wordpress_auth.py
Normal file
456
app/services/wordpress_auth.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user