Files
yellow-bank-soal/app/services/wordpress_auth.py
Dwindi Ramadhana cf193d7ea0 first commit
2026-03-21 23:32:59 +07:00

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