WordPress Auth

Detail modul wordpress_auth.py - verifikasi JWT WordPress, sinkronisasi user, multi-site isolation.

WordPress Auth

Source: backend/app/services/wordpress_auth.py Router: backend/app/routers/wordpress.py Lihat juga: Integrasi → Sejoli Tryout, Integrasi → Multi-Tenant

Tanggung Jawab

Modul wordpress_auth.py adalah gerbang autentikasi tunggal antara aplikasi dan WordPress:

  • Verifikasi JWT token WordPress via REST API
  • Sinkronisasi user dari WordPress ke database lokal
  • Extract & normalisasi identitas user (email, username, display_name)
  • Validasi website_id untuk multi-tenant isolation

Endpoint

Didefinisikan di backend/app/routers/wordpress.py dengan prefix /wordpress:

MethodEndpointTujuan
POST/wordpress/verify_sessionVerifikasi token WP, return app access token
POST/wordpress/sync_usersSync semua user WP ke DB lokal (admin only)
GET/wordpress/website/{website_id}/usersList user lokal per website

Verifikasi Token

python
async def verify_wordpress_token(
    token: str,
    website_id: int,
    wp_user_id: str,
    db: AsyncSession,
) -> Optional[WordPressUserInfo]:
    """
    Verifikasi JWT WordPress lewat REST API.

    Flow:
    1. Cek website_id ada di DB lokal
    2. GET {site_url}/wp-json/wp/v2/users/me dengan Authorization: Bearer {token}
    3. Verifikasi response.id == wp_user_id yang dikirim client
    4. Return WordPressUserInfo kalau valid, None kalau mismatch
    """

Sequence Verifikasi

sequenceDiagram
    autonumber
    participant Client
    participant App
    participant WP as WordPress REST API

    Client->>App: POST /wordpress/verify_session
    Note over Client,App: body: wp_user_id, token, website_id

    App->>App: Cek website_id di DB lokal
    alt website_id tidak ada
        App-->>Client: 404 WebsiteNotFoundError
    end

    App->>WP: GET /wp-json/wp/v2/users/me
    Note over App,WP: Authorization: Bearer {token}

    alt token invalid/expired
        WP-->>App: 401 Unauthorized
        App-->>Client: WordPressTokenInvalidError
    else rate limited
        WP-->>App: 429 Too Many Requests
        App-->>Client: WordPressRateLimitError
    else success
        WP-->>App: 200 { id, email, name, roles, ... }
        App->>App: Verify response.id == wp_user_id
        alt ID match
            App-->>Client: App access token + user_info
        else ID mismatch
            App-->>Client: None (silent reject)
        end
    end

HTTP Error Mapping

WP ResponseExceptionHTTP Code ke Client
200 OK(success)200
401 UnauthorizedWordPressTokenInvalidError401
429 Too Many RequestsWordPressRateLimitError429
503 Service UnavailableWordPressAPIError502
LainnyaWordPressAPIError502
Timeout (10s)WordPressAPIError504
ConnectErrorWordPressAPIError502

Data Class

WordPressUserInfo

Hasil verifikasi token:

python
@dataclass
class WordPressUserInfo:
    wp_user_id: str          # ID numerik WordPress
    username: str            # user_login (atau slug)
    email: str               # email (di-lowercase)
    display_name: str        # nama tampilan
    roles: list[str]         # role WP: ["administrator"], ["subscriber"], dll
    raw_data: dict[str, Any] # response mentah WP (untuk audit)

SyncStats

Hasil sinkronisasi batch:

python
@dataclass
class SyncStats:
    inserted: int    # user baru dibuat
    updated: int     # user existing di-update
    total: int       # inserted + updated
    errors: int      # user gagal sync (skip)

Normalisasi Identitas

Tiga helper untuk menjaga konsistensi identitas user:

python
def clean_optional_string(value: Any) -> Optional[str]:
    """Strip whitespace, return None kalau empty string."""
    if value is None:
        return None
    cleaned = str(value).strip()
    return cleaned or None

def normalize_email(value: Any) -> Optional[str]:
    """Email wajib lowercase untuk konsistensi search."""
    cleaned = clean_optional_string(value)
    return cleaned.lower() if cleaned else None

