Files
mnemosyne/docs/Pattern_SSO-Allauth-Casdoor_V1-00.md
Robert Helewka 99bdb4ac92 Add Themis application with custom widgets, views, and utilities
- Implemented custom form widgets for date, time, and datetime fields with DaisyUI styling.
- Created utility functions for formatting dates, times, and numbers according to user preferences.
- Developed views for profile settings, API key management, and notifications, including health check endpoints.
- Added URL configurations for Themis tests and main application routes.
- Established test cases for custom widgets to ensure proper functionality and integration.
- Defined project metadata and dependencies in pyproject.toml for package management.
2026-03-21 02:00:18 +00:00

25 KiB

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 <app>/adapters.py User provisioning, group mapping, superuser protection
Local account adapter <app>/adapters.py Disable local signup, authentication logging
Management command <app>/management/commands/create_sso_groups.py Idempotent group + permission creation
Login template templates/account/login.html SSO button + conditional local login form
Context processor <app>/context_processors.py Expose CASDOOR_ENABLED / ALLOW_LOCAL_LOGIN to templates
SSL patch (optional) <app>/ssl_patch.py Development-only SSL bypass

Minimum settings.py configuration

# INSTALLED_APPS — required entries
INSTALLED_APPS = [
    # ... standard Django apps ...
    'allauth',
    'allauth.account',
    'allauth.socialaccount',
    'allauth.socialaccount.providers.openid_connect',
    '<your_app>',
]

# 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/<provider_id>/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

Most implementations should include these Allauth settings:

# 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 = '<app>.adapters.CasdoorAccountAdapter'
ACCOUNT_ADAPTER = '<app>.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

Add allauth.mfa for TOTP/WebAuthn second-factor support:

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.

Protect the local login form from brute-force attacks with django-axes or similar:

# 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.

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:

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:

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:

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:

{% load socialaccount %}

<!-- SSO Login Button (POST form for CSRF protection) -->
{% if CASDOOR_ENABLED %}
<form method="post" action="{% provider_login_url 'casdoor' %}">
    {% csrf_token %}
    <button type="submit">Sign in with SSO</button>
</form>
{% endif %}

<!-- Local Login Form (conditional) -->
{% if ALLOW_LOCAL_LOGIN or user.is_superuser %}
<form method="post" action="{% url 'account_login' %}">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Sign In Locally</button>
</form>
{% endif %}

Why POST? Using a <a href> GET link to initiate the OAuth flow skips CSRF validation. Allauth's {% provider_login_url %} is designed for use inside a <form method="post"> so the CSRF token is verified before the redirect.


Context Processor

Exposes SSO settings to every template:

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:

TEMPLATES = [{
    'OPTIONS': {
        'context_processors': [
            # ... standard processors ...
            '<app>.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:

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:

_ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower()
if _ssl_verify == 'false':
    import <app>.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
# 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:

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:

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 (<a href>) 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:

# 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 = '<app>.adapters.CasdoorAccountAdapter'
ACCOUNT_ADAPTER = '<app>.adapters.LocalAccountAdapter'

Testing

Standard test cases every implementation should cover:

from django.test import TestCase, override_settings
from unittest.mock import MagicMock
from django.contrib.auth.models import User, Group
from <app>.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()))