# 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 ` 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: ``` After authentication completes with JWT enabled: ``` Authorization: Bearer ``` --- ## 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