From d032ee3baa9a3d108136185d8187e206f1ae83f9 Mon Sep 17 00:00:00 2001 From: Markos Gogoulos Date: Mon, 29 Dec 2025 16:35:47 +0200 Subject: [PATCH] this --- lti/admin.py | 18 +++--- lti/handlers.py | 19 +++--- lti/migrations/0001_initial.py | 13 ----- lti/models.py | 20 +------ lti/views.py | 103 +++++++++++++++++++++++---------- 5 files changed, 92 insertions(+), 81 deletions(-) diff --git a/lti/admin.py b/lti/admin.py index 9f44a52c..1f997c59 100644 --- a/lti/admin.py +++ b/lti/admin.py @@ -56,18 +56,16 @@ class LTIPlatformAdmin(admin.ModelAdmin): class LTIResourceLinkAdmin(admin.ModelAdmin): """Admin for LTI Resource Links""" - list_display = ['context_title', 'platform', 'category_link', 'rbac_group_link', 'launch_count', 'last_launch'] - list_filter = ['platform', 'created_at', 'last_launch'] + list_display = ['context_title', 'platform', 'category_link', 'rbac_group_link'] + list_filter = ['platform'] search_fields = ['context_id', 'context_title', 'resource_link_id'] - readonly_fields = ['created_at', 'last_launch', 'launch_count'] actions = ['sync_course_members'] fieldsets = ( ('Platform', {'fields': ('platform',)}), ('Context (Course)', {'fields': ('context_id', 'context_title', 'context_label')}), ('Resource Link', {'fields': ('resource_link_id', 'resource_link_title')}), - ('MediaCMS Mappings', {'fields': ('category', 'rbac_group', 'media')}), - ('Metrics', {'fields': ('launch_count', 'last_launch', 'created_at'), 'classes': ('collapse',)}), + ('MediaCMS Mappings', {'fields': ('category', 'rbac_group')}), ) def category_link(self, obj): @@ -134,14 +132,13 @@ class LTIResourceLinkAdmin(admin.ModelAdmin): class LTIUserMappingAdmin(admin.ModelAdmin): """Admin for LTI User Mappings""" - list_display = ['user_link', 'lti_user_id', 'platform', 'email', 'last_login'] + list_display = ['user_link', 'lti_user_id', 'platform', 'user_email', 'last_login'] list_filter = ['platform', 'created_at', 'last_login'] - search_fields = ['lti_user_id', 'user__username', 'user__email', 'email'] + search_fields = ['lti_user_id', 'user__username', 'user__email'] readonly_fields = ['created_at', 'last_login'] fieldsets = ( ('Mapping', {'fields': ('platform', 'lti_user_id', 'user')}), - ('User Info (Cached)', {'fields': ('email', 'given_name', 'family_name', 'name')}), ('Timestamps', {'fields': ('created_at', 'last_login')}), ) @@ -150,6 +147,11 @@ class LTIUserMappingAdmin(admin.ModelAdmin): user_link.short_description = 'MediaCMS User' + def user_email(self, obj): + return obj.user.email + + user_email.short_description = 'User Email' + @admin.register(LTIRoleMapping) class LTIRoleMappingAdmin(admin.ModelAdmin): diff --git a/lti/handlers.py b/lti/handlers.py index d44cc7e2..c064ed5d 100644 --- a/lti/handlers.py +++ b/lti/handlers.py @@ -23,12 +23,12 @@ from .models import LTIResourceLink, LTIRoleMapping, LTIUserMapping # Default LTI role mappings DEFAULT_LTI_ROLE_MAPPINGS = { - 'Instructor': {'global_role': 'advancedUser', 'group_role': 'manager'}, - 'TeachingAssistant': {'global_role': 'user', 'group_role': 'contributor'}, - 'Learner': {'global_role': 'user', 'group_role': 'member'}, - 'Student': {'global_role': 'user', 'group_role': 'member'}, - 'Administrator': {'global_role': 'manager', 'group_role': 'manager'}, - 'Faculty': {'global_role': 'advancedUser', 'group_role': 'manager'}, + 'Instructor': {'global_role': '', 'group_role': 'manager'}, + 'TeachingAssistant': {'global_role': '', 'group_role': 'contributor'}, + 'Learner': {'global_role': '', 'group_role': 'member'}, + 'Student': {'global_role': '', 'group_role': 'member'}, + 'Administrator': {'global_role': '', 'group_role': 'manager'}, + 'Faculty': {'global_role': '', 'group_role': 'manager'}, } @@ -107,7 +107,7 @@ def provision_lti_user(platform, claims): pass # Create mapping - LTIUserMapping.objects.create(platform=platform, lti_user_id=lti_user_id, user=user, email=email, given_name=given_name, family_name=family_name, name=name) + LTIUserMapping.objects.create(platform=platform, lti_user_id=lti_user_id, user=user) return user @@ -208,11 +208,6 @@ def provision_lti_context(platform, claims, resource_link_id): }, ) - # Update launch metrics - resource_link.launch_count += 1 - resource_link.last_launch = timezone.now() - resource_link.save(update_fields=['launch_count', 'last_launch']) - if not created: # Update relationships if needed if resource_link.category != category: diff --git a/lti/migrations/0001_initial.py b/lti/migrations/0001_initial.py index 5414ae95..34b01c59 100644 --- a/lti/migrations/0001_initial.py +++ b/lti/migrations/0001_initial.py @@ -53,21 +53,12 @@ class Migration(migrations.Migration): ('context_label', models.CharField(blank=True, help_text='Course short name/code', max_length=100)), ('resource_link_id', models.CharField(db_index=True, help_text='LTI resource link ID', max_length=255)), ('resource_link_title', models.CharField(blank=True, help_text='Resource link title', max_length=255)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('last_launch', models.DateTimeField(auto_now=True)), - ('launch_count', models.IntegerField(default=0, help_text='Number of times this resource has been launched')), ( 'category', models.ForeignKey( blank=True, help_text='Mapped MediaCMS category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lti_resource_links', to='files.category' ), ), - ( - 'media', - models.ForeignKey( - blank=True, help_text='Specific media for embedded links', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lti_resource_links', to='files.media' - ), - ), ('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resource_links', to='lti.ltiplatform')), ( 'rbac_group', @@ -147,10 +138,6 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('lti_user_id', models.CharField(db_index=True, help_text="LTI 'sub' claim (unique user identifier from platform)", max_length=255)), - ('email', models.EmailField(blank=True, max_length=254)), - ('given_name', models.CharField(blank=True, max_length=100)), - ('family_name', models.CharField(blank=True, max_length=100)), - ('name', models.CharField(blank=True, max_length=255)), ('created_at', models.DateTimeField(auto_now_add=True)), ('last_login', models.DateTimeField(auto_now=True)), ('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_mappings', to='lti.ltiplatform')), diff --git a/lti/models.py b/lti/models.py index a882e249..2654368b 100755 --- a/lti/models.py +++ b/lti/models.py @@ -4,27 +4,22 @@ from django.db import models class LTIPlatform(models.Model): """LTI 1.3 Platform (Moodle instance) configuration""" - # Basic identification name = models.CharField(max_length=255, unique=True, help_text="Platform name (e.g., 'Moodle Production')") platform_id = models.URLField(help_text="Platform's issuer URL (iss claim, e.g., https://moodle.example.com)") client_id = models.CharField(max_length=255, help_text="Client ID provided by the platform") - # OIDC endpoints auth_login_url = models.URLField(help_text="OIDC authentication endpoint URL") auth_token_url = models.URLField(help_text="OAuth2 token endpoint URL") auth_audience = models.URLField(blank=True, null=True, help_text="OAuth2 audience (optional)") - # JWK configuration 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 & features 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-provisioning settings 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") @@ -73,14 +68,8 @@ class LTIResourceLink(models.Model): # MediaCMS mappings category = models.ForeignKey('files.Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='lti_resource_links', help_text="Mapped MediaCMS category") - media = models.ForeignKey('files.Media', on_delete=models.SET_NULL, null=True, blank=True, related_name='lti_resource_links', help_text="Specific media for embedded links") rbac_group = models.ForeignKey('rbac.RBACGroup', on_delete=models.SET_NULL, null=True, blank=True, related_name='lti_resource_links', help_text="RBAC group for course members") - # Metrics - created_at = models.DateTimeField(auto_now_add=True) - last_launch = models.DateTimeField(auto_now=True) - launch_count = models.IntegerField(default=0, help_text="Number of times this resource has been launched") - class Meta: verbose_name = 'LTI Resource Link' verbose_name_plural = 'LTI Resource Links' @@ -101,13 +90,6 @@ class LTIUserMapping(models.Model): lti_user_id = models.CharField(max_length=255, db_index=True, help_text="LTI 'sub' claim (unique user identifier from platform)") user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='lti_mappings') - # User info from LTI (cached) - email = models.EmailField(blank=True) - given_name = models.CharField(max_length=100, blank=True) - family_name = models.CharField(max_length=100, blank=True) - name = models.CharField(max_length=255, blank=True) - - # Timestamps created_at = models.DateTimeField(auto_now_add=True) last_login = models.DateTimeField(auto_now=True) @@ -127,7 +109,7 @@ class LTIUserMapping(models.Model): class LTIRoleMapping(models.Model): """Maps LTI institutional roles to MediaCMS roles""" - GLOBAL_ROLE_CHOICES = [('user', 'Authenticated User'), ('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')] + GLOBAL_ROLE_CHOICES = [('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')] GROUP_ROLE_CHOICES = [('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')] diff --git a/lti/views.py b/lti/views.py index 67683d66..40375b29 100644 --- a/lti/views.py +++ b/lti/views.py @@ -10,7 +10,8 @@ Implements the LTI 1.3 / LTI Advantage flow: - Manual NRPS Sync """ -import json +import logging +import traceback import uuid from urllib.parse import urlencode @@ -41,9 +42,11 @@ from .handlers import ( provision_lti_user, validate_lti_session, ) -from .models import LTILaunchLog, LTIPlatform, LTIResourceLink, LTIUserMapping +from .models import LTILaunchLog, LTIPlatform, LTIResourceLink from .services import LTINRPSClient +logger = logging.getLogger(__name__) + def get_client_ip(request): """Get client IP address from request""" @@ -71,7 +74,13 @@ class OIDCLoginView(View): def handle_oidc_login(self, request): """Handle OIDC login initiation""" + print("=== OIDC Login Started ===", flush=True) + logger.info("=== OIDC Login Started ===") try: + # Get all request parameters for debugging + all_params = dict(request.GET.items()) if request.method == 'GET' else dict(request.POST.items()) + print(f"All OIDC request params: {all_params}", flush=True) + # Get target_link_uri and other OIDC params target_link_uri = request.GET.get('target_link_uri') or request.POST.get('target_link_uri') iss = request.GET.get('iss') or request.POST.get('iss') @@ -79,14 +88,23 @@ class OIDCLoginView(View): login_hint = request.GET.get('login_hint') or request.POST.get('login_hint') lti_message_hint = request.GET.get('lti_message_hint') or request.POST.get('lti_message_hint') + print(f"OIDC params - iss: {iss}, client_id: {client_id}, target: {target_link_uri}", flush=True) + print(f"login_hint: {login_hint}, lti_message_hint: {lti_message_hint}", flush=True) + logger.info(f"OIDC params - iss: {iss}, client_id: {client_id}, target: {target_link_uri}") + if not all([target_link_uri, iss, client_id]): + print("ERROR: Missing OIDC parameters", flush=True) + logger.error("Missing OIDC parameters") 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) + print(f"Found platform: {platform.name}", flush=True) + logger.info(f"Found platform: {platform.name}") # Create tool config for this platform tool_config = DjangoToolConfig.from_platform(platform) + print(f"Tool config: {tool_config._config}", flush=True) # Wrap Django request for PyLTI1p3 lti_request = DjangoRequest(request) @@ -95,15 +113,28 @@ class OIDCLoginView(View): session_service = DjangoSessionService(request) cookie_service = DjangoSessionService(request) # Using same service for cookies + print("Creating OIDCLogin...", flush=True) oidc_login = OIDCLogin(lti_request, tool_config, session_service=session_service, cookie_service=cookie_service) + print("OIDCLogin created successfully", flush=True) # Redirect to platform's authorization endpoint + print(f"Target link URI: {target_link_uri}", flush=True) + print(f"Auth login URL: {platform.auth_login_url}", flush=True) + try: + print("Calling enable_check_cookies()...", flush=True) oidc_with_cookies = oidc_login.enable_check_cookies() + print(f"Calling redirect({target_link_uri})...", flush=True) redirect_url = oidc_with_cookies.redirect(target_link_uri) + print(f"Redirect returned: '{redirect_url}'", flush=True) + print(f"OIDC redirect URL type: {type(redirect_url)}", flush=True) + print(f"OIDC redirecting to: {redirect_url}", flush=True) + logger.info(f"OIDC redirecting to: {redirect_url}") if not redirect_url: + print("PyLTI1p3 redirect failed, building URL manually...", flush=True) # Manual OIDC redirect construction with all required OAuth 2.0 parameters + state = str(uuid.uuid4()) nonce = str(uuid.uuid4()) @@ -128,14 +159,20 @@ class OIDCLoginView(View): params['lti_message_hint'] = lti_message_hint redirect_url = f"{platform.auth_login_url}?{urlencode(params)}" + print(f"Manually built redirect URL: {redirect_url}", flush=True) return HttpResponseRedirect(redirect_url) - except Exception: + except Exception as e: + print(f"ERROR in OIDC redirect: {str(e)}", flush=True) + + traceback.print_exc() raise except LtiException as e: + logger.error(f"LTI OIDC Login Error: {str(e)}") return render(request, 'lti/launch_error.html', {'error': 'OIDC Login Failed', 'message': str(e)}, status=400) - except Exception: + except Exception as e: + logger.error(f"OIDC Login Error: {str(e)}", exc_info=True) return JsonResponse({'error': 'Internal server error during OIDC login'}, status=500) @@ -150,6 +187,8 @@ class LaunchView(View): def post(self, request): """Handle LTI launch with JWT validation""" + print("=== LTI Launch Started ===", flush=True) + logger.info("=== LTI Launch Started ===") platform = None user = None error_message = '' @@ -162,12 +201,15 @@ class LaunchView(View): raise ValueError("Missing id_token in launch request") # Decode JWT to get issuer (without validation first) + unverified = jwt.decode(id_token, options={"verify_signature": False}) iss = unverified.get('iss') aud = unverified.get('aud') # Get platform platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=aud, active=True) + print(f"Launch from platform: {platform.name}", flush=True) + logger.info(f"Launch from platform: {platform.name}") # Create tool config tool_config = DjangoToolConfig.from_platform(platform) @@ -191,28 +233,6 @@ class LaunchView(View): launch_data = message_launch.get_launch_data() claims = self.sanitize_claims(launch_data) - # Print all JWT information for debugging - print("\n" + "=" * 80) - print("LTI JWT DECRYPTED - ALL CLAIMS:") - print("=" * 80) - print(f"Issuer (iss): {launch_data.get('iss')}") - print(f"Subject (sub): {launch_data.get('sub')}") - print(f"Email: {launch_data.get('email')}") - print(f"Given Name: {launch_data.get('given_name')}") - print(f"Family Name: {launch_data.get('family_name')}") - print(f"Full Name: {launch_data.get('name')}") - print(f"Roles: {launch_data.get('https://purl.imsglobal.org/spec/lti/claim/roles')}") - context = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/context', {}) - print(f"Context ID: {context.get('id')}") - print(f"Context Title: {context.get('title')}") - print(f"Context Label: {context.get('label')}") - resource_link = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {}) - print(f"Resource Link ID: {resource_link.get('id')}") - print(f"Resource Link Title: {resource_link.get('title')}") - print("\nFull Launch Data:") - print(json.dumps(launch_data, indent=2)) - print("=" * 80 + "\n") - # Extract key claims sub = launch_data.get('sub') resource_link = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {}) @@ -227,10 +247,16 @@ class LaunchView(View): return self.handle_deep_linking_launch(request, message_launch, platform, launch_data) # Provision user + print(f"Provisioning user, sub: {sub}", flush=True) + logger.info(f"Provisioning user, sub: {sub}") if platform.auto_create_users: user = provision_lti_user(platform, launch_data) + print(f"User provisioned: {user.username}", flush=True) + logger.info(f"User provisioned: {user.username}") 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") @@ -238,29 +264,38 @@ class LaunchView(View): # Provision context (category + RBAC group) if 'https://purl.imsglobal.org/spec/lti/claim/context' in launch_data: + logger.info("Provisioning context...") category, rbac_group, resource_link_obj = provision_lti_context(platform, launch_data, resource_link_id) + logger.info(f"Context provisioned: category={category.title if category else None}") # Apply roles apply_lti_roles(user, platform, roles, rbac_group) + logger.info(f"Roles applied: {roles}") else: # No context - might be a direct media embed resource_link_obj = None # Create session create_lti_session(request, user, message_launch, platform) + logger.info("LTI session created") # 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)) + logger.info("Launch logged") # Determine where to redirect redirect_url = self.determine_redirect(launch_data, resource_link_obj) + print(f"=== Launch Success - Redirecting to: {redirect_url} ===", flush=True) + logger.info(f"=== Launch Success - Redirecting to: {redirect_url} ===") return HttpResponseRedirect(redirect_url) except LtiException as e: error_message = f"LTI Launch Error: {str(e)}" + logger.error(error_message) except Exception as e: error_message = f"Launch Error: {str(e)}" + logger.error(error_message, exc_info=True) # Log failed launch if platform: @@ -337,15 +372,24 @@ class MyMediaLTIView(View): def get(self, request): """Display my media page""" + print(f"=== My Media LTI View - User: {request.user} ===", flush=True) + logger.info(f"=== My Media LTI View - User: {request.user} ===") + # Validate LTI session lti_session = validate_lti_session(request) + print(f"LTI session valid: {bool(lti_session)}", flush=True) + logger.info(f"LTI session valid: {bool(lti_session)}") if not lti_session: + print("ERROR: LTI session validation failed", flush=True) + logger.error("LTI session validation failed") return JsonResponse({'error': 'Not authenticated via LTI'}, status=403) # Redirect to user's profile page # The existing user profile page is already iframe-compatible profile_url = f"/user/{request.user.username}" + print(f"Redirecting to profile: {profile_url}", flush=True) + logger.info(f"Redirecting to profile: {profile_url}") return HttpResponseRedirect(profile_url) @@ -370,9 +414,9 @@ class EmbedMediaLTIView(View): can_view = True else: can_view = False - else: - # Fall back to public state check - can_view = media.state == 'public' + + if media.state in ["public", "unlisted"]: + can_view = True if not can_view: return JsonResponse({'error': 'Access denied', 'message': 'You do not have permission to view this media'}, status=403) @@ -440,4 +484,5 @@ class ManualSyncView(APIView): ) except Exception as e: + logger.error(f"NRPS sync error: {str(e)}", exc_info=True) return Response({'error': 'Sync failed', 'message': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)