Files
thrillwiki_django_no_react/docs/allauth_integration_guide.md
pacnpal 2b66814d82 Based on the git diff provided, here's a concise and descriptive commit message:
feat: add security event taxonomy and optimize park queryset

- Add comprehensive security_event_types ChoiceGroup with categories for authentication, MFA, password, account, session, and API key events
- Include severity levels, icons, and CSS classes for each event type
- Fix park queryset optimization by using select_related for OneToOne location relationship
- Remove location property fields (latitude/longitude) from values() call as they are not actual DB columns
- Add proper location fields (city, state, country) to values() for map display

This change enhances security event tracking capabilities and resolves a queryset optimization issue where property decorators were incorrectly used in values() queries.
2026-01-10 16:41:31 -05:00

20 KiB

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
  2. JWT Token Authentication
  3. Password Authentication
  4. MFA: TOTP (Authenticator App)
  5. MFA: WebAuthn/Passkeys
  6. Social OAuth: Google
  7. Social OAuth: Discord
  8. API Patterns & DRF Integration
  9. Internal API Reference

Installation & Setup

Required Packages

# 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

# 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

# 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

MIDDLEWARE = [
    # ... other middleware
    "allauth.account.middleware.AccountMiddleware",
]

URL Configuration

# 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

# 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

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:
    {
      "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

# 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)
# 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

# 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

# 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:

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

# 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
  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

# 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
  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

# 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

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

# 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

# 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

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 uses a hybrid authentication system with django-allauth for MFA and social auth, and SimpleJWT for API tokens.

Backend Configuration

Feature Status Notes
Password Auth Active Email + username login
Email Verification Mandatory With resend support
TOTP MFA Active 6-digit codes, 30s period
WebAuthn/Passkeys Active Passkey login enabled, counts as MFA
Google OAuth Configured Requires admin SocialApp setup
Discord OAuth Configured Requires admin SocialApp setup
Magic Link Configured 5-minute timeout
JWT Tokens SimpleJWT 15min access, 7 day refresh

Frontend MFA Integration (Updated 2026-01-10)

The frontend recognizes both TOTP and Passkeys as valid MFA factors:

// authService.ts - getEnrolledFactors()
// Checks both Supabase TOTP AND Django passkeys
const mfaStatus = await djangoClient.rpc('get_mfa_status', {});
if (statusData.passkey_enabled && statusData.passkey_count > 0) {
  factors.push({ id: 'passkey', factor_type: 'webauthn', ... });
}

Admin Panel MFA Requirements

Admins and moderators must have MFA enabled to access protected routes:

  1. useAdminGuard() hook checks MFA enrollment via useRequireMFA()
  2. getEnrolledFactors() queries Django's get_mfa_status endpoint
  3. Backend returns has_second_factor: true if TOTP or Passkey is enabled
  4. Users with only passkeys (no TOTP) now pass the MFA requirement

API Endpoints Reference

Authentication

Endpoint Method Description
/api/v1/auth/login/ POST Password login
/api/v1/auth/login/mfa-verify/ POST Complete MFA (TOTP or Passkey)
/api/v1/auth/signup/ POST Register new account
/api/v1/auth/logout/ POST Logout, blacklist tokens
/api/v1/auth/token/refresh/ POST Refresh JWT access token

MFA (TOTP)

Endpoint Method Description
/api/v1/auth/mfa/status/ GET Get MFA status (TOTP + Passkey)
/api/v1/auth/mfa/totp/setup/ POST Start TOTP enrollment
/api/v1/auth/mfa/totp/activate/ POST Activate with 6-digit code
/api/v1/auth/mfa/totp/deactivate/ POST Remove TOTP (requires password)

Passkeys (WebAuthn)

Endpoint Method Description
/api/v1/auth/passkey/status/ GET List registered passkeys
/api/v1/auth/passkey/registration-options/ GET Get WebAuthn creation options
/api/v1/auth/passkey/register/ POST Complete passkey registration
/api/v1/auth/passkey/login-options/ POST Get auth options (uses mfa_token)
/api/v1/auth/passkey/{id}/ DELETE Remove passkey

Social Authentication

Endpoint Method Description
/api/v1/auth/social/providers/ GET List configured providers
/api/v1/auth/social/connect/{provider}/ POST Start OAuth flow
/api/v1/auth/social/disconnect/{provider}/ POST Unlink provider

Login Flow with MFA

1. POST /api/v1/auth/login/ {username, password}
   └── If MFA enabled: Returns {mfa_required: true, mfa_token, mfa_types: ["totp", "webauthn"]}

2a. TOTP Verification:
    POST /api/v1/auth/login/mfa-verify/ {mfa_token, code: "123456"}
    
2b. Passkey Verification:
    POST /api/v1/auth/passkey/login-options/ {mfa_token}  ← Get challenge
    Browser: navigator.credentials.get()                   ← User authenticates
    POST /api/v1/auth/login/mfa-verify/ {mfa_token, credential: {...}}

3. Returns: {access, refresh, user, message: "Login successful"}

Frontend Components

Component Purpose
MFAChallenge.tsx TOTP code entry during login
MFAEnrollmentRequired.tsx Prompts admin/mod to set up MFA
MFAGuard.tsx Wraps routes requiring MFA
useRequireMFA Hook checking MFA enrollment
useAdminGuard Combines auth + role + MFA checks

Admin MFA Requirements

Moderators, admins, and superusers must have MFA enrolled to access admin pages.

The system uses enrollment-based verification rather than per-session AAL2 tokens:

  • MFA verification happens at login time via the mfa_token flow
  • Django-allauth doesn't embed AAL claims in JWT tokens
  • The frontend checks if the user has TOTP or passkey enrolled
  • Mid-session MFA step-up is not supported (user must re-login)

This means:

  • useRequireMFA returns hasMFA: true if user has any factor enrolled
  • useAdminGuard blocks access if needsEnrollment is true
  • Users prompted to enroll MFA on their first admin page visit