diff --git a/README.md b/README.md new file mode 100644 index 0000000..222634a --- /dev/null +++ b/README.md @@ -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 diff --git a/opus_submitter/accounts/__init__.py b/opus_submitter/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opus_submitter/accounts/admin.py b/opus_submitter/accounts/admin.py new file mode 100644 index 0000000..d55d104 --- /dev/null +++ b/opus_submitter/accounts/admin.py @@ -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' \ No newline at end of file diff --git a/opus_submitter/accounts/apps.py b/opus_submitter/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/opus_submitter/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/opus_submitter/accounts/migrations/0001_initial.py b/opus_submitter/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..60523cb --- /dev/null +++ b/opus_submitter/accounts/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/opus_submitter/accounts/migrations/__init__.py b/opus_submitter/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opus_submitter/accounts/models.py b/opus_submitter/accounts/models.py new file mode 100644 index 0000000..c978a77 --- /dev/null +++ b/opus_submitter/accounts/models.py @@ -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() + diff --git a/opus_submitter/accounts/tests.py b/opus_submitter/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/opus_submitter/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/opus_submitter/accounts/views.py b/opus_submitter/accounts/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/opus_submitter/accounts/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/opus_submitter/opus_submitter/settings.py b/opus_submitter/opus_submitter/settings.py index 023d6ed..e0a7dfd 100644 --- a/opus_submitter/opus_submitter/settings.py +++ b/opus_submitter/opus_submitter/settings.py @@ -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 = "/" diff --git a/opus_submitter/opus_submitter/urls.py b/opus_submitter/opus_submitter/urls.py index 614aa84..30ebbdf 100644 --- a/opus_submitter/opus_submitter/urls.py +++ b/opus_submitter/opus_submitter/urls.py @@ -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"), ] diff --git a/opus_submitter/simple_cas_backend.py b/opus_submitter/simple_cas_backend.py new file mode 100644 index 0000000..1f2ac60 --- /dev/null +++ b/opus_submitter/simple_cas_backend.py @@ -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 diff --git a/opus_submitter/simple_cas_views.py b/opus_submitter/simple_cas_views.py new file mode 100644 index 0000000..45aa39f --- /dev/null +++ b/opus_submitter/simple_cas_views.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 5f1b633..14d68cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Opus Magnum submitter with CAS authentication" requires-python = ">=3.10" dependencies = [ "django>=5.2.7", + "requests>=2.31.0", ] [build-system]