feat(auth): add Casdoor SSO integration via django-allauth
Integrate OIDC-based SSO authentication through Casdoor using django-allauth. Adds configuration for enabling SSO, custom account adapters, and an optional SSL verification bypass for sandbox environments with self-signed certificates. - Add CASDOOR_* and ALLOW_LOCAL_LOGIN env vars to .env.example and docker-compose (app service only) - Configure allauth with openid_connect provider for Casdoor - Register custom adapters (CasdoorAccountAdapter, LocalAccountAdapter) - Apply SSL patch early in settings when CASDOOR_SSL_VERIFY=false
This commit is contained in:
739
docs/Pattern_SSO-Allauth-Casdoor_V1-01.md
Normal file
739
docs/Pattern_SSO-Allauth-Casdoor_V1-01.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# 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 is NOT a Django setting — it is read directly from the
|
||||
# environment at the top of settings.py (before any imports) to apply SSL
|
||||
# bypass via ssl_patch.py or set REQUESTS_CA_BUNDLE for a custom CA.
|
||||
# See the SSL Bypass section above for the correct implementation.
|
||||
|
||||
# --- 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()))
|
||||
```
|
||||
@@ -1,324 +0,0 @@
|
||||
## 🐾 Red Panda Approval™
|
||||
|
||||
This project follows Red Panda Approval standards — our gold standard for Django application quality. Code must be elegant, reliable, and maintainable to earn the approval of our adorable red panda judges.
|
||||
|
||||
### The 5 Sacred Django Criteria
|
||||
1. **Fresh Migration Test** — Clean migrations from empty database
|
||||
2. **Elegant Simplicity** — No unnecessary complexity
|
||||
3. **Observable & Debuggable** — Proper logging and error handling
|
||||
4. **Consistent Patterns** — Follow Django conventions
|
||||
5. **Actually Works** — Passes all checks and serves real user needs
|
||||
|
||||
## Environment Standards
|
||||
- Virtual environment: ~/env/PROJECT/bin/activate
|
||||
- Use pyproject.toml for project configuration (no setup.py, no requirements.txt)
|
||||
- Python version: specified in pyproject.toml
|
||||
- Dependencies: floor-pinned with ceiling (e.g. `Django>=5.2,<6.0`)
|
||||
|
||||
### Dependency Pinning
|
||||
|
||||
```toml
|
||||
# Correct — floor pin with ceiling
|
||||
dependencies = [
|
||||
"Django>=5.2,<6.0",
|
||||
"djangorestframework>=3.14,<4.0",
|
||||
"cryptography>=41.0,<45.0",
|
||||
]
|
||||
|
||||
# Wrong — exact pins in library packages
|
||||
dependencies = [
|
||||
"Django==5.2.7", # too strict, breaks downstream
|
||||
]
|
||||
```
|
||||
|
||||
Exact pins (`==`) are only appropriate in application-level lock files, not in reusable library packages.
|
||||
|
||||
## Directory Structure
|
||||
myproject/ # Git repository root
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
├── pyproject.toml # Project configuration (moved to repo root)
|
||||
├── docker-compose.yml
|
||||
├── .env # Docker Compose environment
|
||||
│ # DB_ENGINE=postgresql
|
||||
│ # APP_DB_NAME=angelia2
|
||||
│ # APP_DB_USER=angelia
|
||||
│ # APP_DB_PASSWORD=changeme
|
||||
│ # DB_HOST=db
|
||||
│ # DB_PORT=5432
|
||||
├── .env.example
|
||||
│
|
||||
├── project/ # Django project root (manage.py lives here)
|
||||
│ ├── manage.py
|
||||
│ ├── Dockerfile
|
||||
│ ├── .env # Local development environment
|
||||
│ │ # DB_ENGINE=sqlite
|
||||
├── .env.example
|
||||
│
|
||||
├── config/ # Django configuration module
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── settings.py
|
||||
│ │ ├── urls.py
|
||||
│ │ ├── wsgi.py
|
||||
│ │ └── asgi.py
|
||||
│ │
|
||||
│ ├── accounts/ # Django app
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── models.py
|
||||
│ │ ├── views.py
|
||||
│ │ └── urls.py
|
||||
│ │
|
||||
│ ├── blog/ # Django app
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── models.py
|
||||
│ │ ├── views.py
|
||||
│ │ └── urls.py
|
||||
│ │
|
||||
│ ├── static/
|
||||
│ │ ├── css/
|
||||
│ │ └── js/
|
||||
│ │
|
||||
│ └── templates/
|
||||
│ └── base.html
|
||||
│
|
||||
├── web/ # Nginx configuration
|
||||
│ └── nginx.conf
|
||||
│
|
||||
├── db/ # PostgreSQL configuration
|
||||
│ └── postgresql.conf
|
||||
│
|
||||
└── docs/ # Project documentation
|
||||
└── index.md
|
||||
|
||||
## Settings Structure
|
||||
- Use a single settings.py file
|
||||
- Use django-environ or python-dotenv for environment variables
|
||||
- Never commit .env files to version control
|
||||
- Provide .env.example with all required variables documented
|
||||
- Create .gitignore file
|
||||
- Create a .dockerignore file
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### PostgreSQL settings (only if DB_ENGINE=postgresql)
|
||||
```
|
||||
APP_DB_NAME=angelia2
|
||||
APP_DB_USER=angelia
|
||||
APP_DB_PASSWORD=changeme
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
```
|
||||
|
||||
## Code Organization
|
||||
- Imports: PEP 8 ordering (stdlib, third-party, local)
|
||||
- Type hints on function parameters
|
||||
- CSS: External .css files only (no inline styles, no embedded `<style>` tags)
|
||||
- JS: External .js files only (no inline handlers, no embedded `<script>` blocks)
|
||||
- Maximum file length: 1000 lines
|
||||
- If a file exceeds 500 lines, consider splitting by domain concept
|
||||
|
||||
## Database Conventions
|
||||
- Migrations run cleanly from empty database
|
||||
- Never edit deployed migrations
|
||||
- Use meaningful migration names: --name add_email_to_profile
|
||||
- One logical change per migration when possible
|
||||
- Test migrations both forward and backward
|
||||
|
||||
### Development vs Production
|
||||
- Development: SQLite
|
||||
- Production: PostgreSQL
|
||||
|
||||
## Caching
|
||||
- Expensive queries are cached
|
||||
- Cache keys follow naming convention
|
||||
- TTLs are appropriate (not infinite)
|
||||
- Invalidation is documented
|
||||
- Key Naming Pattern: {app}:{model}:{identifier}:{field}
|
||||
|
||||
## Model Naming
|
||||
- Model names: singular PascalCase (User, BlogPost, OrderItem)
|
||||
- Correct English pluralization on related names
|
||||
- All models have created_at and updated_at
|
||||
- All models define __str__ and get_absolute_url
|
||||
- TextChoices used for status fields
|
||||
- related_name defined on ForeignKey fields
|
||||
- Related names: plural snake_case with proper English pluralization
|
||||
|
||||
## Forms
|
||||
- Use ModelForm with explicit fields list (never __all__)
|
||||
|
||||
## Field Naming
|
||||
- Foreign keys: singular without _id suffix (author, category, parent)
|
||||
- Boolean fields: use prefixes (is_active, has_permission, can_edit)
|
||||
- Date fields: use suffixes (created_at, updated_at, published_on)
|
||||
- Avoid abbreviations (use description, not desc)
|
||||
|
||||
## Required Model Fields
|
||||
- All models should include:
|
||||
- created_at = models.DateTimeField(auto_now_add=True)
|
||||
- updated_at = models.DateTimeField(auto_now=True)
|
||||
- Consider adding:
|
||||
- id = models.UUIDField(primary_key=True) for public-facing models
|
||||
- is_active = models.BooleanField(default=True) for soft deletes
|
||||
|
||||
## Indexing
|
||||
- Add db_index=True to frequently queried fields
|
||||
- Use Meta.indexes for composite indexes
|
||||
- Document why each index exists
|
||||
|
||||
## Queries
|
||||
- Use select_related() for foreign keys
|
||||
- Use prefetch_related() for reverse relations and M2M
|
||||
- Avoid queries in loops (N+1 problem)
|
||||
- Use .only() and .defer() for large models
|
||||
- Add comments explaining complex querysets
|
||||
|
||||
## Docstrings
|
||||
- Use Sphinx style docstrings
|
||||
- Document all public functions, classes, and modules
|
||||
- Skip docstrings for obvious one-liners and standard Django overrides
|
||||
|
||||
## Views
|
||||
- Use Function-Based Views (FBVs) exclusively
|
||||
- Explicit logic is preferred over implicit inheritance
|
||||
- Extract shared logic into utility functions
|
||||
|
||||
## URLs & Identifiers
|
||||
|
||||
- Public URLs use short UUIDs (12 characters) via `shortuuid`
|
||||
- Never expose sequential IDs in URLs (security/enumeration risk)
|
||||
- Internal references may use standard UUIDs or PKs
|
||||
|
||||
## URL Patterns
|
||||
- Resource-based URLs (RESTful style)
|
||||
- Namespaced URL names per app
|
||||
- Trailing slashes (Django default)
|
||||
- Flat structure preferred over deep nesting
|
||||
|
||||
## Background Tasks
|
||||
- All tasks are run synchronously unless the design specifies background tasks are needed for long operations
|
||||
- Long operations use Celery tasks
|
||||
- Use Memcached, task progress pattern: {app}:task:{task_id}:progress
|
||||
- Tasks are idempotent
|
||||
- Tasks include retry logic
|
||||
- Tasks live in app/tasks.py
|
||||
- RabbitMQ is the Message Broker
|
||||
- Flower Monitoring: Use for debugging failed tasks
|
||||
|
||||
## Testing
|
||||
- Framework: Django TestCase (not pytest)
|
||||
- Separate test files per module: test_models.py, test_views.py, test_forms.py
|
||||
|
||||
## Frontend Standards
|
||||
|
||||
### New Projects (DaisyUI + Tailwind)
|
||||
- DaisyUI 4 via CDN for component classes
|
||||
- Tailwind CSS via CDN for utility classes
|
||||
- Theme management via Themis (DaisyUI `data-theme` attribute)
|
||||
- All apps extend `themis/base.html` for consistent navigation
|
||||
- No inline styles or scripts
|
||||
|
||||
### Existing Projects (Bootstrap 5)
|
||||
- Bootstrap 5 via CDN
|
||||
- Bootstrap Icons via CDN
|
||||
- Bootswatch for theme variants (if applicable)
|
||||
- django-bootstrap5 and crispy-bootstrap5 for form rendering
|
||||
|
||||
## Preferred Packages
|
||||
|
||||
### Core Django
|
||||
- django>=5.2,<6.0
|
||||
- django-environ — Environment variables
|
||||
|
||||
### Authentication & Security
|
||||
- django-allauth — User management
|
||||
- django-allauth-2fa — Two-factor authentication
|
||||
|
||||
### API Development
|
||||
- djangorestframework>=3.14,<4.0 — REST APIs
|
||||
- drf-spectacular — OpenAPI/Swagger documentation
|
||||
|
||||
### Encryption
|
||||
- cryptography — Fernet encryption for secrets/API keys
|
||||
|
||||
### Background Tasks
|
||||
- celery — Async task queue
|
||||
- django-celery-progress — Progress bars
|
||||
- flower — Celery monitoring
|
||||
|
||||
### Caching
|
||||
- pymemcache — Memcached backend
|
||||
|
||||
### Database
|
||||
- psycopg[binary] — PostgreSQL adapter
|
||||
- shortuuid — Short UUIDs for public URLs
|
||||
|
||||
### Production
|
||||
- gunicorn — WSGI server
|
||||
|
||||
### Shared Apps
|
||||
- django-heluca-themis — User preferences, themes, key management, navigation
|
||||
|
||||
### Deprecated / Removed
|
||||
- ~~pytz~~ — Use stdlib `zoneinfo` (Python 3.9+, Django 4+)
|
||||
- ~~Pillow~~ — Only add if your app needs ImageField
|
||||
- ~~django-heluca-core~~ — Replaced by Themis
|
||||
- ~~dj-database-url~~ — Use individual Django DB env vars instead
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Models
|
||||
- Don't use `Model.objects.get()` without handling `DoesNotExist`
|
||||
- Don't use `null=True` on `CharField` or `TextField` (use `blank=True, default=""`)
|
||||
- Don't use `related_name='+'` unless you have a specific reason
|
||||
- Don't override `save()` for business logic (use signals or service functions)
|
||||
- Don't use `auto_now=True` on fields you might need to manually set
|
||||
- Don't use `ForeignKey` without specifying `on_delete` explicitly
|
||||
- Don't use `Meta.ordering` on large tables (specify ordering in queries)
|
||||
|
||||
### Queries
|
||||
- Don't query inside loops (N+1 problem)
|
||||
- Don't use `.all()` when you need a subset
|
||||
- Don't use raw SQL unless absolutely necessary
|
||||
- Don't forget `select_related()` and `prefetch_related()`
|
||||
|
||||
### Views
|
||||
- Don't put business logic in views
|
||||
- Don't use `request.POST.get()` without validation (use forms)
|
||||
- Don't return sensitive data in error messages
|
||||
- Don't forget `login_required` decorator on protected views
|
||||
|
||||
### Forms
|
||||
- Don't use `fields = '__all__'` in ModelForm
|
||||
- Don't trust client-side validation alone
|
||||
- Don't use `exclude` in ModelForm (use explicit `fields`)
|
||||
|
||||
### Templates
|
||||
- Don't use `{{ variable }}` for URLs (use `{% url %}` tag)
|
||||
- Don't put logic in templates
|
||||
- Don't use inline CSS or JavaScript (external files only)
|
||||
- Don't forget `{% csrf_token %}` in forms
|
||||
|
||||
### Security
|
||||
- Don't store secrets in `settings.py` (use environment variables)
|
||||
- Don't commit `.env` files to version control
|
||||
- Don't use `DEBUG=True` in production
|
||||
- Don't expose sequential IDs in public URLs
|
||||
- Don't use `mark_safe()` on user-supplied content
|
||||
- Don't disable CSRF protection
|
||||
|
||||
### Imports & Code Style
|
||||
- Don't use `from module import *`
|
||||
- Don't use mutable default arguments
|
||||
- Don't use bare `except:` clauses
|
||||
- Don't ignore linter warnings without documented reason
|
||||
|
||||
### Migrations
|
||||
- Don't edit migrations that have been deployed
|
||||
- Don't use `RunPython` without a reverse function
|
||||
- Don't add non-nullable fields without a default value
|
||||
|
||||
### Celery Tasks
|
||||
- Don't pass model instances to tasks (pass IDs and re-fetch)
|
||||
- Don't assume tasks run immediately
|
||||
- Don't forget retry logic for external service calls
|
||||
Reference in New Issue
Block a user