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

726 lines
20 KiB
Markdown

# 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 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:
```typescript
// 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