feat: cas

This commit is contained in:
Loïc Gremaud 2025-10-29 00:50:26 +01:00
parent 578a5af3df
commit 498a1063f7
14 changed files with 473 additions and 37 deletions

121
README.md Normal file
View File

@ -0,0 +1,121 @@
# Opus Magnum Submitter with CAS Authentication
A simple Django application that demonstrates CAS (Central Authentication Service) integration with PolyLAN's CAS server at https://polylan.ch/cas/.
## Features
- 🔐 CAS authentication with PolyLAN
- 👤 Automatic user creation with custom attributes
- 🏷️ CAS groups and permissions storage
- 🏠 Protected home page requiring authentication
- 🌍 Public page accessible without authentication
- 🚪 Clean login/logout functionality
- 📱 Responsive web interface
- 🛠️ Admin interface for user management
## Quick Start
1. **Install dependencies:**
```bash
pip install -e .
```
2. **Run database migrations:**
```bash
cd opus_submitter
uv run manage.py migrate
```
3. **Create a superuser (optional, for admin access):**
```bash
uv run manage.py createsuperuser
```
4. **Start the development server:**
```bash
uv run manage.py runserver localhost:7777
```
5. **Access the application:**
- Open your browser to http://localhost:7777/
- Try the public page: http://localhost:7777/public/
- Login with CAS to access protected features
- Admin interface: http://localhost:7777/admin/ (requires superuser)
## How It Works
### Authentication Flow
1. User visits a protected page (e.g., home page)
2. Django redirects to `/cas/login/`
3. CAS redirects to PolyLAN CAS server: `https://polylan.ch/cas/login`
4. User enters credentials on PolyLAN
5. CAS validates credentials and redirects back with a service ticket
6. Django validates the ticket with the CAS server
7. User is authenticated and redirected to the requested page
### Configuration
The CAS configuration is in `opus_submitter/settings.py`:
```python
# CAS Authentication Settings
CAS_SERVER_URL = 'https://polylan.ch/cas/'
CAS_VERSION = '3'
CAS_CREATE_USER = True
CAS_LOGOUT_COMPLETELY = True
```
### URLs
- `/` - Protected home page (requires authentication)
- `/public/` - Public page (no authentication required)
- `/cas/login/` - CAS login endpoint
- `/cas/logout/` - CAS logout endpoint
- `/admin/` - Django admin (requires staff privileges)
## Project Structure
```
opus_submitter/
├── manage.py
├── opus_submitter/
│ ├── __init__.py
│ ├── settings.py # Django settings with CAS configuration
│ ├── urls.py # URL routing with CAS endpoints
│ ├── wsgi.py
│ └── asgi.py
└── templates/
├── base.html # Base template with navigation
├── home.html # Protected home page
└── public.html # Public page
```
## Dependencies
- Django 5.2.7+
- django-cas-ng 5.0.1+ (CAS client for Django)
- requests 2.31.0+ (HTTP library for CAS communication)
## Development
To modify the CAS configuration:
1. Edit `CAS_SERVER_URL` in `settings.py` if using a different CAS server
2. Adjust `CAS_VERSION` if needed (supports CAS 1.0, 2.0, and 3.0)
3. Set `CAS_CREATE_USER = False` if you don't want automatic user creation
## Testing
1. Visit http://127.0.0.1:8000/public/ (should work without login)
2. Visit http://127.0.0.1:8000/ (should redirect to CAS login)
3. Login with your PolyLAN credentials
4. Verify you're redirected back and can see user information
5. Test logout functionality
## Notes
- This is a development setup with `DEBUG = True`
- For production, update `SECRET_KEY`, set `DEBUG = False`, and configure `ALLOWED_HOSTS`
- The application automatically creates Django users from CAS authentication
- User information is populated from CAS attributes when available

View File

View File

