diff --git a/backend/apps/api/v1/rides/ride_model_views.py b/backend/apps/api/v1/rides/ride_model_views.py new file mode 100644 index 00000000..76b89408 --- /dev/null +++ b/backend/apps/api/v1/rides/ride_model_views.py @@ -0,0 +1,254 @@ +""" +Global Ride Model views for ThrillWiki API v1. + +This module provides top-level ride model endpoints that don't require +manufacturer context, matching the frontend's expectation of /rides/models/. +""" + +from django.db.models import Q +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import permissions, status +from rest_framework.pagination import PageNumberPagination +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +# Import serializers +from apps.api.v1.serializers.ride_models import ( + RideModelDetailOutputSerializer, + RideModelListOutputSerializer, +) + +# Attempt to import models +try: + from apps.rides.models import RideModel + from apps.rides.models.company import Company + + MODELS_AVAILABLE = True +except ImportError: + try: + from apps.rides.models.rides import Company, RideModel + + MODELS_AVAILABLE = True + except ImportError: + RideModel = None + Company = None + MODELS_AVAILABLE = False + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + +class GlobalRideModelListAPIView(APIView): + """ + Global ride model list endpoint. + + This endpoint provides a top-level list of all ride models without + requiring a manufacturer slug, matching the frontend's expectation + of calling /rides/models/ directly. + """ + + permission_classes = [permissions.AllowAny] + + @extend_schema( + summary="List all ride models with filtering and pagination", + description=( + "List all ride models across all manufacturers with comprehensive " + "filtering and pagination support. This is a global endpoint that " + "doesn't require manufacturer context." + ), + parameters=[ + OpenApiParameter( + name="page", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Page number for pagination", + ), + OpenApiParameter( + name="page_size", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Number of results per page (max 100)", + ), + OpenApiParameter( + name="search", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="Search term for name, description, or manufacturer", + ), + OpenApiParameter( + name="category", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="Filter by category (e.g., RC, DR, FR, WR)", + ), + OpenApiParameter( + name="manufacturer", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="Filter by manufacturer slug", + ), + OpenApiParameter( + name="target_market", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="Filter by target market (e.g., FAMILY, THRILL)", + ), + OpenApiParameter( + name="is_discontinued", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description="Filter by discontinued status", + ), + OpenApiParameter( + name="ordering", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="Order by field: name, -name, manufacturer__name, etc.", + ), + ], + responses={200: RideModelListOutputSerializer(many=True)}, + tags=["Ride Models"], + ) + def get(self, request: Request) -> Response: + """List all ride models with filtering and pagination.""" + if not MODELS_AVAILABLE: + return Response( + { + "count": 0, + "next": None, + "previous": None, + "results": [], + "detail": "Ride model listing is not available.", + }, + status=status.HTTP_200_OK, + ) + + # Base queryset with eager loading + qs = RideModel.objects.select_related("manufacturer").prefetch_related( + "photos" + ).order_by("manufacturer__name", "name") + + # Search filter + search = request.query_params.get("search", "").strip() + if search: + qs = qs.filter( + Q(name__icontains=search) + | Q(description__icontains=search) + | Q(manufacturer__name__icontains=search) + ) + + # Category filter + category = request.query_params.get("category", "").strip() + if category: + # Support comma-separated categories + categories = [c.strip() for c in category.split(",") if c.strip()] + if categories: + qs = qs.filter(category__in=categories) + + # Manufacturer filter + manufacturer = request.query_params.get("manufacturer", "").strip() + if manufacturer: + qs = qs.filter(manufacturer__slug=manufacturer) + + # Target market filter + target_market = request.query_params.get("target_market", "").strip() + if target_market: + markets = [m.strip() for m in target_market.split(",") if m.strip()] + if markets: + qs = qs.filter(target_market__in=markets) + + # Discontinued filter + is_discontinued = request.query_params.get("is_discontinued") + if is_discontinued is not None: + qs = qs.filter(is_discontinued=is_discontinued.lower() == "true") + + # Ordering + ordering = request.query_params.get("ordering", "manufacturer__name,name") + valid_orderings = [ + "name", "-name", + "manufacturer__name", "-manufacturer__name", + "first_installation_year", "-first_installation_year", + "total_installations", "-total_installations", + "created_at", "-created_at", + ] + if ordering: + order_fields = [ + f.strip() for f in ordering.split(",") + if f.strip() in valid_orderings or f.strip().lstrip("-") in [ + o.lstrip("-") for o in valid_orderings + ] + ] + if order_fields: + qs = qs.order_by(*order_fields) + + # Paginate + paginator = StandardResultsSetPagination() + page = paginator.paginate_queryset(qs, request) + + if page is not None: + serializer = RideModelListOutputSerializer( + page, many=True, context={"request": request} + ) + return paginator.get_paginated_response(serializer.data) + + # Fallback without pagination + serializer = RideModelListOutputSerializer( + qs[:100], many=True, context={"request": request} + ) + return Response(serializer.data) + + +class GlobalRideModelDetailAPIView(APIView): + """ + Global ride model detail endpoint by ID or slug. + + This endpoint provides detail for a single ride model without + requiring manufacturer context. + """ + + permission_classes = [permissions.AllowAny] + + @extend_schema( + summary="Retrieve a ride model by ID", + description="Get detailed information about a specific ride model by its ID.", + parameters=[ + OpenApiParameter( + name="pk", + location=OpenApiParameter.PATH, + type=OpenApiTypes.INT, + required=True, + description="Ride model ID", + ), + ], + responses={200: RideModelDetailOutputSerializer()}, + tags=["Ride Models"], + ) + def get(self, request: Request, pk: int) -> Response: + """Get ride model detail by ID.""" + if not MODELS_AVAILABLE: + return Response( + {"detail": "Ride model not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + try: + ride_model = ( + RideModel.objects.select_related("manufacturer") + .prefetch_related("photos", "variants", "technical_specs") + .get(pk=pk) + ) + except RideModel.DoesNotExist: + return Response( + {"detail": "Ride model not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = RideModelDetailOutputSerializer( + ride_model, context={"request": request} + ) + return Response(serializer.data) diff --git a/docs/allauth_integration_guide.md b/docs/allauth_integration_guide.md new file mode 100644 index 00000000..73a53569 --- /dev/null +++ b/docs/allauth_integration_guide.md @@ -0,0 +1,635 @@ +# Django-Allauth Integration Guide for ThrillWiki + +This guide documents how to properly integrate django-allauth for authentication in ThrillWiki, covering JWT tokens, password authentication, MFA (TOTP/WebAuthn), and social OAuth (Google/Discord). + +--- + +## Table of Contents + +1. [Installation & Setup](#installation--setup) +2. [JWT Token Authentication](#jwt-token-authentication) +3. [Password Authentication](#password-authentication) +4. [MFA: TOTP (Authenticator App)](#mfa-totp-authenticator-app) +5. [MFA: WebAuthn/Passkeys](#mfa-webauthnpasskeys) +6. [Social OAuth: Google](#social-oauth-google) +7. [Social OAuth: Discord](#social-oauth-discord) +8. [API Patterns & DRF Integration](#api-patterns--drf-integration) +9. [Internal API Reference](#internal-api-reference) + +--- + +## Installation & Setup + +### Required Packages + +```bash +# Add packages to pyproject.toml +uv add "django-allauth[headless,mfa,socialaccount]" +uv add fido2 # For WebAuthn support + +# Install all dependencies +uv sync +``` + +### Running Django Commands + +```bash +# Run migrations +uv run manage.py migrate + +# Create superuser +uv run manage.py createsuperuser + +# Run development server +uv run manage.py runserver + +# Collect static files +uv run manage.py collectstatic +``` + +### INSTALLED_APPS Configuration + +```python +# config/django/base.py +INSTALLED_APPS = [ + # Django built-in + "django.contrib.auth", + "django.contrib.sites", + + # Allauth core (required) + "allauth", + "allauth.account", + + # Optional modules + "allauth.headless", # For headless/API mode + "allauth.mfa", # MFA support (TOTP, recovery codes) + "allauth.mfa.webauthn", # WebAuthn/Passkey support + "allauth.socialaccount", # Social auth base + "allauth.socialaccount.providers.google", + "allauth.socialaccount.providers.discord", +] +``` + +### Middleware + +```python +MIDDLEWARE = [ + # ... other middleware + "allauth.account.middleware.AccountMiddleware", +] +``` + +### URL Configuration + +```python +# urls.py +urlpatterns = [ + # Allauth browser views (needed for OAuth callbacks) + path("accounts/", include("allauth.urls")), + + # Allauth headless API endpoints + path("_allauth/", include("allauth.headless.urls")), +] +``` + +--- + +## JWT Token Authentication + +### Configuration + +```python +# settings.py + +# Token strategy - use JWT +HEADLESS_TOKEN_STRATEGY = "allauth.headless.tokens.JWTTokenStrategy" + +# Generate private key: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 +HEADLESS_JWT_PRIVATE_KEY = """ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASC... +-----END PRIVATE KEY----- +""" + +# Token lifetimes +HEADLESS_JWT_ACCESS_TOKEN_EXPIRES_IN = 300 # 5 minutes +HEADLESS_JWT_REFRESH_TOKEN_EXPIRES_IN = 86400 # 24 hours + +# Authorization header scheme +HEADLESS_JWT_AUTHORIZATION_HEADER_SCHEME = "Bearer" + +# Stateful validation (invalidates tokens on logout) +HEADLESS_JWT_STATEFUL_VALIDATION_ENABLED = True + +# Rotate refresh tokens on use +HEADLESS_JWT_ROTATE_REFRESH_TOKEN = True +``` + +### DRF Integration + +```python +from allauth.headless.contrib.rest_framework.authentication import JWTTokenAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +class ProtectedAPIView(APIView): + authentication_classes = [JWTTokenAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + return Response({"user": request.user.email}) +``` + +### JWT Flow + +1. User authenticates (password/social/passkey) +2. During auth, pass `X-Session-Token` header to allauth API +3. Upon successful authentication, response `meta` contains: + ```json + { + "meta": { + "access_token": "eyJ...", + "refresh_token": "abc123..." + } + } + ``` +4. Use `Authorization: Bearer ` for subsequent requests +5. Refresh tokens via `POST /_allauth/browser/v1/auth/token/refresh` + +--- + +## Password Authentication + +### Configuration + +```python +# config/settings/third_party.py + +# Signup fields (* = required) +ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"] + +# Login methods +ACCOUNT_LOGIN_METHODS = {"email", "username"} # Allow both + +# Email verification +ACCOUNT_EMAIL_VERIFICATION = "mandatory" # Options: "mandatory", "optional", "none" +ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True +ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_RESEND = True + +# Security +ACCOUNT_REAUTHENTICATION_REQUIRED = True # Require re-auth for sensitive operations +ACCOUNT_EMAIL_NOTIFICATIONS = True +ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False # Don't reveal if email exists + +# Redirects +LOGIN_REDIRECT_URL = "/" +ACCOUNT_LOGOUT_REDIRECT_URL = "/" + +# Custom adapters +ACCOUNT_ADAPTER = "apps.accounts.adapters.CustomAccountAdapter" +``` + +### Headless API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/_allauth/browser/v1/auth/login` | POST | Login with email/username + password | +| `/_allauth/browser/v1/auth/signup` | POST | Register new account | +| `/_allauth/browser/v1/auth/logout` | POST | Logout (invalidate tokens) | +| `/_allauth/browser/v1/auth/password/reset` | POST | Request password reset | +| `/_allauth/browser/v1/auth/password/reset/key` | POST | Complete password reset | +| `/_allauth/browser/v1/auth/password/change` | POST | Change password (authenticated) | + +### Magic Link (Login by Code) + +```python +# Enable magic link authentication +ACCOUNT_LOGIN_BY_CODE_ENABLED = True +ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 3 +ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 300 # 5 minutes +``` + +--- + +## MFA: TOTP (Authenticator App) + +### Configuration + +```python +# config/settings/third_party.py + +# Enable TOTP in supported types +MFA_SUPPORTED_TYPES = ["totp", "webauthn"] + +# TOTP settings +MFA_TOTP_ISSUER = "ThrillWiki" # Shows in authenticator app +MFA_TOTP_DIGITS = 6 # Code length +MFA_TOTP_PERIOD = 30 # Seconds per code +``` + +### TOTP API Flow + +1. **Get TOTP Secret** (for QR code): + ``` + GET /_allauth/browser/v1/account/authenticators/totp + ``` + Response contains `totp_url` for QR code generation. + +2. **Activate TOTP**: + ``` + POST /_allauth/browser/v1/account/authenticators/totp + { + "code": "123456" + } + ``` + +3. **Deactivate TOTP**: + ``` + DELETE /_allauth/browser/v1/account/authenticators/totp + ``` + +4. **MFA Login Flow**: + - After password auth, if MFA enabled, receive `401` with `mfa_required` + - Submit TOTP code: + ``` + POST /_allauth/browser/v1/auth/2fa/authenticate + { + "code": "123456" + } + ``` + +--- + +## MFA: WebAuthn/Passkeys + +### Configuration + +```python +# config/settings/third_party.py + +# Include webauthn in supported types +MFA_SUPPORTED_TYPES = ["totp", "webauthn"] + +# Enable passkey-only login +MFA_PASSKEY_LOGIN_ENABLED = True + +# Allow insecure origin for localhost development +MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True # Only for DEBUG=True +``` + +### Internal WebAuthn API Functions + +The `allauth.mfa.webauthn.internal.auth` module provides these functions: + +```python +from allauth.mfa.webauthn.internal import auth as webauthn_auth + +# Registration Flow +def begin_registration(user, passwordless: bool) -> Dict: + """ + Start passkey registration. + + Args: + user: The Django user object + passwordless: True for passkey login, False for MFA-only + + Returns: + Dict with WebAuthn creation options (challenge, rp, user, etc.) + + Note: State is stored internally via set_state() + """ + +def complete_registration(credential: Dict) -> AuthenticatorData: + """ + Complete passkey registration. + + Args: + credential: The parsed credential response from browser + + Returns: + AuthenticatorData (binding) - NOT an Authenticator model + + Note: You must create the Authenticator record yourself! + """ + +# Authentication Flow +def begin_authentication(user=None) -> Dict: + """ + Start passkey authentication. + + Args: + user: Optional user (for MFA). None for passwordless login. + + Returns: + Dict with WebAuthn request options + + Note: State is stored internally via set_state() + """ + +def complete_authentication(user, response: Dict) -> Authenticator: + """ + Complete passkey authentication. + + Args: + user: The Django user object + response: The credential response from browser + + Returns: + The matching Authenticator model instance + """ + +# State Management (internal, use context) +def get_state() -> Optional[Dict]: + """Get stored WebAuthn state from session.""" + +def set_state(state: Dict) -> None: + """Store WebAuthn state in session.""" + +def clear_state() -> None: + """Clear WebAuthn state from session.""" + +# Helper functions +def parse_registration_response(response: Any) -> RegistrationResponse: + """Parse browser registration response.""" + +def parse_authentication_response(response: Any) -> AuthenticationResponse: + """Parse browser authentication response.""" +``` + +### Custom Passkey API Implementation + +```python +# apps/api/v1/auth/passkey.py + +from allauth.mfa.webauthn.internal import auth as webauthn_auth +from allauth.mfa.models import Authenticator + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_registration_options(request): + """Get WebAuthn registration options.""" + # passwordless=False for MFA passkeys, True for passwordless login + creation_options = webauthn_auth.begin_registration( + request.user, + passwordless=False + ) + # State is stored internally + return Response({"options": creation_options}) + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def register_passkey(request): + """Complete passkey registration.""" + credential = request.data.get("credential") + name = request.data.get("name", "Passkey") + + # Check for pending registration + state = webauthn_auth.get_state() + if not state: + return Response({"error": "No pending registration"}, status=400) + + # Parse and complete registration + credential_data = webauthn_auth.parse_registration_response(credential) + authenticator_data = webauthn_auth.complete_registration(credential_data) + + # Create Authenticator record manually + authenticator = Authenticator.objects.create( + user=request.user, + type=Authenticator.Type.WEBAUTHN, + data={"name": name}, + ) + + return Response({"id": str(authenticator.id), "name": name}) + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_authentication_options(request): + """Get WebAuthn authentication options.""" + request_options = webauthn_auth.begin_authentication(request.user) + return Response({"options": request_options}) + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def authenticate_passkey(request): + """Verify passkey authentication.""" + credential = request.data.get("credential") + + state = webauthn_auth.get_state() + if not state: + return Response({"error": "No pending authentication"}, status=400) + + # Complete authentication (handles state internally) + webauthn_auth.complete_authentication(request.user, credential) + + return Response({"success": True}) +``` + +--- + +## Social OAuth: Google + +### Google Cloud Console Setup + +1. Go to [Google Cloud Console](https://console.developers.google.com/) +2. Create/select project → APIs & Services → Credentials +3. Create OAuth 2.0 Client ID (Web application) +4. Set Authorized JavaScript origins: + - `http://localhost:3000` (development) + - `https://thrillwiki.com` (production) +5. Set Authorized redirect URIs: + - `http://localhost:8000/accounts/google/login/callback/` + - `https://api.thrillwiki.com/accounts/google/login/callback/` +6. Note the Client ID and Client Secret + +### Django Configuration + +```python +# config/settings/third_party.py + +SOCIALACCOUNT_PROVIDERS = { + "google": { + "SCOPE": ["profile", "email"], + "AUTH_PARAMS": {"access_type": "online"}, # Use "offline" for refresh tokens + "OAUTH_PKCE_ENABLED": True, + # "FETCH_USERINFO": True, # If you need avatar_url for private profiles + }, +} +``` + +### Admin Setup + +1. Go to `/admin/socialaccount/socialapp/` +2. Add new Social Application: + - Provider: Google + - Name: Google + - Client ID: (from Google Console) + - Secret key: (from Google Console) + - Sites: Select your site + +--- + +## Social OAuth: Discord + +### Discord Developer Portal Setup + +1. Go to [Discord Developer Portal](https://discordapp.com/developers/applications/me) +2. Create New Application +3. Go to OAuth2 → General +4. Add Redirect URIs: + - `http://127.0.0.1:8000/accounts/discord/login/callback/` + - `https://api.thrillwiki.com/accounts/discord/login/callback/` +5. Note Client ID and Client Secret + +### Django Configuration + +```python +# config/settings/third_party.py + +SOCIALACCOUNT_PROVIDERS = { + "discord": { + "SCOPE": ["identify", "email"], # "identify" is required + "OAUTH_PKCE_ENABLED": True, + }, +} +``` + +### Admin Setup + +1. Go to `/admin/socialaccount/socialapp/` +2. Add new Social Application: + - Provider: Discord + - Name: Discord + - Client ID: (from Discord Portal) + - Secret key: (from Discord Portal) + - Sites: Select your site + +--- + +## API Patterns & DRF Integration + +### Authentication Classes + +```python +from allauth.headless.contrib.rest_framework.authentication import ( + JWTTokenAuthentication, + SessionTokenAuthentication, +) + +# For JWT-based authentication +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "allauth.headless.contrib.rest_framework.authentication.JWTTokenAuthentication", + ], +} +``` + +### Headless Frontend URLs + +```python +# Required for email verification, password reset links +HEADLESS_FRONTEND_URLS = { + "account_confirm_email": "https://thrillwiki.com/account/verify-email/{key}", + "account_reset_password_from_key": "https://thrillwiki.com/account/password/reset/{key}", + "account_signup": "https://thrillwiki.com/signup", +} +``` + +### Custom Adapters + +```python +# apps/accounts/adapters.py +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter + +class CustomAccountAdapter(DefaultAccountAdapter): + def save_user(self, request, user, form, commit=True): + """Customize user creation.""" + user = super().save_user(request, user, form, commit=False) + # Custom logic here + if commit: + user.save() + return user + + def is_open_for_signup(self, request): + """Control signup availability.""" + return True + +class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): + def pre_social_login(self, request, sociallogin): + """Hook before social login completes.""" + # Link social account to existing user by email + if sociallogin.is_existing: + return + + email = sociallogin.account.extra_data.get("email") + if email: + User = get_user_model() + try: + user = User.objects.get(email=email) + sociallogin.connect(request, user) + except User.DoesNotExist: + pass +``` + +--- + +## Internal API Reference + +### Authenticator Model + +```python +from allauth.mfa.models import Authenticator + +# Types +Authenticator.Type.TOTP # TOTP authenticator +Authenticator.Type.WEBAUTHN # WebAuthn/Passkey +Authenticator.Type.RECOVERY_CODES # Recovery codes + +# Query user's authenticators +passkeys = Authenticator.objects.filter( + user=user, + type=Authenticator.Type.WEBAUTHN +) + +# Check if MFA is enabled +from allauth.mfa.adapter import get_adapter +is_mfa_enabled = get_adapter().is_mfa_enabled(user) +``` + +### Session Token Header + +For headless mode during authentication flow: +``` +X-Session-Token: +``` + +After authentication completes with JWT enabled: +``` +Authorization: Bearer +``` + +--- + +## Current ThrillWiki Implementation Summary + +ThrillWiki already has these allauth features configured: + +| Feature | Status | Notes | +|---------|--------|-------| +| Password Auth | ✅ Configured | Email + username login | +| Email Verification | ✅ Mandatory | With resend support | +| TOTP MFA | ✅ Configured | 6-digit codes, 30s period | +| WebAuthn/Passkeys | ✅ Configured | Passkey login enabled | +| Google OAuth | ✅ Configured | Needs admin SocialApp | +| Discord OAuth | ✅ Configured | Needs admin SocialApp | +| Magic Link | ✅ Configured | 5-minute timeout | +| JWT Tokens | ❌ Not configured | Using SimpleJWT instead | + +### Recommendation + +To use allauth's native JWT support instead of SimpleJWT: + +1. Add `"allauth.headless"` to INSTALLED_APPS +2. Configure `HEADLESS_TOKEN_STRATEGY` and JWT settings +3. Replace `rest_framework_simplejwt` authentication with `JWTTokenAuthentication` +4. Add `/_allauth/` URL routes