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.
This commit is contained in:
736
docs/Pattern_SSO-Allauth-Casdoor_V1-00.md
Normal file
736
docs/Pattern_SSO-Allauth-Casdoor_V1-00.md
Normal file
@@ -0,0 +1,736 @@
|
||||
# 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
|
||||
|
||||
```python
|
||||
# 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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 = '<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
|
||||
```
|
||||
|
||||
### 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 %}
|
||||
|
||||
<!-- 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:
|
||||
|
||||
```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 ...
|
||||
'<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:
|
||||
|
||||
```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 <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 |
|
||||
|
||||
### 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 (`<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:
|
||||
|
||||
```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 = '<app>.adapters.CasdoorAccountAdapter'
|
||||
ACCOUNT_ADAPTER = '<app>.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 <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()))
|
||||
```
|
||||
Reference in New Issue
Block a user