diff --git a/lti/adapters.py b/lti/adapters.py index 7336e17c..9c904587 100644 --- a/lti/adapters.py +++ b/lti/adapters.py @@ -5,6 +5,7 @@ Provides Django-specific implementations for PyLTI1p3 interfaces """ import json +import logging import time from typing import Any, Dict, Optional @@ -23,6 +24,8 @@ from pylti1p3.tool_config import ToolConfAbstract from .models import LTIPlatform, LTIToolKeys +logger = logging.getLogger(__name__) + class DjangoRequest(Request): """Django request adapter for PyLTI1p3""" @@ -95,29 +98,58 @@ class DjangoMessageLaunch: class DjangoSessionService: - """Launch data storage using Django sessions""" + """ + Launch data storage using Django cache for state/nonce (to avoid race conditions) + and Django sessions for other data + """ def __init__(self, request): self.request = request self._session_key_prefix = 'lti1p3_' + self._cache_prefix = 'lti1p3_cache_' + + def _use_cache_for_key(self, key): + """Determine if this key should use cache (for concurrent access safety)""" + # Use cache for state and nonce to avoid race conditions in concurrent launches + return key.startswith('state-') or key.startswith('nonce-') def get_launch_data(self, key): - """Get launch data from session""" - session_key = self._session_key_prefix + key - data = self.request.session.get(session_key) + """Get launch data from cache or session depending on key type""" + if self._use_cache_for_key(key): + # Get from cache (atomic, no race condition) + cache_key = self._cache_prefix + key + data = cache.get(cache_key) + else: + # Get from session (for non-concurrent data) + session_key = self._session_key_prefix + key + data = self.request.session.get(session_key) + return json.loads(data) if data else None def save_launch_data(self, key, data): - """Save launch data to session""" - session_key = self._session_key_prefix + key - self.request.session[session_key] = json.dumps(data) - self.request.session.modified = True + """Save launch data to cache or session depending on key type""" + if self._use_cache_for_key(key): + # Save to cache with 10 minute expiration (atomic operation, no race condition) + cache_key = self._cache_prefix + key + cache.set(cache_key, json.dumps(data), timeout=600) + else: + # Save to session (for non-concurrent data) + session_key = self._session_key_prefix + key + self.request.session[session_key] = json.dumps(data) + self.request.session.modified = True + return True def check_launch_data_storage_exists(self, key): - """Check if launch data exists in session""" - session_key = self._session_key_prefix + key - return session_key in self.request.session + """Check if launch data exists in cache or session""" + if self._use_cache_for_key(key): + # Check cache + cache_key = self._cache_prefix + key + return cache.get(cache_key) is not None + else: + # Check session + session_key = self._session_key_prefix + key + return session_key in self.request.session def check_state_is_valid(self, state, nonce): """Check if state is valid - state is for CSRF protection, nonce is validated separately by JWT""" diff --git a/lti/handlers.py b/lti/handlers.py index aa7b3fef..30b70cdc 100644 --- a/lti/handlers.py +++ b/lti/handlers.py @@ -9,6 +9,7 @@ Provides functions to: """ import hashlib +import logging from allauth.account.models import EmailAddress from django.conf import settings @@ -21,6 +22,8 @@ from users.models import User from .models import LTIResourceLink, LTIRoleMapping, LTIUserMapping +logger = logging.getLogger(__name__) + DEFAULT_LTI_ROLE_MAPPINGS = { 'Instructor': {'global_role': '', 'group_role': 'manager'}, 'TeachingAssistant': {'global_role': '', 'group_role': 'contributor'}, @@ -318,6 +321,10 @@ def create_lti_session(request, user, launch_data, platform): timeout = getattr(settings, 'LTI_SESSION_TIMEOUT', 3600) request.session.set_expiry(timeout) + # CRITICAL: Explicitly save session before redirect (for cross-site contexts) + request.session.modified = True + request.session.save() + return True @@ -328,6 +335,7 @@ def validate_lti_session(request): Returns: Dict of LTI session data or None """ + lti_session = request.session.get('lti_session') if not lti_session: diff --git a/lti/views.py b/lti/views.py index a489b2c3..73c417e7 100644 --- a/lti/views.py +++ b/lti/views.py @@ -10,6 +10,8 @@ Implements the LTI 1.3 / LTI Advantage flow: - Manual NRPS Sync """ +import base64 +import json import logging import traceback import uuid @@ -112,9 +114,6 @@ class OIDCLoginView(View): # Encode lti_message_hint IN the state parameter for retry reliability # This survives session/cookie issues since it's passed through URLs - import base64 - import json as json_module - state_data = {'uuid': state_uuid} if lti_message_hint: state_data['hint'] = lti_message_hint @@ -122,7 +121,7 @@ class OIDCLoginView(View): state_data['media_token'] = media_token # Encode as base64 URL-safe string - state = base64.urlsafe_b64encode(json_module.dumps(state_data).encode()).decode().rstrip('=') + state = base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode().rstrip('=') launch_data = {'target_link_uri': target_link_uri, 'nonce': nonce} # Store cmid if provided (including 0 for filter-based launches) @@ -159,13 +158,6 @@ class OIDCLoginView(View): redirect_url = f"{platform.auth_login_url}?{urlencode(params)}" - # Debug logging for filter launches - logger.error(f"[OIDC LOGIN DEBUG] Redirecting to: {redirect_url}") - logger.error(f"[OIDC LOGIN DEBUG] Has lti_message_hint: {bool(lti_message_hint)}") - logger.error(f"[OIDC LOGIN DEBUG] Has media_token: {bool(media_token)}") - logger.error(f"[OIDC LOGIN DEBUG] media_token value: {media_token}") - logger.error(f"[OIDC LOGIN DEBUG] cmid: {cmid}") - return HttpResponseRedirect(redirect_url) except Exception: raise @@ -194,16 +186,11 @@ class LaunchView(View): error_message = '' claims = {} - logger.error("[LTI LAUNCH DEBUG] Launch view POST called") - # Extract media_token from state parameter if present (for filter launches) media_token_from_state = None state = request.POST.get('state') if state: try: - import base64 - import json as json_module - # Add padding if needed for base64 decode padding = 4 - (len(state) % 4) if padding and padding != 4: @@ -212,12 +199,10 @@ class LaunchView(View): state_padded = state state_decoded = base64.urlsafe_b64decode(state_padded.encode()).decode() - state_data = json_module.loads(state_decoded) + state_data = json.loads(state_decoded) media_token_from_state = state_data.get('media_token') - if media_token_from_state: - logger.error(f"[LTI LAUNCH DEBUG] Extracted media_token from state: {media_token_from_state}") - except Exception as e: - logger.error(f"[LTI LAUNCH DEBUG] Could not decode state for media_token (might be plain UUID): {e}") + except Exception: + pass try: id_token = request.POST.get('id_token') @@ -249,37 +234,25 @@ class LaunchView(View): launch_data = message_launch.get_launch_data() claims = self.sanitize_claims(launch_data) - logger.error(f"[LTI LAUNCH DEBUG] Full launch_data keys: {list(launch_data.keys())}") - logger.error(f"[LTI LAUNCH DEBUG] Launch data has custom claim: {'https://purl.imsglobal.org/spec/lti/claim/custom' in launch_data}") - - # Extract and log custom claims EARLY before any other processing + # Extract custom claims and inject media_token from state if present try: custom_claims = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/custom', {}) - logger.error(f"[LTI LAUNCH DEBUG] Custom claims type: {type(custom_claims)}") - logger.error(f"[LTI LAUNCH DEBUG] Custom claims keys: {list(custom_claims.keys()) if isinstance(custom_claims, dict) else 'not a dict'}") - logger.error(f"[LTI LAUNCH DEBUG] Custom claims full content: {custom_claims}") - logger.error(f"[LTI LAUNCH DEBUG] Has media_friendly_token: {bool(custom_claims.get('media_friendly_token'))}") # Inject media_token from state if present (for filter launches) if media_token_from_state and not custom_claims.get('media_friendly_token'): custom_claims['media_friendly_token'] = media_token_from_state # Update launch_data with the modified custom claims launch_data['https://purl.imsglobal.org/spec/lti/claim/custom'] = custom_claims - logger.error(f"[LTI LAUNCH DEBUG] Injected media_friendly_token from state: {media_token_from_state}") - except Exception as e: - logger.error(f"[LTI LAUNCH DEBUG] Error getting custom claims: {e}") + except Exception: custom_claims = {} 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', []) - message_type = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/message_type') - - if message_type == 'LtiDeepLinkingRequest': - return self.handle_deep_linking_launch(request, message_launch, platform, launch_data) - + # IMPORTANT: Provision user and create session BEFORE handling deep linking + # This ensures filter launches (which are deep linking) have authenticated user user = provision_lti_user(platform, launch_data) if 'https://purl.imsglobal.org/spec/lti/claim/context' in launch_data: @@ -291,25 +264,54 @@ class LaunchView(View): create_lti_session(request, user, message_launch, platform) - # Check for media_friendly_token in custom claims (for both deep linking and filter launches) - media_token = custom_claims.get('media_friendly_token') - if media_token: - logger.error(f"[LTI LAUNCH DEBUG] Found media_friendly_token in custom claims: {media_token}") + message_type = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/message_type') + + if message_type == 'LtiDeepLinkingRequest': + return self.handle_deep_linking_launch(request, message_launch, platform, launch_data) # Clear retry counter on successful launch if 'lti_retry_count' in request.session: - retry_attempts = request.session['lti_retry_count'] del request.session['lti_retry_count'] - logger.error(f"========== [LTI RETRY SUCCESS] Launch succeeded after {retry_attempts} retry attempt(s) ==========") - else: - logger.error("[LTI LAUNCH SUCCESS] Launch succeeded on first attempt (no retry needed)") LTILaunchLog.objects.create(platform=platform, user=user, resource_link=resource_link_obj, launch_type='resource_link', success=True, claims=claims) redirect_url = self.determine_redirect(launch_data, resource_link_obj) - logger.error(f"[LTI LAUNCH DEBUG] Redirecting to: {redirect_url}") - return HttpResponseRedirect(redirect_url) + # Use HTML meta refresh instead of HTTP redirect to ensure session cookie is sent + # In cross-site/iframe contexts, HTTP 302 redirects may not preserve session cookies + html_content = f""" + + +
+ +Loading MediaCMS...
+If you are not redirected, click here
+Loading...
+ + + """ + request.session.modified = True + return HttpResponse(html_content, content_type='text/html') class JWKSView(View):