mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-04 22:55:18 -05:00
feat(accounts): add public profiles list endpoint with search and pagination
- Add new `/profiles/` endpoint for listing user profiles with search, filtering, and pagination support - Implement list_profiles view with OpenAPI documentation for user discovery and leaderboards - Refactor WebAuthn authentication state management to simplify begin_authentication flow - Update MFA passkey login to store user reference instead of full state in cache This enables public profile browsing and improves the passkey authentication implementation by leveraging allauth's internal session management.
This commit is contained in:
254
backend/apps/api/v1/rides/ride_model_views.py
Normal file
254
backend/apps/api/v1/rides/ride_model_views.py
Normal file
@@ -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)
|
||||
635
docs/allauth_integration_guide.md
Normal file
635
docs/allauth_integration_guide.md
Normal file
@@ -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 <access_token>` 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: <session-token>
|
||||
```
|
||||
|
||||
After authentication completes with JWT enabled:
|
||||
```
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user