@ -0,0 +1,32 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import CustomUser
@admin.register(CustomUser)
class CustomUserAdmin(UserAdmin):
"""Admin interface for CustomUser."""
# Add custom fields to the user admin
fieldsets = UserAdmin.fieldsets + (
('CAS Information', {
'fields': ('cas_user_id', 'cas_groups', 'cas_attributes'),
}),
)
# Add custom fields to the list display
list_display = UserAdmin.list_display + ('cas_user_id', 'get_cas_groups_display')
# Add search fields
search_fields = UserAdmin.search_fields + ('cas_user_id',)
# Add filters
list_filter = UserAdmin.list_filter + ('cas_groups',)
# Make CAS fields readonly in admin
readonly_fields = ('cas_user_id', 'cas_groups', 'cas_attributes')
def get_cas_groups_display(self, obj):
"""Display CAS groups in admin list."""
return obj.get_cas_groups_display()
get_cas_groups_display.short_description = 'CAS Groups'

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'

View File

@ -0,0 +1,47 @@
# Generated by Django 5.2.7 on 2025-10-28 23:41
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('cas_user_id', models.CharField(blank=True, max_length=50, null=True, unique=True)),
('cas_groups', models.JSONField(blank=True, default=list)),
('cas_attributes', models.JSONField(blank=True, default=dict)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@ -0,0 +1,60 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
import json
class CustomUser(AbstractUser):
"""Custom User model to store CAS attributes from PolyLAN."""
# Store CAS user ID (the numeric ID from CAS)
cas_user_id = models.CharField(max_length=50, blank=True, null=True, unique=True)
# Store CAS groups as JSON
cas_groups = models.JSONField(default=list, blank=True)
# Additional fields that might come from CAS
cas_attributes = models.JSONField(default=dict, blank=True)
def __str__(self):
return f"{self.username} ({self.cas_user_id})"
def has_cas_group(self, group_name):
"""Check if user has a specific CAS group."""
return group_name in self.cas_groups
def get_cas_groups_display(self):
"""Get a comma-separated list of CAS groups for display."""
return ", ".join(self.cas_groups) if self.cas_groups else "No groups"
def update_cas_data(self, cas_user_id, attributes):
"""Update user with CAS data."""
self.cas_user_id = cas_user_id
# Update basic fields from CAS attributes
if "firstname" in attributes:
self.first_name = attributes["firstname"]
if "lastname" in attributes:
self.last_name = attributes["lastname"]
if "email" in attributes:
self.email = attributes["email"]
# Store groups
if "groups" in attributes:
# CAS groups come as a list or single value
groups = attributes["groups"]
if isinstance(groups, str):
self.cas_groups = [groups]
elif isinstance(groups, list):
self.cas_groups = groups
else:
self.cas_groups = []
if "RESPONSABLE_ANIMATION" in self.cas_groups:
self.is_staff = True
self.is_superuser = True
# Store all other attributes
self.cas_attributes = attributes
self.save()

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@ -20,62 +20,64 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-m0ivj_3gpbf281jl1$&n5pdo!5le(bp4z31u(1&4s=n#!tpy=n' SECRET_KEY = "django-insecure-m0ivj_3gpbf281jl1$&n5pdo!5le(bp4z31u(1&4s=n#!tpy=n"
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
"accounts",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'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",
'django.contrib.messages.middleware.MessageMiddleware', # Removed CASMiddleware to avoid conflicts with our custom backend
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = 'opus_submitter.urls' ROOT_URLCONF = "opus_submitter.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [], "DIRS": [BASE_DIR / "templates"],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'opus_submitter.wsgi.application' WSGI_APPLICATION = "opus_submitter.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': BASE_DIR / 'db.sqlite3', "NAME": BASE_DIR / "db.sqlite3",
} }
} }
@ -85,16 +87,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, },
] ]
@ -102,9 +104,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
TIME_ZONE = 'UTC' TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True
@ -114,9 +116,27 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = "static/"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Custom User Model
AUTH_USER_MODEL = "accounts.CustomUser"
# Simple CAS Configuration
CAS_SERVER_URL = "https://polylan.ch/cas/"
# Authentication backends
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"simple_cas_backend.SimpleCASBackend",
]
# Login/Logout URLs
LOGIN_URL = "/cas/login/"
LOGOUT_URL = "/cas/logout/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"

