mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 13:55:19 -05:00
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.
726 lines
20 KiB
Markdown
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
|