def extract_wordpress_identity(raw_user: dict) -> dict:
    """
    Extract field identitas dari response WP API.
    WordPress kadang pakai 'name', kadang 'display_name'.
    Username bisa di 'username', 'slug', atau 'user_login'.
    """
    return {
        "email": normalize_email(raw_user.get("email")),
        "display_name": clean_optional_string(
            raw_user.get("name") or raw_user.get("display_name")
        ),
        "username": clean_optional_string(
            raw_user.get("username") or raw_user.get("slug") or raw_user.get("user_login")
        ),
    }

Kenapa normalisasi penting?

  1. Email lowercase — hindari duplikat user akibat case beda ([email protected] vs [email protected])
  2. Strip whitespace — hindari user berbeda karena typo spasi
  3. Multi-field fallback — WordPress API beda versi punya schema response beda

Sinkronisasi User

python
async def sync_wordpress_users(
    website_id: int,
    admin_token: str,
    db: AsyncSession,
) -> SyncStats:
    """
    Sync SEMUA user dari WordPress ke DB lokal (upsert).
    Dipanggil admin secara berkala.
    """

Algoritma Upsert

  1. Fetch existing users dari DB lokal → cache di dict {wp_user_id: User}
  2. Paginate fetch dari WP API (per_page=100):
    • GET /wp-json/wp/v2/users?page=1&per_page=100&context=edit
  3. Untuk setiap WP user:
    • Existingapply_user_identity() update field yang berubah
    • New → buat User baru dengan identitas dari WP
  4. Commit transaction
  5. Return SyncStats(inserted, updated, errors)

Strategi Anti-Duplikat

  • Unique constraint: (wp_user_id, website_id) — tidak bisa ada user sama di website sama
  • Identitas di-update via apply_user_identity() yang hanya tulis field yang berubah (return changed: bool)

Role Mapping

WordPress roles dipetakan ke API roles internal:

python
def _api_role_from_wordpress_roles(roles: list[str]) -> str:
    normalized = {str(r).strip().lower() for r in roles}
    if normalized & {"super_admin", "system_admin"}:
        return "system_admin"
    if normalized & {"administrator", "admin"}:
        return "admin"
    return "student"

Dipakai oleh route authorization untuk menentukan hak akses endpoint (mis. /ai/generate butuh role admin).

Helper Functions

FungsiTujuan
get_wordpress_api_base(website)Build URL: {site_url}/wp-json
verify_wordpress_token(token, ...)Verifikasi JWT, return WordPressUserInfo
fetch_wordpress_users(website, admin_token, page)Paginate fetch users (admin)
sync_wordpress_users(website_id, admin_token, db)Batch upsert
get_wordpress_user(wp_user_id, website_id, db)Get single user dari DB lokal
verify_website_exists(website_id, db)Validasi website ada di DB
get_or_create_user(wp_user_id, website_id, db, ...)Upsert single user

Configuration

Di app/core/config.py (Settings):

SettingDefaultArti
WordPress REST endpoint{site_url}/wp-jsonOtomatis dari Website.site_url
Timeout verifikasi10s (connect 5s)httpx.Timeout(10.0, connect=5.0)
Timeout sync batch30s (connect 10s)httpx.Timeout(30.0, connect=10.0)
per_page sync100 (max WP)Hardcoded

Security Considerations

  • Token tidak disimpan di DB — hanya dipakai untuk satu request verifikasi
  • App access token hasil verifikasi dipakai sebagai Authorization: Bearer untuk endpoint /session/*
  • Admin token untuk sync_users harus punya kapabilitas list_users di WP
  • CORS WordPress REST API harus whitelist URL app
  • SSL wajib di production (site_url harus https://)

Edge Cases

KasusPenanganan
Token expiredWP return 401 → WordPressTokenInvalidError
wp_user_id beda dengan token ownerReturn None (silent reject, log warning)
website_id tidak adaRaise WebsiteNotFoundError
WP API unreachableConnectErrorWordPressAPIError
WP rate limit (429)Raise WordPressRateLimitError (no auto-retry)
Response WP bukan JSONjson() raise → WordPressAPIError
User sync: field WP kosongclean_optional_string return None, user tetap di-insert
User sync: wp_user_id kosongSkip user, increment errors counter

Dependency

  • httpx async HTTP client
  • sqlalchemy async session
  • Model: Website, User

Bacaan Lanjutan

Last updated Jul 25, 2026