View File

@ -14,9 +14,14 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path("admin/", admin.site.urls),
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
] ]

View File

@ -0,0 +1,92 @@
"""
Simple CAS 2.0 authentication backend - bare minimum implementation.
"""
import json
import requests
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend
class SimpleCASBackend(BaseBackend):
"""Simple CAS 2.0 authentication backend."""
def authenticate(self, request, ticket=None, service=None, **kwargs):
"""Authenticate user using CAS ticket."""
if not ticket or not service:
return None
# Validate ticket with CAS server
cas_user_id, attributes = self.validate_ticket(ticket, service)
if not cas_user_id:
return None
print(f"CAS User ID: {cas_user_id}")
print(f"CAS Attributes: {attributes}")
User = get_user_model()
# Try to find user by CAS user ID first, then by username
username = attributes.get("username", cas_user_id).lower()
try:
# First try to find by CAS user ID
user = User.objects.get(cas_user_id=cas_user_id)
except User.DoesNotExist:
try:
# Then try by username
user = User.objects.get(username=username)
# Update the CAS user ID if found by username
user.cas_user_id = cas_user_id
user.save()
except User.DoesNotExist:
# Create new user
user = User.objects.create_user(
username=username,
cas_user_id=cas_user_id,
first_name=attributes.get("firstname", ""),
last_name=attributes.get("lastname", ""),
email=attributes.get("email", ""),
)
# Always update CAS data on login
user.update_cas_data(cas_user_id, attributes)
return user
def validate_ticket(self, ticket, service):
"""Validate CAS ticket and return username and attributes."""
validate_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/serviceValidate"
params = {"ticket": ticket, "service": service, "format": "JSON"}
try:
response = requests.get(validate_url, params=params, timeout=10)
response.raise_for_status()
data = json.loads(response.text)
# Parse CAS 2.0 JSON response
service_response = data.get("serviceResponse", {})
auth_success = service_response.get("authenticationSuccess")
if auth_success:
cas_user_id = auth_success.get("user", "")
attributes = auth_success.get("attributes", {})
return cas_user_id, attributes
return None, None
except Exception as e:
print(f"CAS validation error: {e}")
return None, None
def get_user(self, user_id):
"""Get user by ID."""
User = get_user_model()
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None

View File

@ -0,0 +1,46 @@
"""
Simple CAS views - bare minimum implementation.
"""
from django.conf import settings
from django.contrib.auth import authenticate, login, logout
from django.shortcuts import redirect
from django.http import HttpResponse
from django.views import View
import urllib.parse
class SimpleCASLoginView(View):
"""Simple CAS login view."""
def get(self, request):
ticket = request.GET.get('ticket')
if ticket:
# Coming back from CAS with ticket - validate it
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
user = authenticate(request=request, ticket=ticket, service=service_url)
if user:
login(request, user)
return redirect(settings.LOGIN_REDIRECT_URL)
else:
return HttpResponse("Authentication failed", status=401)
else:
# No ticket - redirect to CAS
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
cas_login_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/login?service={urllib.parse.quote(service_url)}"
return redirect(cas_login_url)
class SimpleCASLogoutView(View):
"""Simple CAS logout view."""
def get(self, request):
logout(request)
# Redirect to CAS logout
cas_logout_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/logout"
return redirect(cas_logout_url)

View File

@ -5,6 +5,7 @@ description = "Opus Magnum submitter with CAS authentication"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"django>=5.2.7", "django>=5.2.7",
"requests>=2.31.0",
] ]
[build-system] [build-system]