From 06bc64b2c4b97ae6abf26ba53514546af6f6b9a3 Mon Sep 17 00:00:00 2001 From: Markos Gogoulos Date: Mon, 29 Dec 2025 18:21:44 +0200 Subject: [PATCH] all --- ..._is_lms_course_category_lti_context_id.py} | 11 +-- .../migrations/0016_category_lti_platform.py | 21 +++++ lti/adapters.py | 41 ++++----- lti/admin.py | 67 +++++++++++---- lti/apps.py | 12 ++- lti/deep_linking.py | 9 +- lti/handlers.py | 4 +- lti/keys.py | 38 ++++++++ lti/migrations/0001_initial.py | 33 +++---- lti/models.py | 86 +++++++++++++++++-- lti/views.py | 31 +++---- 11 files changed, 249 insertions(+), 104 deletions(-) rename files/migrations/{0015_category_is_lms_course_category_lti_context_id_and_more.py => 0015_category_is_lms_course_category_lti_context_id.py} (59%) mode change 100755 => 100644 create mode 100644 files/migrations/0016_category_lti_platform.py create mode 100644 lti/keys.py diff --git a/files/migrations/0015_category_is_lms_course_category_lti_context_id_and_more.py b/files/migrations/0015_category_is_lms_course_category_lti_context_id.py old mode 100755 new mode 100644 similarity index 59% rename from files/migrations/0015_category_is_lms_course_category_lti_context_id_and_more.py rename to files/migrations/0015_category_is_lms_course_category_lti_context_id.py index 93a1974a..e1112c5d --- a/files/migrations/0015_category_is_lms_course_category_lti_context_id_and_more.py +++ b/files/migrations/0015_category_is_lms_course_category_lti_context_id.py @@ -1,13 +1,11 @@ -# Generated by Django 5.2.6 on 2025-12-24 15:08 +# Generated by Django 5.2.6 on 2025-12-29 16:15 -import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('files', '0014_alter_subtitle_options_and_more'), - ('lti', '__first__'), ] operations = [ @@ -21,11 +19,4 @@ class Migration(migrations.Migration): name='lti_context_id', field=models.CharField(blank=True, db_index=True, help_text='LTI context ID from platform', max_length=255), ), - migrations.AddField( - model_name='category', - name='lti_platform', - field=models.ForeignKey( - blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform' - ), - ), ] diff --git a/files/migrations/0016_category_lti_platform.py b/files/migrations/0016_category_lti_platform.py new file mode 100644 index 00000000..3a5a6014 --- /dev/null +++ b/files/migrations/0016_category_lti_platform.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.6 on 2025-12-29 16:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('files', '0015_category_is_lms_course_category_lti_context_id'), + ('lti', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='category', + name='lti_platform', + field=models.ForeignKey( + blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform' + ), + ), + ] diff --git a/lti/adapters.py b/lti/adapters.py index 6e26a3b2..faa5ae4d 100644 --- a/lti/adapters.py +++ b/lti/adapters.py @@ -8,10 +8,15 @@ import json from typing import Any, Dict, Optional from django.core.cache import cache +from pylti1p3.message_launch import MessageLaunch +from pylti1p3.oidc_login import OIDCLogin from pylti1p3.registration import Registration from pylti1p3.request import Request from pylti1p3.tool_config import ToolConfAbstract +from .keys import load_private_key +from .models import LTIPlatform + class DjangoRequest(Request): """Django request adapter for PyLTI1p3""" @@ -57,8 +62,6 @@ class DjangoOIDCLogin: def get_redirect(self, redirect_url): """Get the redirect object for OIDC login""" - from pylti1p3.oidc_login import OIDCLogin - oidc_login = OIDCLogin(self.lti_request, self.tool_config, session_service=self.launch_data_storage, cookie_service=self.launch_data_storage) return oidc_login.enable_check_cookies().redirect(redirect_url) @@ -75,7 +78,6 @@ class DjangoMessageLaunch: def validate(self): """Validate the LTI launch message""" - from pylti1p3.message_launch import MessageLaunch # Create custom MessageLaunch that properly implements _get_request_param class CustomMessageLaunch(MessageLaunch): @@ -224,8 +226,6 @@ class DjangoToolConfig(ToolConfAbstract): if config.get('auth_audience'): registration.set_auth_audience(config.get('auth_audience')) registration.set_key_set_url(config.get('key_set_url')) - if config.get('key_set'): - registration.set_key_set(config.get('key_set')) return registration @@ -247,8 +247,6 @@ class DjangoToolConfig(ToolConfAbstract): if config.get('auth_audience'): registration.set_auth_audience(config.get('auth_audience')) registration.set_key_set_url(config.get('key_set_url')) - if config.get('key_set'): - registration.set_key_set(config.get('key_set')) return registration @@ -280,25 +278,26 @@ class DjangoToolConfig(ToolConfAbstract): return self.find_registration_by_params(iss, client_id) def get_jwks(self, iss, client_id=None): - """Get JWKS from configuration""" - if iss not in self._config: - return None - - config_dict = self._config[iss] - if client_id and config_dict.get('client_id') != client_id: - return None - - return config_dict.get('key_set') + """Get JWKS from configuration - returns None to fetch from URL""" + # No caching - PyLTI1p3 will fetch from key_set_url + return None def get_iss(self): """Get all issuers""" return list(self._config.keys()) + def get_jwk(self, iss=None, client_id=None): + """ + Get private JWK for signing Deep Linking responses + + PyLTI1p3 calls this to get the tool's private key for signing + """ + # Return MediaCMS's private key (same for all platforms) + return load_private_key() + @classmethod def from_platform(cls, platform): """Create ToolConfig from LTIPlatform model instance""" - from .models import LTIPlatform - if isinstance(platform, LTIPlatform): config = {platform.platform_id: platform.get_lti_config()} return cls(config) @@ -307,10 +306,8 @@ class DjangoToolConfig(ToolConfAbstract): @classmethod def from_all_platforms(cls): - """Create ToolConfig with all active platforms""" - from .models import LTIPlatform - - platforms = LTIPlatform.objects.filter(active=True) + """Create ToolConfig with all platforms""" + platforms = LTIPlatform.objects.filter() config = {} for platform in platforms: diff --git a/lti/admin.py b/lti/admin.py index 1f997c59..fd92ce65 100644 --- a/lti/admin.py +++ b/lti/admin.py @@ -10,6 +10,7 @@ from .models import ( LTIPlatform, LTIResourceLink, LTIRoleMapping, + LTIToolKeys, LTIUserMapping, ) from .services import LTINRPSClient @@ -19,28 +20,20 @@ from .services import LTINRPSClient class LTIPlatformAdmin(admin.ModelAdmin): """Admin for LTI Platforms (Moodle instances)""" - list_display = ['name', 'platform_id', 'client_id', 'active_badge', 'nrps_enabled', 'deep_linking_enabled', 'created_at'] - list_filter = ['active', 'enable_nrps', 'enable_deep_linking', 'created_at'] + list_display = ['name', 'platform_id', 'client_id', 'nrps_enabled', 'deep_linking_enabled', 'created_at'] + list_filter = ['enable_nrps', 'enable_deep_linking', 'created_at'] search_fields = ['name', 'platform_id', 'client_id'] - readonly_fields = ['created_at', 'updated_at', 'key_set_updated'] + readonly_fields = ['created_at', 'updated_at'] fieldsets = ( - ('Basic Information', {'fields': ('name', 'platform_id', 'client_id', 'active')}), + ('Basic Information', {'fields': ('name', 'platform_id', 'client_id')}), ('OIDC Endpoints', {'fields': ('auth_login_url', 'auth_token_url', 'auth_audience')}), - ('JWK Configuration', {'fields': ('key_set_url', 'key_set', 'key_set_updated'), 'classes': ('collapse',)}), + ('JWK Configuration', {'fields': ('key_set_url',), 'classes': ('collapse',)}), ('Deployment & Features', {'fields': ('deployment_ids', 'enable_nrps', 'enable_deep_linking')}), - ('Auto-Provisioning Settings', {'fields': ('auto_create_categories', 'auto_create_users', 'auto_sync_roles', 'remove_from_groups_on_unenroll')}), + ('Auto-Provisioning Settings', {'fields': ('remove_from_groups_on_unenroll',)}), ('Timestamps', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}), ) - def active_badge(self, obj): - """Display active status as badge""" - if obj.active: - return format_html('✓ Active') - return format_html('✗ Inactive') - - active_badge.short_description = 'Status' - def nrps_enabled(self, obj): return '✓' if obj.enable_nrps else '✗' @@ -171,14 +164,14 @@ class LTIRoleMappingAdmin(admin.ModelAdmin): class LTILaunchLogAdmin(admin.ModelAdmin): """Admin for LTI Launch Logs""" - list_display = ['created_at', 'platform', 'user_link', 'launch_type', 'success_badge', 'ip_address'] + list_display = ['created_at', 'platform', 'user_link', 'launch_type', 'success_badge'] list_filter = ['success', 'launch_type', 'platform', 'created_at'] - search_fields = ['user__username', 'ip_address', 'error_message'] + search_fields = ['user__username', 'error_message'] readonly_fields = ['created_at', 'claims'] date_hierarchy = 'created_at' fieldsets = ( - ('Launch Info', {'fields': ('platform', 'user', 'resource_link', 'launch_type', 'success', 'ip_address', 'created_at')}), + ('Launch Info', {'fields': ('platform', 'user', 'resource_link', 'launch_type', 'success', 'created_at')}), ('Error Details', {'fields': ('error_message',), 'classes': ('collapse',)}), ('Claims Data', {'fields': ('claims',), 'classes': ('collapse',)}), ) @@ -204,3 +197,43 @@ class LTILaunchLogAdmin(admin.ModelAdmin): def has_change_permission(self, request, obj=None): """Make launch logs read-only""" return False + + +@admin.register(LTIToolKeys) +class LTIToolKeysAdmin(admin.ModelAdmin): + """Admin for LTI Tool RSA Keys""" + + list_display = ['key_id', 'created_at', 'updated_at'] + readonly_fields = ['key_id', 'created_at', 'updated_at', 'public_key_display'] + + fieldsets = ( + ('Key Information', {'fields': ('key_id', 'created_at', 'updated_at')}), + ('Public Key (for JWKS)', {'fields': ('public_key_display',)}), + ('Private Key (Keep Secure!)', {'fields': ('private_key_jwk',), 'classes': ('collapse',), 'description': '⚠️ This is your private signing key. Do not share it!'}), + ) + + actions = ['regenerate_keys'] + + def public_key_display(self, obj): + """Display public key in readable format""" + import json + + return format_html('
{}
', json.dumps(obj.public_key_jwk, indent=2)) + + public_key_display.short_description = 'Public Key (JWK)' + + def regenerate_keys(self, request, queryset): + """Regenerate keys for selected instances""" + for key_obj in queryset: + key_obj.generate_keys() + self.message_user(request, f"Keys regenerated for {key_obj.key_id}", messages.SUCCESS) + + regenerate_keys.short_description = 'Regenerate RSA keys' + + def has_add_permission(self, request): + """Only allow one key pair - disable manual add if exists""" + return not LTIToolKeys.objects.exists() + + def has_delete_permission(self, request, obj=None): + """Prevent accidental deletion of keys""" + return False diff --git a/lti/apps.py b/lti/apps.py index c4b4a1ff..03735307 100644 --- a/lti/apps.py +++ b/lti/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig +from .keys import ensure_keys_exist + class LtiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' @@ -7,6 +9,10 @@ class LtiConfig(AppConfig): verbose_name = 'LTI 1.3 Integration' def ready(self): - """Import signal handlers when app is ready""" - # Import any signals here if needed in the future - pass + """Initialize LTI app - ensure keys exist""" + # Ensure RSA key pair exists for signing Deep Linking responses + try: + ensure_keys_exist() + except Exception: + # Don't block startup if key generation fails + pass diff --git a/lti/deep_linking.py b/lti/deep_linking.py index b8761aa3..bebb12b9 100644 --- a/lti/deep_linking.py +++ b/lti/deep_linking.py @@ -4,6 +4,8 @@ LTI Deep Linking 2.0 for MediaCMS Allows instructors to select media from MediaCMS library and embed in Moodle courses """ +import traceback + from django.contrib.auth.decorators import login_required from django.http import JsonResponse from django.shortcuts import render @@ -11,6 +13,8 @@ from django.urls import reverse from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt +from pylti1p3.deep_link import DeepLink +from pylti1p3.deep_link_resource import DeepLinkResource from files.models import Media from files.views.media import MediaList @@ -125,9 +129,6 @@ class SelectMediaView(View): """ Create JWT response for deep linking using PyLTI1p3 """ - from pylti1p3.deep_link import DeepLink - from pylti1p3.deep_link_resource import DeepLinkResource - try: platform_id = deep_link_data['platform_id'] platform = LTIPlatform.objects.get(id=platform_id) @@ -182,7 +183,5 @@ class SelectMediaView(View): except Exception as e: # Log error for debugging - import traceback - traceback.print_exc() raise ValueError(f"Failed to create Deep Linking JWT: {str(e)}") diff --git a/lti/handlers.py b/lti/handlers.py index 2eaa4dc1..089f3589 100644 --- a/lti/handlers.py +++ b/lti/handlers.py @@ -260,9 +260,7 @@ def apply_lti_roles(user, platform, lti_roles, rbac_group): if role_global: global_role = get_higher_privilege_global(global_role, role_global) - # Apply global role if auto_sync_roles is enabled - if platform.auto_sync_roles: - user.set_role_from_mapping(global_role) + user.set_role_from_mapping(global_role) # Determine group role group_role = 'member' diff --git a/lti/keys.py b/lti/keys.py new file mode 100644 index 00000000..d291a783 --- /dev/null +++ b/lti/keys.py @@ -0,0 +1,38 @@ +""" +LTI Key Management for MediaCMS + +Manages RSA keys for signing Deep Linking responses (stored in database) +""" + + +def load_private_key(): + """Load private key from database""" + from .models import LTIToolKeys + + key_obj = LTIToolKeys.get_or_create_keys() + return key_obj.private_key_jwk + + +def load_public_key(): + """Load public key from database""" + from .models import LTIToolKeys + + key_obj = LTIToolKeys.get_or_create_keys() + return key_obj.public_key_jwk + + +def get_jwks(): + """ + Get JWKS (JSON Web Key Set) for public keys + + Returns public keys in JWKS format for the /lti/jwks/ endpoint + """ + public_key = load_public_key() + return {'keys': [public_key]} + + +def ensure_keys_exist(): + """Ensure key pair exists in database, generate if not""" + from .models import LTIToolKeys + + LTIToolKeys.get_or_create_keys() diff --git a/lti/migrations/0001_initial.py b/lti/migrations/0001_initial.py index 34b01c59..8d33214d 100644 --- a/lti/migrations/0001_initial.py +++ b/lti/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-12-24 15:18 +# Generated by Django 5.2.6 on 2025-12-29 16:15 import django.db.models.deletion from django.conf import settings @@ -9,11 +9,27 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('files', '0015_category_is_lms_course_category_lti_context_id'), ('rbac', '0003_alter_rbacgroup_members'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='LTIToolKeys', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key_id', models.CharField(default='mediacms-lti-key', help_text='Key identifier', max_length=255, unique=True)), + ('private_key_jwk', models.JSONField(help_text='Private key in JWK format (for signing)')), + ('public_key_jwk', models.JSONField(help_text='Public key in JWK format (for JWKS endpoint)')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'LTI Tool Keys', + 'verbose_name_plural': 'LTI Tool Keys', + }, + ), migrations.CreateModel( name='LTIPlatform', fields=[ @@ -25,18 +41,12 @@ class Migration(migrations.Migration): ('auth_token_url', models.URLField(help_text='OAuth2 token endpoint URL')), ('auth_audience', models.URLField(blank=True, help_text='OAuth2 audience (optional)', null=True)), ('key_set_url', models.URLField(help_text="Platform's public JWK Set URL")), - ('key_set', models.JSONField(blank=True, help_text='Cached JWK Set (auto-fetched)', null=True)), - ('key_set_updated', models.DateTimeField(blank=True, help_text='Last time JWK Set was fetched', null=True)), ('deployment_ids', models.JSONField(default=list, help_text='List of deployment IDs for this platform')), ('enable_nrps', models.BooleanField(default=True, help_text='Enable Names and Role Provisioning Service')), ('enable_deep_linking', models.BooleanField(default=True, help_text='Enable Deep Linking 2.0')), - ('auto_create_categories', models.BooleanField(default=True, help_text='Automatically create categories for courses')), - ('auto_create_users', models.BooleanField(default=True, help_text='Automatically create users on first launch')), - ('auto_sync_roles', models.BooleanField(default=True, help_text='Automatically sync user roles from LTI')), ('remove_from_groups_on_unenroll', models.BooleanField(default=False, help_text="Remove users from RBAC groups when they're no longer in the course")), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('active', models.BooleanField(default=True, help_text='Whether this platform is currently active')), ], options={ 'verbose_name': 'LTI Platform', @@ -80,7 +90,6 @@ class Migration(migrations.Migration): ('success', models.BooleanField(db_index=True, default=True, help_text='Whether the launch was successful')), ('error_message', models.TextField(blank=True, help_text='Error message if launch failed')), ('claims', models.JSONField(help_text='Sanitized LTI claims from the launch')), - ('ip_address', models.GenericIPAddressField(blank=True, help_text='IP address of the user', null=True)), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), ( 'user', @@ -111,13 +120,7 @@ class Migration(migrations.Migration): 'global_role', models.CharField( blank=True, - choices=[ - ('user', 'Authenticated User'), - ('advancedUser', 'Advanced User'), - ('editor', 'MediaCMS Editor'), - ('manager', 'MediaCMS Manager'), - ('admin', 'MediaCMS Administrator'), - ], + choices=[('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')], help_text='MediaCMS global role to assign', max_length=20, ), diff --git a/lti/models.py b/lti/models.py index 2654368b..c7fd854c 100755 --- a/lti/models.py +++ b/lti/models.py @@ -1,4 +1,10 @@ +import json + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from django.db import models +from jwcrypto import jwk class LTIPlatform(models.Model): @@ -13,22 +19,16 @@ class LTIPlatform(models.Model): auth_audience = models.URLField(blank=True, null=True, help_text="OAuth2 audience (optional)") key_set_url = models.URLField(help_text="Platform's public JWK Set URL") - key_set = models.JSONField(blank=True, null=True, help_text="Cached JWK Set (auto-fetched)") - key_set_updated = models.DateTimeField(null=True, blank=True, help_text="Last time JWK Set was fetched") deployment_ids = models.JSONField(default=list, help_text="List of deployment IDs for this platform") enable_nrps = models.BooleanField(default=True, help_text="Enable Names and Role Provisioning Service") enable_deep_linking = models.BooleanField(default=True, help_text="Enable Deep Linking 2.0") - auto_create_categories = models.BooleanField(default=True, help_text="Automatically create categories for courses") - auto_create_users = models.BooleanField(default=True, help_text="Automatically create users on first launch") - auto_sync_roles = models.BooleanField(default=True, help_text="Automatically sync user roles from LTI") remove_from_groups_on_unenroll = models.BooleanField(default=False, help_text="Remove users from RBAC groups when they're no longer in the course") # Timestamps created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - active = models.BooleanField(default=True, help_text="Whether this platform is currently active") class Meta: verbose_name = 'LTI Platform' @@ -47,7 +47,6 @@ class LTIPlatform(models.Model): 'auth_token_url': self.auth_token_url, 'auth_audience': self.auth_audience, 'key_set_url': self.key_set_url, - 'key_set': self.key_set, 'deployment_ids': self.deployment_ids, } @@ -149,7 +148,6 @@ class LTILaunchLog(models.Model): error_message = models.TextField(blank=True, help_text="Error message if launch failed") claims = models.JSONField(help_text="Sanitized LTI claims from the launch") - ip_address = models.GenericIPAddressField(null=True, blank=True, help_text="IP address of the user") created_at = models.DateTimeField(auto_now_add=True, db_index=True) class Meta: @@ -165,3 +163,75 @@ class LTILaunchLog(models.Model): status = "✓" if self.success else "✗" user_str = self.user.username if self.user else "Unknown" return f"{status} {user_str} @ {self.platform.name} ({self.created_at.strftime('%Y-%m-%d %H:%M')})" + + +class LTIToolKeys(models.Model): + """ + Stores MediaCMS's RSA key pair for signing LTI responses (e.g., Deep Linking) + + Only one instance should exist (singleton pattern) + """ + + key_id = models.CharField(max_length=255, unique=True, default='mediacms-lti-key', help_text='Key identifier') + + # JWK format keys + private_key_jwk = models.JSONField(help_text='Private key in JWK format (for signing)') + public_key_jwk = models.JSONField(help_text='Public key in JWK format (for JWKS endpoint)') + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'LTI Tool Keys' + verbose_name_plural = 'LTI Tool Keys' + + def __str__(self): + return f"LTI Keys ({self.key_id})" + + @classmethod + def get_or_create_keys(cls): + """Get or create the default key pair""" + key_obj, created = cls.objects.get_or_create( + key_id='mediacms-lti-key', + defaults={'private_key_jwk': {}, 'public_key_jwk': {}}, # Will be populated by save() + ) + + # If keys are empty, generate them + if created or not key_obj.private_key_jwk or not key_obj.public_key_jwk: + key_obj.generate_keys() + + return key_obj + + def generate_keys(self): + """Generate new RSA key pair""" + # Generate RSA key pair + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) + + public_key = private_key.public_key() + + # Convert to PEM + private_pem = private_key.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption()) + + public_pem = public_key.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo) + + # Convert to JWK + private_jwk = jwk.JWK.from_pem(private_pem) + public_jwk = jwk.JWK.from_pem(public_pem) + + # Add metadata + private_jwk_dict = json.loads(private_jwk.export()) + private_jwk_dict['kid'] = self.key_id + private_jwk_dict['alg'] = 'RS256' + private_jwk_dict['use'] = 'sig' + + public_jwk_dict = json.loads(public_jwk.export_public()) + public_jwk_dict['kid'] = self.key_id + public_jwk_dict['alg'] = 'RS256' + public_jwk_dict['use'] = 'sig' + + # Save to database + self.private_key_jwk = private_jwk_dict + self.public_key_jwk = public_jwk_dict + self.save() + + return private_jwk_dict, public_jwk_dict diff --git a/lti/views.py b/lti/views.py index f9fad5c6..d5b0de2b 100644 --- a/lti/views.py +++ b/lti/views.py @@ -40,6 +40,7 @@ from .handlers import ( provision_lti_user, validate_lti_session, ) +from .keys import get_jwks from .models import LTILaunchLog, LTIPlatform, LTIResourceLink from .services import LTINRPSClient @@ -82,7 +83,7 @@ class OIDCLoginView(View): return JsonResponse({'error': 'Missing required OIDC parameters'}, status=400) # Get platform configuration - platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=client_id, active=True) + platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=client_id) # Create tool config for this platform tool_config = DjangoToolConfig.from_platform(platform) @@ -168,7 +169,7 @@ class LaunchView(View): aud = unverified.get('aud') # Get platform - platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=aud, active=True) + platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=aud) # Create tool config tool_config = DjangoToolConfig.from_platform(platform) @@ -193,7 +194,6 @@ class LaunchView(View): claims = self.sanitize_claims(launch_data) # Extract key claims - sub = launch_data.get('sub') resource_link = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {}) resource_link_id = resource_link.get('id', 'default') roles = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/roles', []) @@ -205,17 +205,7 @@ class LaunchView(View): # Deep linking request - handle separately return self.handle_deep_linking_launch(request, message_launch, platform, launch_data) - # Provision user - if platform.auto_create_users: - user = provision_lti_user(platform, launch_data) - else: - # Must find existing user - from .models import LTIUserMapping - - mapping = LTIUserMapping.objects.filter(platform=platform, lti_user_id=sub).first() - if not mapping: - raise ValueError("User auto-creation disabled and no existing mapping found") - user = mapping.user + user = provision_lti_user(platform, launch_data) # Provision context (category + RBAC group) if 'https://purl.imsglobal.org/spec/lti/claim/context' in launch_data: @@ -231,7 +221,7 @@ class LaunchView(View): create_lti_session(request, user, message_launch, platform) # Log successful launch - LTILaunchLog.objects.create(platform=platform, user=user, resource_link=resource_link_obj, launch_type='resource_link', success=True, claims=claims, ip_address=get_client_ip(request)) + LTILaunchLog.objects.create(platform=platform, user=user, resource_link=resource_link_obj, launch_type='resource_link', success=True, claims=claims) # Determine where to redirect redirect_url = self.determine_redirect(launch_data, resource_link_obj) @@ -245,7 +235,7 @@ class LaunchView(View): # Log failed launch if platform: - LTILaunchLog.objects.create(platform=platform, user=user, launch_type='resource_link', success=False, error_message=error_message, claims=claims, ip_address=get_client_ip(request)) + LTILaunchLog.objects.create(platform=platform, user=user, launch_type='resource_link', success=False, error_message=error_message, claims=claims) return render(request, 'lti/launch_error.html', {'error': 'LTI Launch Failed', 'message': error_message}, status=400) @@ -311,14 +301,13 @@ class JWKSView(View): """ JWKS Endpoint - Provides tool's public keys - Used by Moodle to validate signatures from MediaCMS + Used by Moodle to validate signatures from MediaCMS (e.g., Deep Linking responses) """ def get(self, request): """Return tool's public JWK Set""" - # For now, return empty JWKS since we're not signing responses - # In the future, we can generate and store keys for signing deep linking responses - jwks = {"keys": []} + # Return public keys for signature validation + jwks = get_jwks() return JsonResponse(jwks, content_type='application/json') @@ -390,7 +379,7 @@ class ManualSyncView(APIView): """Manually trigger NRPS sync""" try: # Get platform - platform = get_object_or_404(LTIPlatform, id=platform_id, active=True) + platform = get_object_or_404(LTIPlatform, id=platform_id) # Find resource link by context resource_link = LTIResourceLink.objects.filter(platform=platform, context_id=context_id).first()