- 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.
737 lines
25 KiB
Markdown
737 lines
25 KiB
Markdown
# 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()))
|
|
```
|