feat: cas
This commit is contained in:
@@ -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'
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -20,62 +20,64 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# 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!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"accounts",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
# Removed CASMiddleware to avoid conflicts with our custom backend
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'opus_submitter.urls'
|
||||
ROOT_URLCONF = "opus_submitter.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'opus_submitter.wsgi.application'
|
||||
WSGI_APPLICATION = "opus_submitter.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,16 +87,16 @@ DATABASES = {
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -114,9 +116,27 @@ USE_TZ = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Default primary key field type
|
||||
# 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 = "/"
|
||||
|
||||
@@ -14,9 +14,14 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||
|
||||
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user