diff --git a/backend/igny8_core/api/authentication.py b/backend/igny8_core/api/authentication.py index 56f8a6f8..d010ef01 100644 --- a/backend/igny8_core/api/authentication.py +++ b/backend/igny8_core/api/authentication.py @@ -83,3 +83,60 @@ class JWTAuthentication(BaseAuthentication): # This allows session authentication to work if JWT fails return None + +class APIKeyAuthentication(BaseAuthentication): + """ + API Key authentication for WordPress integration. + Validates API keys stored in Site.wp_api_key field. + """ + def authenticate(self, request): + """ + Authenticate using WordPress API key. + Returns (user, api_key) tuple if valid. + """ + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + + if not auth_header.startswith('Bearer '): + return None # Not an API key request + + api_key = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else None + if not api_key or len(api_key) < 20: # API keys should be at least 20 chars + return None + + # Don't try to authenticate JWT tokens (they start with 'ey') + if api_key.startswith('ey'): + return None # Let JWTAuthentication handle it + + try: + from igny8_core.auth.models import Site, User + + # Find site by API key + site = Site.objects.select_related('account', 'account__owner').filter( + wp_api_key=api_key, + is_active=True + ).first() + + if not site: + return None # API key not found or site inactive + + # Get account and user + account = site.account + user = account.owner # Use account owner as the authenticated user + + if not user.is_active: + raise AuthenticationFailed('User account is disabled.') + + # Set account on request for tenant isolation + request.account = account + + # Set site on request for WordPress integration context + request.site = site + + return (user, api_key) + + except Exception as e: + # Log the error but return None to allow other auth classes to try + import logging + logger = logging.getLogger(__name__) + logger.debug(f'APIKeyAuthentication error: {str(e)}') + return None diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index 0f134bf2..0abff5c8 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -105,11 +105,66 @@ class SectorInline(admin.TabularInline): @admin.register(Site) class SiteAdmin(AccountAdminMixin, admin.ModelAdmin): - list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_sectors_count'] - list_filter = ['status', 'is_active', 'account', 'industry'] + list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_api_key_status', 'get_sectors_count'] + list_filter = ['status', 'is_active', 'account', 'industry', 'hosting_type'] search_fields = ['name', 'slug', 'domain', 'industry__name'] - readonly_fields = ['created_at', 'updated_at'] + readonly_fields = ['created_at', 'updated_at', 'get_api_key_display'] inlines = [SectorInline] + actions = ['generate_api_keys'] + + fieldsets = ( + ('Site Info', { + 'fields': ('name', 'slug', 'account', 'domain', 'description', 'industry', 'site_type', 'hosting_type', 'status', 'is_active') + }), + ('WordPress Integration', { + 'fields': ('wp_url', 'wp_username', 'wp_app_password', 'get_api_key_display'), + 'description': 'Legacy WordPress integration fields. For WordPress sites using the IGNY8 WP Bridge plugin.' + }), + ('SEO Metadata', { + 'fields': ('seo_metadata',), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def get_api_key_display(self, obj): + """Display API key with copy button""" + if obj.wp_api_key: + from django.utils.html import format_html + return format_html( + '
' + '{}' + '' + '
', + obj.wp_api_key, + obj.wp_api_key + ) + return format_html('No API key generated') + get_api_key_display.short_description = 'WordPress API Key' + + def get_api_key_status(self, obj): + """Show API key status in list view""" + if obj.wp_api_key: + from django.utils.html import format_html + return format_html(' Active') + return format_html(' None') + get_api_key_status.short_description = 'API Key' + + def generate_api_keys(self, request, queryset): + """Generate API keys for selected sites""" + import secrets + updated_count = 0 + for site in queryset: + if not site.wp_api_key: + site.wp_api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}" + site.save() + updated_count += 1 + self.message_user(request, f'Generated API keys for {updated_count} site(s). Sites with existing keys were skipped.') + generate_api_keys.short_description = 'Generate WordPress API Keys' def get_sectors_count(self, obj): try: diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 55fea18e..8f331119 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -219,6 +219,7 @@ REST_FRAMEWORK = { 'rest_framework.permissions.AllowAny', # Allow unauthenticated access for now ], 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'igny8_core.api.authentication.APIKeyAuthentication', # WordPress API key authentication (check first) 'igny8_core.api.authentication.JWTAuthentication', # JWT token authentication 'igny8_core.api.authentication.CSRFExemptSessionAuthentication', # Session auth without CSRF for API 'rest_framework.authentication.BasicAuthentication', # Enable basic auth as fallback