Files
thrillwiki_django_no_react/docs/allauth_integration_guide.md
pacnpal 22ff0d1c49 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.
2026-01-10 12:59:39 -05:00

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