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:
14
.env.example
14
.env.example
@@ -94,6 +94,20 @@ CELERY_TASK_ALWAYS_EAGER=False
|
|||||||
CELERY_QUEUES=celery,embedding,batch
|
CELERY_QUEUES=celery,embedding,batch
|
||||||
CELERY_CONCURRENCY=2
|
CELERY_CONCURRENCY=2
|
||||||
|
|
||||||
|
# --- SSO / Casdoor ----------------------------------------------------------
|
||||||
|
# Consumed by: app only
|
||||||
|
# Set CASDOOR_ENABLED=True in production to activate OIDC login. All other
|
||||||
|
# CASDOOR_* vars are required when enabled. CASDOOR_SSL_VERIFY should always
|
||||||
|
# be true in production; set to false only in sandboxes with self-signed certs.
|
||||||
|
CASDOOR_ENABLED=False
|
||||||
|
CASDOOR_ORIGIN=https://casdoor.example.com
|
||||||
|
CASDOOR_ORIGIN_FRONTEND=https://casdoor.example.com
|
||||||
|
CASDOOR_CLIENT_ID=
|
||||||
|
CASDOOR_CLIENT_SECRET=
|
||||||
|
CASDOOR_ORG_NAME=
|
||||||
|
CASDOOR_SSL_VERIFY=true
|
||||||
|
ALLOW_LOCAL_LOGIN=False
|
||||||
|
|
||||||
# --- MCP server -------------------------------------------------------------
|
# --- MCP server -------------------------------------------------------------
|
||||||
# Consumed by: mcp only
|
# Consumed by: mcp only
|
||||||
MCP_REQUIRE_AUTH=True
|
MCP_REQUIRE_AUTH=True
|
||||||
|
|||||||
@@ -174,6 +174,16 @@ services:
|
|||||||
- SEARCH_DEFAULT_LIMIT=${SEARCH_DEFAULT_LIMIT}
|
- SEARCH_DEFAULT_LIMIT=${SEARCH_DEFAULT_LIMIT}
|
||||||
- RERANKER_MAX_CANDIDATES=${RERANKER_MAX_CANDIDATES}
|
- RERANKER_MAX_CANDIDATES=${RERANKER_MAX_CANDIDATES}
|
||||||
- RERANKER_TIMEOUT=${RERANKER_TIMEOUT}
|
- RERANKER_TIMEOUT=${RERANKER_TIMEOUT}
|
||||||
|
# SSO / Casdoor (app only — only this service renders the login page
|
||||||
|
# and initiates the OIDC flow; worker and mcp never touch OAuth)
|
||||||
|
- CASDOOR_ENABLED=${CASDOOR_ENABLED}
|
||||||
|
- CASDOOR_ORIGIN=${CASDOOR_ORIGIN}
|
||||||
|
- CASDOOR_ORIGIN_FRONTEND=${CASDOOR_ORIGIN_FRONTEND}
|
||||||
|
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
|
||||||
|
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
|
||||||
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
|
- CASDOOR_SSL_VERIFY=${CASDOOR_SSL_VERIFY}
|
||||||
|
- ALLOW_LOCAL_LOGIN=${ALLOW_LOCAL_LOGIN}
|
||||||
# Logging
|
# Logging
|
||||||
- MNEMOSYNE_COMPONENT=app
|
- MNEMOSYNE_COMPONENT=app
|
||||||
- LOGGING_LEVEL=${LOGGING_LEVEL}
|
- LOGGING_LEVEL=${LOGGING_LEVEL}
|
||||||
|
|||||||
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
|
|
||||||
@@ -10,6 +10,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
import environ
|
import environ
|
||||||
|
|
||||||
|
# Apply SSL bypass before any HTTP-making imports when CASDOOR_SSL_VERIFY=false.
|
||||||
|
# This must run before allauth or requests make outbound calls.
|
||||||
|
if os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower() == 'false':
|
||||||
|
import themis.ssl_patch # noqa: F401
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
@@ -34,6 +39,15 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
USE_X_FORWARDED_PORT = True
|
USE_X_FORWARDED_PORT = True
|
||||||
|
|
||||||
|
# --- SSO (Casdoor / Allauth) ---
|
||||||
|
CASDOOR_ENABLED = env.bool("CASDOOR_ENABLED", default=False)
|
||||||
|
CASDOOR_ORIGIN = env("CASDOOR_ORIGIN", default="")
|
||||||
|
CASDOOR_ORIGIN_FRONTEND = env("CASDOOR_ORIGIN_FRONTEND", default="")
|
||||||
|
CASDOOR_CLIENT_ID = env("CASDOOR_CLIENT_ID", default="")
|
||||||
|
CASDOOR_CLIENT_SECRET = env("CASDOOR_CLIENT_SECRET", default="")
|
||||||
|
CASDOOR_ORG_NAME = env("CASDOOR_ORG_NAME", default="")
|
||||||
|
ALLOW_LOCAL_LOGIN = env.bool("ALLOW_LOCAL_LOGIN", default=False)
|
||||||
|
|
||||||
# --- LLM API Encryption ---
|
# --- LLM API Encryption ---
|
||||||
LLM_API_SECRETS_ENCRYPTION_KEY = env(
|
LLM_API_SECRETS_ENCRYPTION_KEY = env(
|
||||||
"LLM_API_SECRETS_ENCRYPTION_KEY", default=""
|
"LLM_API_SECRETS_ENCRYPTION_KEY", default=""
|
||||||
@@ -54,6 +68,10 @@ INSTALLED_APPS = [
|
|||||||
"storages",
|
"storages",
|
||||||
"django_neomodel",
|
"django_neomodel",
|
||||||
"django_prometheus",
|
"django_prometheus",
|
||||||
|
"allauth",
|
||||||
|
"allauth.account",
|
||||||
|
"allauth.socialaccount",
|
||||||
|
"allauth.socialaccount.providers.openid_connect",
|
||||||
# Mnemosyne apps
|
# Mnemosyne apps
|
||||||
"themis",
|
"themis",
|
||||||
"library",
|
"library",
|
||||||
@@ -64,10 +82,52 @@ INSTALLED_APPS = [
|
|||||||
# --- MCP Server ---
|
# --- MCP Server ---
|
||||||
MCP_REQUIRE_AUTH = env.bool("MCP_REQUIRE_AUTH", default=True)
|
MCP_REQUIRE_AUTH = env.bool("MCP_REQUIRE_AUTH", default=True)
|
||||||
|
|
||||||
|
# --- Authentication backends ---
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
"allauth.account.auth_backends.AuthenticationBackend",
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Allauth ---
|
||||||
|
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
|
||||||
|
SOCIALACCOUNT_AUTO_SIGNUP = True
|
||||||
|
SOCIALACCOUNT_EMAIL_VERIFICATION = "none"
|
||||||
|
SOCIALACCOUNT_QUERY_EMAIL = True
|
||||||
|
SOCIALACCOUNT_STORE_TOKENS = True
|
||||||
|
SOCIALACCOUNT_ADAPTER = "themis.adapters.CasdoorAccountAdapter"
|
||||||
|
ACCOUNT_ADAPTER = "themis.adapters.LocalAccountAdapter"
|
||||||
|
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
||||||
|
SESSION_COOKIE_AGE = 28800
|
||||||
|
SESSION_SAVE_EVERY_REQUEST = True
|
||||||
|
|
||||||
|
if CASDOOR_ENABLED:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"allauth.account.middleware.AccountMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
@@ -257,9 +317,9 @@ REST_FRAMEWORK = {
|
|||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# --- Login URLs ---
|
# --- Login URLs ---
|
||||||
LOGIN_URL = "/login/"
|
LOGIN_URL = "/accounts/login/"
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/dashboard/"
|
||||||
LOGOUT_REDIRECT_URL = "/"
|
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
|
||||||
|
|
||||||
# --- Embedding Pipeline (Phase 2) ---
|
# --- Embedding Pipeline (Phase 2) ---
|
||||||
EMBEDDING_BATCH_SIZE = env.int("EMBEDDING_BATCH_SIZE", default=8)
|
EMBEDDING_BATCH_SIZE = env.int("EMBEDDING_BATCH_SIZE", default=8)
|
||||||
|
|||||||
92
mnemosyne/mnemosyne/templates/account/login.html
Normal file
92
mnemosyne/mnemosyne/templates/account/login.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends "themis/base.html" %}
|
||||||
|
{% load socialaccount %}
|
||||||
|
|
||||||
|
{% block title %}Log In — {{ themis_app_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex justify-center items-center min-h-[60vh]">
|
||||||
|
<div class="card bg-base-200 shadow-xl w-full max-w-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl justify-center mb-4">Log In</h2>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags|default:'info' }} mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if CASDOOR_ENABLED %}
|
||||||
|
<form method="post" action="{% provider_login_url 'casdoor' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-control mt-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-full">Sign in with SSO</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if ALLOW_LOCAL_LOGIN or user.is_superuser %}
|
||||||
|
|
||||||
|
{% if CASDOOR_ENABLED %}
|
||||||
|
<div class="divider">or</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-error mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Invalid email or password. Please try again.</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'account_login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label" for="id_login">
|
||||||
|
<span class="label-text">Email</span>
|
||||||
|
</label>
|
||||||
|
<input type="email" name="login" id="id_login"
|
||||||
|
class="input input-bordered w-full{% if form.errors %} input-error{% endif %}"
|
||||||
|
value="{{ form.login.value|default:'' }}"
|
||||||
|
autofocus required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-6">
|
||||||
|
<label class="label" for="id_password">
|
||||||
|
<span class="label-text">Password</span>
|
||||||
|
</label>
|
||||||
|
<input type="password" name="password" id="id_password"
|
||||||
|
class="input input-bordered w-full{% if form.errors %} input-error{% endif %}"
|
||||||
|
required>
|
||||||
|
<label class="label">
|
||||||
|
<a href="{% url 'account_reset_password' %}" class="label-text-alt link link-hover">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="form-control mt-2">
|
||||||
|
<button type="submit" class="btn btn-outline w-full">Sign In Locally</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -14,8 +14,8 @@ urlpatterns = [
|
|||||||
# Landing / Dashboard
|
# Landing / Dashboard
|
||||||
path("", views.landing, name="landing"),
|
path("", views.landing, name="landing"),
|
||||||
path("dashboard/", views.dashboard, name="dashboard"),
|
path("dashboard/", views.dashboard, name="dashboard"),
|
||||||
# Django auth (login, logout, password reset)
|
# Allauth (SSO + local login/logout/password reset)
|
||||||
path("", include("django.contrib.auth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
# Admin
|
# Admin
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
# Prometheus metrics
|
# Prometheus metrics
|
||||||
|
|||||||
117
mnemosyne/themis/adapters.py
Normal file
117
mnemosyne/themis/adapters.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
|
from allauth.exceptions import ImmediateHttpResponse
|
||||||
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CasdoorAccountAdapter(DefaultSocialAccountAdapter):
|
||||||
|
|
||||||
|
def is_open_for_signup(self, request, sociallogin):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def pre_social_login(self, request, sociallogin):
|
||||||
|
"""
|
||||||
|
Runs on every SSO login (new and returning users).
|
||||||
|
|
||||||
|
Blocks superusers (they must use local auth) and re-syncs group
|
||||||
|
and is_staff claims for returning users so IdP changes take effect
|
||||||
|
immediately without requiring a re-registration.
|
||||||
|
"""
|
||||||
|
if sociallogin.user.id:
|
||||||
|
user = sociallogin.user
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
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):
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
user.is_superuser = False
|
||||||
|
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
# Mnemosyne has no Organization model — no-op, preserved for future use.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _assign_groups(self, user, group_names):
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalAccountAdapter(DefaultAccountAdapter):
|
||||||
|
|
||||||
|
def is_open_for_signup(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def authentication_failed(self, request, **kwargs):
|
||||||
|
logger.warning(
|
||||||
|
f"Local authentication failed from {request.META.get('REMOTE_ADDR')}"
|
||||||
|
)
|
||||||
|
super().authentication_failed(request, **kwargs)
|
||||||
@@ -17,12 +17,16 @@ def themis_settings(request):
|
|||||||
Context variables:
|
Context variables:
|
||||||
themis_app_name: Application display name
|
themis_app_name: Application display name
|
||||||
themis_notification_poll_interval: Polling interval in seconds (0=disabled)
|
themis_notification_poll_interval: Polling interval in seconds (0=disabled)
|
||||||
|
CASDOOR_ENABLED: Whether SSO is active
|
||||||
|
ALLOW_LOCAL_LOGIN: Whether the local login form is shown to non-superusers
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"themis_app_name": getattr(settings, "THEMIS_APP_NAME", "Application"),
|
"themis_app_name": getattr(settings, "THEMIS_APP_NAME", "Application"),
|
||||||
"themis_notification_poll_interval": getattr(
|
"themis_notification_poll_interval": getattr(
|
||||||
settings, "THEMIS_NOTIFICATION_POLL_INTERVAL", 60
|
settings, "THEMIS_NOTIFICATION_POLL_INTERVAL", 60
|
||||||
),
|
),
|
||||||
|
"CASDOOR_ENABLED": getattr(settings, "CASDOOR_ENABLED", False),
|
||||||
|
"ALLOW_LOCAL_LOGIN": getattr(settings, "ALLOW_LOCAL_LOGIN", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
44
mnemosyne/themis/management/commands/create_sso_groups.py
Normal file
44
mnemosyne/themis/management/commands/create_sso_groups.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
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"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
models_to_permission = [
|
||||||
|
# themis
|
||||||
|
"userprofile",
|
||||||
|
"userapikey",
|
||||||
|
"usernotification",
|
||||||
|
# library
|
||||||
|
"ingestjob",
|
||||||
|
# llm_manager
|
||||||
|
"llmapi",
|
||||||
|
"llmmodel",
|
||||||
|
"llmusage",
|
||||||
|
]
|
||||||
|
|
||||||
|
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"))
|
||||||
29
mnemosyne/themis/ssl_patch.py
Normal file
29
mnemosyne/themis/ssl_patch.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import os
|
||||||
|
import 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()
|
||||||
130
mnemosyne/themis/tests/test_adapters.py
Normal file
130
mnemosyne/themis/tests/test_adapters.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group, User
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from themis.adapters import CasdoorAccountAdapter, LocalAccountAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class CasdoorAdapterTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.adapter = CasdoorAccountAdapter()
|
||||||
|
|
||||||
|
def test_signup_always_open(self):
|
||||||
|
self.assertTrue(self.adapter.is_open_for_signup(MagicMock(), MagicMock()))
|
||||||
|
|
||||||
|
def test_superuser_never_set_via_sso(self):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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_view_only_not_staff(self):
|
||||||
|
sociallogin = MagicMock()
|
||||||
|
data = {"email": "viewer@example.com", "groups": ["view_only"]}
|
||||||
|
user = self.adapter.populate_user(MagicMock(), sociallogin, data)
|
||||||
|
self.assertFalse(user.is_staff)
|
||||||
|
|
||||||
|
def test_name_fallback_parsing(self):
|
||||||
|
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_name_fallback_single_word(self):
|
||||||
|
sociallogin = MagicMock()
|
||||||
|
data = {"email": "user@example.com", "name": "Cher"}
|
||||||
|
user = self.adapter.populate_user(MagicMock(), sociallogin, data)
|
||||||
|
self.assertEqual(user.first_name, "Cher")
|
||||||
|
self.assertEqual(user.last_name, "")
|
||||||
|
|
||||||
|
def test_given_family_name_takes_precedence_over_name(self):
|
||||||
|
sociallogin = MagicMock()
|
||||||
|
data = {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"given_name": "Alice",
|
||||||
|
"family_name": "Smith",
|
||||||
|
"name": "Should Not Use This",
|
||||||
|
}
|
||||||
|
user = self.adapter.populate_user(MagicMock(), sociallogin, data)
|
||||||
|
self.assertEqual(user.first_name, "Alice")
|
||||||
|
self.assertEqual(user.last_name, "Smith")
|
||||||
|
|
||||||
|
def test_group_mapping(self):
|
||||||
|
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_group_mapping_clears_previous_groups(self):
|
||||||
|
Group.objects.create(name="Admin")
|
||||||
|
Group.objects.create(name="View Only")
|
||||||
|
user = User.objects.create_user("test@example.com", "test@example.com")
|
||||||
|
user.groups.add(Group.objects.get(name="Admin"))
|
||||||
|
self.adapter._assign_groups(user, ["view_only"])
|
||||||
|
group_names = set(user.groups.values_list("name", flat=True))
|
||||||
|
self.assertEqual(group_names, {"View Only"})
|
||||||
|
|
||||||
|
def test_unknown_casdoor_group_ignored(self):
|
||||||
|
user = User.objects.create_user("test@example.com", "test@example.com")
|
||||||
|
self.adapter._assign_groups(user, ["unknown_role"])
|
||||||
|
self.assertEqual(user.groups.count(), 0)
|
||||||
|
|
||||||
|
def test_superuser_sso_login_blocked(self):
|
||||||
|
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):
|
||||||
|
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"})
|
||||||
|
|
||||||
|
def test_new_user_skips_pre_social_login_sync(self):
|
||||||
|
sociallogin = MagicMock()
|
||||||
|
sociallogin.user.id = None
|
||||||
|
# Should not raise or attempt any DB operations
|
||||||
|
self.adapter.pre_social_login(MagicMock(), sociallogin)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalAdapterTest(TestCase):
|
||||||
|
|
||||||
|
def test_local_signup_disabled(self):
|
||||||
|
adapter = LocalAccountAdapter()
|
||||||
|
self.assertFalse(adapter.is_open_for_signup(MagicMock()))
|
||||||
@@ -36,6 +36,8 @@ dependencies = [
|
|||||||
"uvicorn[standard]>=0.30,<1.0",
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
# Phase 6: Per-turn signed JWTs from Daedalus
|
# Phase 6: Per-turn signed JWTs from Daedalus
|
||||||
"PyJWT>=2.8,<3.0",
|
"PyJWT>=2.8,<3.0",
|
||||||
|
# SSO
|
||||||
|
"django-allauth[openid_connect]>=65.0,<66.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user