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.
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
- Installation & Setup
- JWT Token Authentication
- Password Authentication
- MFA: TOTP (Authenticator App)
- MFA: WebAuthn/Passkeys
- Social OAuth: Google
- Social OAuth: Discord
- API Patterns & DRF Integration
- 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
- User authenticates (password/social/passkey)
- During auth, pass
X-Session-Tokenheader to allauth API - Upon successful authentication, response
metacontains:{ "meta": { "access_token": "eyJ...", "refresh_token": "abc123..." } } - Use
Authorization: Bearer <access_token>for subsequent requests - 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) |
Magic Link (Login by Code)
# 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
-
Get TOTP Secret (for QR code):
GET /_allauth/browser/v1/account/authenticators/totpResponse contains
totp_urlfor QR code generation. -
Activate TOTP:
POST /_allauth/browser/v1/account/authenticators/totp { "code": "123456" } -
Deactivate TOTP:
DELETE /_allauth/browser/v1/account/authenticators/totp -
MFA Login Flow:
- After password auth, if MFA enabled, receive
401withmfa_required - Submit TOTP code:
POST /_allauth/browser/v1/auth/2fa/authenticate { "code": "123456" }
- After password auth, if MFA enabled, receive
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
- Go to Google Cloud Console
- Create/select project → APIs & Services → Credentials
- Create OAuth 2.0 Client ID (Web application)
- Set Authorized JavaScript origins:
http://localhost:3000(development)https://thrillwiki.com(production)
- Set Authorized redirect URIs:
http://localhost:8000/accounts/google/login/callback/https://api.thrillwiki.com/accounts/google/login/callback/
- 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
- Go to
/admin/socialaccount/socialapp/ - 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
- Go to Discord Developer Portal
- Create New Application
- Go to OAuth2 → General
- Add Redirect URIs:
http://127.0.0.1:8000/accounts/discord/login/callback/https://api.thrillwiki.com/accounts/discord/login/callback/
- 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
- Go to
/admin/socialaccount/socialapp/ - 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:
useAdminGuard()hook checks MFA enrollment viauseRequireMFA()getEnrolledFactors()queries Django'sget_mfa_statusendpoint- Backend returns
has_second_factor: trueif TOTP or Passkey is enabled - 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_tokenflow - 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:
useRequireMFAreturnshasMFA: trueif user has any factor enrolleduseAdminGuardblocks access ifneedsEnrollmentis true- Users prompted to enroll MFA on their first admin page visit