# SSO with Allauth & Casdoor Pattern v1.0.0 Standardizes OIDC-based Single Sign-On using Django Allauth and Casdoor, covering adapter customization, user provisioning, group mapping, superuser protection, and configurable local-login fallback. Used by the `core` Django application. ## 🐾 Red Panda Approvalβ„’ This pattern follows Red Panda Approval standards. --- ## Why a Pattern, Not a Shared Implementation Every Django project that adopts SSO has different identity-provider configurations, claim schemas, permission models, and organizational structures: - A **project management** app needs role claims mapped to project-scoped permissions - An **e-commerce** app needs tenant/store claims with purchase-limit groups - An **RFP tool** (Spelunker) needs organization + group claims mapped to View Only / Staff / SME / Admin groups Instead, this pattern defines: - **Required components** β€” every implementation must have - **Required settings** β€” Django & Allauth configuration values - **Standard conventions** β€” group names, claim mappings, redirect URL format - **Extension guidelines** β€” for domain-specific provisioning logic --- ## Required Components Every SSO implementation following this pattern must provide these files: | Component | Location | Purpose | |-----------|----------|---------| | Social account adapter | `/adapters.py` | User provisioning, group mapping, superuser protection | | Local account adapter | `/adapters.py` | Disable local signup, authentication logging | | Management command | `/management/commands/create_sso_groups.py` | Idempotent group + permission creation | | Login template | `templates/account/login.html` | SSO button + conditional local login form | | Context processor | `/context_processors.py` | Expose `CASDOOR_ENABLED` / `ALLOW_LOCAL_LOGIN` to templates | | SSL patch (optional) | `/ssl_patch.py` | Development-only SSL bypass | ### Minimum settings.py configuration ```python # INSTALLED_APPS β€” required entries INSTALLED_APPS = [ # ... standard Django apps ... 'allauth', 'allauth.account', 'allauth.socialaccount', 'allauth.socialaccount.providers.openid_connect', '', ] # MIDDLEWARE β€” Allauth middleware is required MIDDLEWARE = [ # ... standard Django middleware ... 'allauth.account.middleware.AccountMiddleware', ] # AUTHENTICATION_BACKENDS β€” both local and SSO AUTHENTICATION_BACKENDS = [ 'django.contrib.auth.backends.ModelBackend', 'allauth.account.auth_backends.AuthenticationBackend', ] ``` --- ## Standard Values / Conventions ### Environment Variables Every deployment must set these environment variables (or `.env` entries): | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `CASDOOR_ENABLED` | Yes | β€” | Enable/disable SSO (`true`/`false`) | | `CASDOOR_ORIGIN` | Yes | β€” | Casdoor backend URL for OIDC discovery | | `CASDOOR_ORIGIN_FRONTEND` | Yes | β€” | Casdoor frontend URL (may differ behind reverse proxy) | | `CASDOOR_CLIENT_ID` | Yes | β€” | OAuth client ID from Casdoor application | | `CASDOOR_CLIENT_SECRET` | Yes | β€” | OAuth client secret from Casdoor application | | `CASDOOR_ORG_NAME` | Yes | β€” | Default organization slug in Casdoor | | `ALLOW_LOCAL_LOGIN` | No | `false` | Show local login form for non-superusers | | `CASDOOR_SSL_VERIFY` | No | `true` | SSL verification (`true`, `false`, or CA-bundle path) | ### Redirect URL Convention The Allauth OIDC callback URL follows a fixed format. Register this URL in Casdoor: ``` /accounts/oidc//login/callback/ ``` For Spelunker with `provider_id = casdoor`: ``` /accounts/oidc/casdoor/login/callback/ ``` > **Important:** The path segment is `oidc`, not `openid_connect`. ### Standard Group Mapping Casdoor group names map to Django groups with consistent naming: | Casdoor Group | Django Group | `is_staff` | Permissions | |---------------|-------------|------------|-------------| | `view_only` | `View Only` | `False` | `view_*` | | `staff` | `Staff` | `True` | `view_*`, `add_*`, `change_*` | | `sme` | `SME` | `True` | `view_*`, `add_*`, `change_*` | | `admin` | `Admin` | `True` | `view_*`, `add_*`, `change_*`, `delete_*` | ### Standard OIDC Claim Mapping | Casdoor Claim | Django Field | Notes | |---------------|-------------|-------| | `email` | `User.username`, `User.email` | Full email used as username | | `given_name` | `User.first_name` | β€” | | `family_name` | `User.last_name` | β€” | | `name` | Parsed into first/last | Fallback when given/family absent | | `organization` | Organization lookup/create | Via adapter | | `groups` | Django Group membership | Via adapter mapping | --- ## Recommended Settings Most implementations should include these Allauth settings: ```python # Authentication mode ACCOUNT_LOGIN_METHODS = {'email'} ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*'] ACCOUNT_EMAIL_VERIFICATION = 'optional' ACCOUNT_SESSION_REMEMBER = True ACCOUNT_LOGIN_ON_PASSWORD_RESET = True ACCOUNT_UNIQUE_EMAIL = True # Redirects LOGIN_REDIRECT_URL = '/dashboard/' ACCOUNT_LOGOUT_REDIRECT_URL = '/' LOGIN_URL = '/accounts/login/' # Social account behavior SOCIALACCOUNT_AUTO_SIGNUP = True SOCIALACCOUNT_EMAIL_VERIFICATION = 'none' SOCIALACCOUNT_QUERY_EMAIL = True SOCIALACCOUNT_STORE_TOKENS = True SOCIALACCOUNT_ADAPTER = '.adapters.CasdoorAccountAdapter' ACCOUNT_ADAPTER = '.adapters.LocalAccountAdapter' # Session management SESSION_COOKIE_AGE = 28800 # 8 hours SESSION_SAVE_EVERY_REQUEST = True # Account linking β€” auto-connect SSO to an existing local account with # the same verified email instead of raising a conflict error SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True ``` ### Multi-Factor Authentication (Recommended) Add `allauth.mfa` for TOTP/WebAuthn second-factor support: ```python INSTALLED_APPS += ['allauth.mfa'] MFA_ADAPTER = 'allauth.mfa.adapter.DefaultMFAAdapter' ``` MFA is enforced per-user inside Django; Casdoor may also enforce its own MFA upstream. ### Rate Limiting on Local Login (Recommended) Protect the local login form from brute-force attacks with `django-axes` or similar: ```python # pip install django-axes INSTALLED_APPS += ['axes'] AUTHENTICATION_BACKENDS = [ 'axes.backends.AxesStandaloneBackend', 'django.contrib.auth.backends.ModelBackend', 'allauth.account.auth_backends.AuthenticationBackend', ] AXES_FAILURE_LIMIT = 5 # Lock after 5 failures AXES_COOLOFF_TIME = 1 # 1-hour cooloff AXES_LOCKOUT_PARAMETERS = ['ip_address', 'username'] ``` --- ## Social Account Adapter The social account adapter is the core of the pattern. It handles user provisioning on SSO login, maps claims to Django fields, enforces superuser protection, and assigns groups. ```python from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.exceptions import ImmediateHttpResponse from django.contrib.auth.models import User, Group from django.contrib import messages from django.shortcuts import redirect import logging logger = logging.getLogger(__name__) class CasdoorAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request, sociallogin): """Always allow SSO-initiated signup.""" return True def pre_social_login(self, request, sociallogin): """ Runs on every SSO login (new and returning users). 1. Blocks superusers β€” they must use local auth. 2. Re-syncs organization and group claims for returning users so that IdP changes are reflected immediately. """ if sociallogin.user.id: user = sociallogin.user # --- Superuser gate --- if user.is_superuser: logger.warning( f"SSO login blocked for superuser {user.username}. " "Superusers must use local authentication." ) messages.error( request, "Superuser accounts must use local authentication." ) raise ImmediateHttpResponse(redirect('account_login')) # --- Re-sync claims for returning users --- extra_data = sociallogin.account.extra_data org_identifier = extra_data.get('organization', '') if org_identifier: self._assign_organization(user, org_identifier) groups = extra_data.get('groups', []) self._assign_groups(user, groups) user.is_staff = any( g in ['staff', 'sme', 'admin'] for g in groups ) user.save(update_fields=['is_staff']) def populate_user(self, request, sociallogin, data): """Map Casdoor claims to Django User fields.""" user = super().populate_user(request, sociallogin, data) email = data.get('email', '') user.username = email user.email = email user.first_name = data.get('given_name', '') user.last_name = data.get('family_name', '') # Fallback: parse full 'name' claim if not user.first_name and not user.last_name: full_name = data.get('name', '') if full_name: parts = full_name.split(' ', 1) user.first_name = parts[0] user.last_name = parts[1] if len(parts) > 1 else '' # Security: SSO users are never superusers user.is_superuser = False # Set is_staff from group membership groups = data.get('groups', []) user.is_staff = any(g in ['staff', 'sme', 'admin'] for g in groups) return user def save_user(self, request, sociallogin, form=None): """Save user and handle organization + group mapping.""" user = super().save_user(request, sociallogin, form) extra_data = sociallogin.account.extra_data org_identifier = extra_data.get('organization', '') if org_identifier: self._assign_organization(user, org_identifier) groups = extra_data.get('groups', []) self._assign_groups(user, groups) return user def _assign_organization(self, user, org_identifier): """Assign (or create) organization from the OIDC claim.""" # Domain-specific β€” see Extension Examples below raise NotImplementedError("Override per project") def _assign_groups(self, user, group_names): """Map Casdoor groups to Django groups.""" group_mapping = { 'view_only': 'View Only', 'staff': 'Staff', 'sme': 'SME', 'admin': 'Admin', } user.groups.clear() for casdoor_group in group_names: django_group_name = group_mapping.get(casdoor_group.lower()) if django_group_name: group, _ = Group.objects.get_or_create(name=django_group_name) user.groups.add(group) logger.info(f"Added {user.username} to group {django_group_name}") ``` --- ## Local Account Adapter Prevents local registration and logs authentication failures: ```python from allauth.account.adapter import DefaultAccountAdapter import logging logger = logging.getLogger(__name__) class LocalAccountAdapter(DefaultAccountAdapter): def is_open_for_signup(self, request): """Disable local signup β€” all users come via SSO or admin.""" return False def authentication_failed(self, request, **kwargs): """Log failures for security monitoring.""" logger.warning( f"Local authentication failed from {request.META.get('REMOTE_ADDR')}" ) super().authentication_failed(request, **kwargs) ``` --- ## OIDC Provider Configuration Register Casdoor as an OpenID Connect provider in `settings.py`: ```python SOCIALACCOUNT_PROVIDERS = { 'openid_connect': { 'APPS': [ { 'provider_id': 'casdoor', 'name': 'Casdoor SSO', 'client_id': CASDOOR_CLIENT_ID, 'secret': CASDOOR_CLIENT_SECRET, 'settings': { 'server_url': f'{CASDOOR_ORIGIN}/.well-known/openid-configuration', }, } ], 'OAUTH_PKCE_ENABLED': True, } } ``` --- ## Management Command β€” Group Creation An idempotent management command ensures groups and permissions exist: ```python from django.core.management.base import BaseCommand from django.contrib.auth.models import Group, Permission class Command(BaseCommand): help = 'Create Django groups for Casdoor SSO integration' def handle(self, *args, **options): groups_config = { 'View Only': {'permissions': ['view']}, 'Staff': {'permissions': ['view', 'add', 'change']}, 'SME': {'permissions': ['view', 'add', 'change']}, 'Admin': {'permissions': ['view', 'add', 'change', 'delete']}, } # Add your domain-specific model names here models_to_permission = [ 'vendor', 'document', 'rfp', 'rfpquestion', ] for group_name, config in groups_config.items(): group, created = Group.objects.get_or_create(name=group_name) status = 'Created' if created else 'Exists' self.stdout.write(f'{status}: {group_name}') for perm_prefix in config['permissions']: for model in models_to_permission: try: perm = Permission.objects.get( codename=f'{perm_prefix}_{model}' ) group.permissions.add(perm) except Permission.DoesNotExist: pass self.stdout.write(self.style.SUCCESS('SSO groups created successfully')) ``` --- ## Login Template The login template shows an SSO button when Casdoor is enabled and conditionally reveals the local login form: ```html {% load socialaccount %} {% if CASDOOR_ENABLED %}
{% csrf_token %}
{% endif %} {% if ALLOW_LOCAL_LOGIN or user.is_superuser %}
{% csrf_token %} {{ form.as_p }}
{% endif %} ``` > **Why POST?** Using a `` GET link to initiate the OAuth flow skips CSRF > validation. Allauth's `{% provider_login_url %}` is designed for use inside a > `
` so the CSRF token is verified before the redirect. --- ## Context Processor Exposes SSO settings to every template: ```python from django.conf import settings def user_preferences(request): context = {} # Always expose SSO flags for the login page context['CASDOOR_ENABLED'] = getattr(settings, 'CASDOOR_ENABLED', False) context['ALLOW_LOCAL_LOGIN'] = getattr(settings, 'ALLOW_LOCAL_LOGIN', False) return context ``` Register in `settings.py`: ```python TEMPLATES = [{ 'OPTIONS': { 'context_processors': [ # ... standard processors ... '.context_processors.user_preferences', ], }, }] ``` --- ## SSL Bypass (Development Only) For sandbox environments with self-signed certificates, an optional SSL patch disables verification at the `requests` library level: ```python import os, logging logger = logging.getLogger(__name__) def apply_ssl_bypass(): ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower() if ssl_verify != 'false': return logger.warning("SSL verification DISABLED β€” sandbox only") import urllib3 from requests.adapters import HTTPAdapter urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) _original_send = HTTPAdapter.send def _patched_send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None): return _original_send(self, request, stream=stream, timeout=timeout, verify=False, cert=cert, proxies=proxies) HTTPAdapter.send = _patched_send apply_ssl_bypass() ``` Load it at the top of `settings.py` **before** any library imports that make HTTP calls: ```python _ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower() if _ssl_verify == 'false': import .ssl_patch # noqa: F401 ``` --- ## Logout Flow By default, Django's `account_logout` destroys the local session but does **not** terminate the upstream Casdoor session. The user remains logged in at the IdP and will be silently re-authenticated on next visit. ### Options | Strategy | Behaviour | Implementation | |----------|-----------|----------------| | **Local-only logout** (default) | Destroys Django session; IdP session survives | No extra work | | **IdP redirect logout** | Redirects to Casdoor's `/api/logout` after local logout | Override `ACCOUNT_LOGOUT_REDIRECT_URL` to point at Casdoor | | **OIDC back-channel logout** | Casdoor notifies Django to invalidate sessions | Requires Casdoor back-channel support + a Django webhook endpoint | ### Recommended: IdP redirect logout ```python # settings.py ACCOUNT_LOGOUT_REDIRECT_URL = ( f'{CASDOOR_ORIGIN}/api/logout' f'?post_logout_redirect_uri=https://your-app.example.com/' ) ``` This ensures the Casdoor session cookie is cleared before the user returns to your app. --- ## Domain Extension Examples ### Spelunker (RFP Tool) Spelunker's adapter creates organizations on first encounter and links them to user profiles: ```python def _assign_organization(self, user, org_identifier): from django.db import models from django.utils.text import slugify from core.models import Organization try: org = Organization.objects.filter( models.Q(slug=org_identifier) | models.Q(name=org_identifier) ).first() if not org: org = Organization.objects.create( name=org_identifier, slug=slugify(org_identifier), type='for-profit', legal_country='CA', status='active', ) logger.info(f"Created organization: {org.name}") if hasattr(user, 'profile'): logger.info(f"Assigned {user.username} β†’ {org.name}") except Exception as e: logger.error(f"Organization assignment error: {e}") ``` ### Multi-Tenant SaaS App A multi-tenant app might restrict users to a single tenant and enforce tenant isolation: ```python def _assign_organization(self, user, org_identifier): from tenants.models import Tenant tenant = Tenant.objects.filter(external_id=org_identifier).first() if not tenant: raise ValueError(f"Unknown tenant: {org_identifier}") user.tenant = tenant user.save(update_fields=['tenant']) ``` --- ## Anti-Patterns - ❌ Don't allow SSO to grant `is_superuser` β€” always force `is_superuser = False` in `populate_user` - ❌ Don't *log-and-continue* for superuser SSO attempts β€” raise `ImmediateHttpResponse` to actually block the login - ❌ Don't disable local login for superusers β€” they need emergency access when SSO is unavailable - ❌ Don't rely on SSO username claims β€” use email as the canonical identifier - ❌ Don't hard-code the OIDC provider URL β€” always read from environment variables - ❌ Don't skip the management command β€” groups and permissions must be idempotent and repeatable - ❌ Don't use `CASDOOR_SSL_VERIFY=false` in production β€” only for sandbox environments with self-signed certificates - ❌ Don't forget PKCE β€” always set `OAUTH_PKCE_ENABLED: True` for Authorization Code flow - ❌ Don't sync groups only on first login β€” re-sync in `pre_social_login` so IdP changes take effect immediately - ❌ Don't use a GET link (``) to start the OAuth flow β€” use a POST form so CSRF protection applies - ❌ Don't assume Django logout kills the IdP session β€” configure an IdP redirect or back-channel logout - ❌ Don't leave the local login endpoint unprotected β€” add rate limiting (e.g. `django-axes`) to prevent brute-force attacks --- ## Settings All Django settings this pattern recognizes: ```python # settings.py # --- SSO Provider --- CASDOOR_ENABLED = env.bool('CASDOOR_ENABLED') # Master SSO toggle CASDOOR_ORIGIN = env('CASDOOR_ORIGIN') # OIDC discovery base URL CASDOOR_ORIGIN_FRONTEND = env('CASDOOR_ORIGIN_FRONTEND') # Frontend URL (may differ) CASDOOR_CLIENT_ID = env('CASDOOR_CLIENT_ID') # OAuth client ID CASDOOR_CLIENT_SECRET = env('CASDOOR_CLIENT_SECRET') # OAuth client secret CASDOOR_ORG_NAME = env('CASDOOR_ORG_NAME') # Default organization CASDOOR_SSL_VERIFY = env('CASDOOR_SSL_VERIFY') # true | false | /path/to/ca.pem # --- Login Behavior --- ALLOW_LOCAL_LOGIN = env.bool('ALLOW_LOCAL_LOGIN', default=False) # Show local form # --- Allauth --- SOCIALACCOUNT_ADAPTER = '.adapters.CasdoorAccountAdapter' ACCOUNT_ADAPTER = '.adapters.LocalAccountAdapter' ``` --- ## Testing Standard test cases every implementation should cover: ```python from django.test import TestCase, override_settings from unittest.mock import MagicMock from django.contrib.auth.models import User, Group from .adapters import CasdoorAccountAdapter, LocalAccountAdapter class CasdoorAdapterTest(TestCase): def setUp(self): self.adapter = CasdoorAccountAdapter() def test_signup_always_open(self): """SSO signup must always be permitted.""" self.assertTrue(self.adapter.is_open_for_signup(MagicMock(), MagicMock())) def test_superuser_never_set_via_sso(self): """populate_user must force is_superuser=False.""" sociallogin = MagicMock() data = {'email': 'admin@example.com', 'groups': ['admin']} user = self.adapter.populate_user(MagicMock(), sociallogin, data) self.assertFalse(user.is_superuser) def test_email_used_as_username(self): """Username must be the full email address.""" sociallogin = MagicMock() data = {'email': 'jane@example.com'} user = self.adapter.populate_user(MagicMock(), sociallogin, data) self.assertEqual(user.username, 'jane@example.com') def test_staff_flag_from_groups(self): """is_staff must be True when user belongs to staff/sme/admin.""" sociallogin = MagicMock() for group in ['staff', 'sme', 'admin']: data = {'email': 'user@example.com', 'groups': [group]} user = self.adapter.populate_user(MagicMock(), sociallogin, data) self.assertTrue(user.is_staff, f"is_staff should be True for group '{group}'") def test_name_fallback_parsing(self): """When given_name/family_name absent, parse 'name' claim.""" sociallogin = MagicMock() data = {'email': 'user@example.com', 'name': 'Jane Doe'} user = self.adapter.populate_user(MagicMock(), sociallogin, data) self.assertEqual(user.first_name, 'Jane') self.assertEqual(user.last_name, 'Doe') def test_group_mapping(self): """Casdoor groups must map to correctly named Django groups.""" Group.objects.create(name='View Only') Group.objects.create(name='Staff') user = User.objects.create_user('test@example.com', 'test@example.com') self.adapter._assign_groups(user, ['view_only', 'staff']) group_names = set(user.groups.values_list('name', flat=True)) self.assertEqual(group_names, {'View Only', 'Staff'}) def test_superuser_sso_login_blocked(self): """pre_social_login must raise ImmediateHttpResponse for superusers.""" from allauth.exceptions import ImmediateHttpResponse user = User.objects.create_superuser( 'admin@example.com', 'admin@example.com', 'pass' ) sociallogin = MagicMock() sociallogin.user = user sociallogin.user.id = user.id with self.assertRaises(ImmediateHttpResponse): self.adapter.pre_social_login(MagicMock(), sociallogin) def test_groups_resync_on_returning_login(self): """pre_social_login must re-sync groups for existing users.""" Group.objects.create(name='Admin') Group.objects.create(name='Staff') user = User.objects.create_user('user@example.com', 'user@example.com') user.groups.add(Group.objects.get(name='Staff')) sociallogin = MagicMock() sociallogin.user = user sociallogin.user.id = user.id sociallogin.account.extra_data = { 'groups': ['admin'], 'organization': '', } self.adapter.pre_social_login(MagicMock(), sociallogin) group_names = set(user.groups.values_list('name', flat=True)) self.assertEqual(group_names, {'Admin'}) class LocalAdapterTest(TestCase): def test_local_signup_disabled(self): """Local signup must always be disabled.""" adapter = LocalAccountAdapter() self.assertFalse(adapter.is_open_for_signup(MagicMock())) ```